Compare commits

..
4 Commits
Author SHA1 Message Date
dmg e5de6b76e7 feat(admin): add Tyrant point grant command
Release / release (push) Successful in 2m27s
CI / build (push) Successful in 1m4s
2026-09-04 22:12:47 -04:00
dmg 4b6c709157 fix(arena): keep vigilante victory private
Release / release (push) Successful in 4m34s
CI / build (push) Successful in 1m16s
2026-09-04 22:05:28 -04:00
dmg 17a5c66575 fix(arena): move winners outside after victory 2026-09-04 22:01:52 -04:00
dmg f0dbde17b8 fix(arena): improve containment and boss idle
Release / release (push) Successful in 2m31s
CI / build (push) Successful in 1m8s
2026-08-23 18:13:24 -04:00
26 changed files with 535 additions and 41 deletions
+25
View File
@@ -6,6 +6,31 @@ description: Chronological record of material decisions affecting the Spigot Tyr
# Spigot Tyrant Design Log
## 2026-09-04 — Administrative Tyrant point grant completed
- Added `/tyrantadmin grantpoint` so a permitted administrator can grant the current Tyrant exactly one level and one unspent unlock choice during a running game.
- Grants preserve purchases and unrelated state, persist immediately, report updated progression, reject vacancies and non-running games safely, and appear in contextual command completion.
- Verified progression and command behavior, permission enforcement, completion, 127 automated tests, compiler warnings, and packaging with `./gradlew clean check jar`.
## 2026-09-04 — Vigilante arena victory kept private
- Removed the server-wide Vigilante arena victory announcement that exposed the new Vigilante's identity.
- The winning Vigilante now receives a private confirmation, while the publicly identifiable Tyrant victory announcement remains unchanged.
- Verified private Vigilante and public Tyrant messaging, 121 automated tests, compiler warnings, and packaging with `./gradlew clean check jar`.
## 2026-09-04 — Arena winners safely extracted
- Corrected shared-role arena completion so a successful Tyrant or Vigilante challenger is moved safely outside immediately after assignment and barrier removal.
- Prevented the next challenge's containment enforcement from treating the previous winner as an unauthorized entrant inside the arena.
- Verified both role-victory paths, 121 automated tests, compiler warnings, and packaging with `./gradlew clean check jar`.
## 2026-08-23 — Arena containment and idle boss corrected
- Corrected shared-arena containment with a validated 12-block barrier wall extending below the arena floor and active-fight block placement and breaking protection.
- Kept red or blue role particles visible at multiple wall heights during fights so challengers can see the otherwise invisible barrier from inside.
- Arena bosses now remain AI-disabled at the center until an eligible challenger enters, move normally within the arena during combat, and return healed and idle only on reset or boundary escape.
- Verified wall geometry, safe restoration, boundary visibility policy, build protection, idle/combat boss transitions, configuration validation, compiler warnings, 119 automated tests, and packaging with `./gradlew clean check jar`.
## 2026-08-23 — Shared Tyrant and Vigilante arena succession completed
- Completed US-020 and reopened related succession, inactivity, administration, configuration, and Vigilante-arena stories so non-player Tyrant replacement is earned rather than random.
@@ -18,6 +18,9 @@ As a **server operator**, I want to start, pause, resume, and inspect the game s
- [x] Role and class effects are suppressed while paused and restored when play resumes.
- [x] Administrative commands require the `spigottyrant.admin` permission, granted to server operators by default.
- [x] Administrators can inspect lifecycle state, roles, classes, Followers, purchases, levels, cooldowns, participation, legacy pending selections, and the configured shared role arena location.
- [x] `/tyrantadmin grantpoint` requires administrative permission and grants the current Tyrant exactly one level and one unspent unlock choice.
- [x] A successful administrative point grant persists immediately and confirms the updated level and choice count to the administrator.
- [x] If the game is not running or has no current Tyrant, an administrative point grant changes nothing and reports a clear error.
- [x] Destructive administrative operations require explicit confirmation.
## Related
@@ -12,6 +12,8 @@ As the **Tyrant**, I want to earn one meaningful choice for defeating the Vigila
## Acceptance criteria
- [x] The Tyrant gains one level and one unlock choice only when personally credited with killing the current Vigilante.
- [x] An administrator can grant the current Tyrant one level and one unlock choice without changing purchases or unrelated game state.
- [x] Each successful administrative grant adds exactly one level and one choice, including across repeated invocations.
- [x] A Vigilante death caused by another player, the environment, or the Vigilante does not level the Tyrant.
- [x] Available purchases are Assassin, Fixer, Tamer, roster intelligence, permanent Resistance, and permanent Strength.
- [x] Each class or ability can be purchased at most once during a reign.
@@ -11,7 +11,7 @@ As a **server operator**, I want configurable and durable game behavior so that
## Acceptance criteria
- [x] Configuration covers ranges, durations, cooldowns, inactivity and maintenance intervals, effect levels and caps, mob restrictions, item materials and names, messages, timer behavior, and shared-role-arena boss balance.
- [x] Configuration covers ranges, durations, cooldowns, inactivity and maintenance intervals, effect levels and caps, mob restrictions, item materials and names, messages, timer behavior, shared-role-arena boss balance, and a validated barrier wall height defaulting to 12 blocks.
- [x] Defaults match the approved user stories, including a 50-block Tyrant range and Follower range, seven-day opt-out, 48-hour inactivity, and one-minute role maintenance.
- [x] Invalid required configuration prevents partial plugin initialization and produces a clear server log message.
- [x] UUID-keyed state stores lifecycle, current and pending roles, assignments, login history, participation, reign progression, purchases, choices, cooldowns, paused time, item delivery, and captured mobs; the administrator-set arena center is durable across restarts.
@@ -22,6 +22,7 @@ As a **player or administrator**, I want contextual command suggestions so that
- [x] `/tyrantadmin start` suggests online Tyrant and Vigilante candidates.
- [x] `/tyrantadmin reset` suggests `confirm`.
- [x] `/tyrantadmin arena` suggests `set` and `status`.
- [x] `/tyrantadmin` suggests `grantpoint`, which accepts no additional arguments.
- [x] Suggestions are filtered case-insensitively by the partially typed argument.
- [x] Suggestions never include syntactically invalid options for the current argument position.
- [x] Console completion works for administrative commands without exposing player-only commands as executable console actions.
@@ -29,7 +30,7 @@ As a **player or administrator**, I want contextual command suggestions so that
## Validation
Automated tests verify root syntax, unlocks, classes, confirmations, eligible recruits, current Followers, permission gating, online administrative candidates, argument positions, and case-insensitive prefix filtering. The complete `./gradlew clean check jar` lifecycle passes.
Automated tests verify root syntax, unlocks, classes, confirmations, eligible recruits, current Followers, permission gating, online administrative candidates, the argument-free `grantpoint` command, argument positions, and case-insensitive prefix filtering. The complete `./gradlew clean check jar` lifecycle passes.
## Related
@@ -17,12 +17,17 @@ As an **opted-in participant**, I want a visible one-player boss challenge for a
- [x] The challenge boss defaults to an armored, 80-health enhanced zombie, does not burn in sunlight, and remains within a default 10-block-radius arena.
- [x] All online opted-in players are told when the role opens and receive the arena world and coordinates; opted-in players receive the same notice when they log in while it remains open, and opted-out players do not.
- [x] Exactly one eligible opted-in non-Tyrant player may enter as challenger, including when entry is attempted by walking or teleportation.
- [x] Starting a fight replaces the passive particle boundary with temporary actual `BARRIER` blocks that are removed safely without permanently changing existing blocks.
- [x] Starting a fight builds temporary actual `BARRIER` blocks from one block below the arena floor through a default 12-block wall height, without permanently changing existing blocks.
- [x] Role-colored boundary particles remain visible from inside and outside an active fight at multiple wall heights.
- [x] Any non-challenger who enters or respawns inside the arena is moved safely outside it.
- [x] Existing mobs in the arena and mobs that spawn or enter it are removed while preserving the arena boss.
- [x] With no challenger, the boss remains AI-disabled at the arena center; entry enables its AI and target, while reset restores full health, center position, no target, and disabled AI.
- [x] The boss attacks only the active challenger, and only that challenger can damage the boss.
- [x] Challenger death, disconnect, or leaving by any mechanism ends the fight and resets the boss to full health at its spawn; death counts as leaving and the same player may retry immediately.
- [x] Block placement and breaking are denied inside the arena during an active fight so players cannot build over or modify the boundary.
- [x] Killing the boss assigns the challenger as Vigilante, removes the boss and barriers, and restores the passive particle boundary.
- [x] After winning, the new Vigilante is moved safely outside the arena before normal arena enforcement resumes.
- [x] The Vigilante winner receives a private confirmation, and no server-wide victory message names or reveals the new Vigilante.
- [x] Pausing, resetting, restarting, reloading, and repeated or concurrent events cannot duplicate the boss, barriers, challenger, or Vigilante assignment.
- [x] If the arena is not configured when a challenge should open, the Vigilante remains vacant and administrators receive a clear warning.
@@ -15,9 +15,10 @@ As an **opted-in participant**, I want to earn a vacant Tyrant role through the
- [x] Tyrant death without a player killer, Tyrant inactivity, and Tyrant relinquishment end the reign, clear all role-side assignments, and open the shared arena with no active or pending Tyrant.
- [x] The shared arena opens only one role challenge at a time: a vacant Tyrant takes priority, and the Vigilante challenge can open only after a Tyrant becomes active.
- [x] The passive arena boundary uses red particles for an open Tyrant challenge, blue particles for an open Vigilante challenge, and a neutral marker while neither role challenge is open.
- [x] The Tyrant challenge uses the existing configured arena, boss balance, mob exclusion, one-challenger barrier, containment, reset, notification, and missing-location rules.
- [x] The Tyrant challenge uses the existing configured arena, boss balance, tall visible one-challenger barrier, block-change protection, idle-center boss behavior, containment, reset, notification, and missing-location rules.
- [x] An eligible opted-in participant, including the former Tyrant, may challenge for the vacant Tyrant role; an active Tyrant cannot enter any arena fight.
- [x] Defeating the Tyrant challenge boss assigns the challenger as Tyrant with level zero, no purchases, and exactly one starting unlock choice.
- [x] After winning, the new Tyrant is moved safely outside before any subsequent Vigilante challenge opens.
- [x] Completing the Tyrant challenge immediately permits the blue Vigilante challenge to open at the same location when the Vigilante role is vacant.
- [x] Legacy pending Tyrant replacement state is cleared into an arena vacancy without granting a role.
- [x] Pausing, resetting, restarting, reloading, and repeated or concurrent events cannot duplicate either role boss, challenger, barrier, or assignment.
@@ -0,0 +1,22 @@
package games.dmg.spigottyrant;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.bukkit.entity.Zombie;
final class ArenaBossState {
private ArenaBossState() {
}
static void idle(Zombie boss, Location center, double health) {
boss.setAI(false);
boss.setTarget(null);
boss.setHealth(health);
boss.teleport(center);
}
static void engage(Zombie boss, Player challenger) {
boss.setAI(true);
boss.setTarget(challenger);
}
}
@@ -0,0 +1,20 @@
package games.dmg.spigottyrant;
final class ArenaBuildProtection {
private ArenaBuildProtection() {
}
static boolean isProtected(
boolean fightActive,
ArenaLocation center,
double radius,
String worldName,
double x,
double y,
double z
) {
return fightActive && ArenaGeometry.contains(
center, radius, worldName, x, y, z
);
}
}
@@ -13,6 +13,7 @@ public record PluginSettings(
double tyrantRangeBlocks,
double followerRangeBlocks,
double vigilanteArenaRadiusBlocks,
int arenaBarrierHeightBlocks,
double vigilanteBossHealth,
double vigilanteBossDamage,
double vigilanteBossArmor,
@@ -53,6 +54,11 @@ public record PluginSettings(
requireFiniteRange(
vigilanteArenaRadiusBlocks, 1.0, 512.0, "vigilante-arena-radius-blocks"
);
if (arenaBarrierHeightBlocks < 3 || arenaBarrierHeightBlocks > 128) {
throw new IllegalArgumentException(
"arena-barrier-height-blocks must be between 3 and 128"
);
}
requireFiniteRange(vigilanteBossHealth, 1.0, 1024.0, "vigilante-boss-health");
requireFiniteRange(vigilanteBossDamage, 0.1, 2048.0, "vigilante-boss-damage");
requireFiniteRange(vigilanteBossArmor, 0.0, 30.0, "vigilante-boss-armor");
@@ -100,6 +106,7 @@ public record PluginSettings(
decimal(values, "tyrant-range-blocks", 50.0),
decimal(values, "follower-range-blocks", 50.0),
decimal(values, "vigilante-arena-radius-blocks", 10.0),
integer(values, "arena-barrier-height-blocks", 12),
decimal(values, "vigilante-boss-health", 80.0),
decimal(values, "vigilante-boss-damage", 8.0),
decimal(values, "vigilante-boss-armor", 10.0),
@@ -8,22 +8,27 @@ import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
final class VigilanteArenaBarrier {
final class RoleArenaBarrier {
private final Map<Location, BlockData> replaced = new LinkedHashMap<>();
private boolean built;
void build(World world, ArenaLocation center, double radius) {
void build(
World world,
ArenaLocation center,
double radius,
int wallHeight
) {
if (built) {
return;
}
built = true;
int samples = Math.max(64, (int) Math.ceil(2.0 * Math.PI * radius * 2.0));
int baseY = (int) Math.floor(center.y());
int baseY = (int) Math.floor(center.y()) - 1;
for (int index = 0; index < samples; index++) {
double angle = 2.0 * Math.PI * index / samples;
int x = (int) Math.floor(center.x() + Math.cos(angle) * radius);
int z = (int) Math.floor(center.z() + Math.sin(angle) * radius);
for (int y = baseY; y < baseY + 3; y++) {
for (int y = baseY; y < baseY + wallHeight; y++) {
Block block = world.getBlockAt(x, y, z);
Location location = block.getLocation();
if (replaced.containsKey(location) || !isAir(block.getType())) {
@@ -26,6 +26,8 @@ import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDeathEvent;
import org.bukkit.event.entity.EntityTargetLivingEntityEvent;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerQuitEvent;
@@ -42,7 +44,7 @@ public final class RoleArenaController implements Listener, Runnable {
new VigilanteArenaSuccessionService();
private final TyrantArenaSuccessionService tyrantSuccession =
new TyrantArenaSuccessionService();
private final VigilanteArenaBarrier barrier = new VigilanteArenaBarrier();
private final RoleArenaBarrier barrier = new RoleArenaBarrier();
private final Set<UUID> relocating = new HashSet<>();
private UUID bossId;
private UUID challengerId;
@@ -121,10 +123,13 @@ public final class RoleArenaController implements Listener, Runnable {
)) {
if (challengerId != null) {
resetFight();
} else {
ArenaBossState.idle(
boss, toBukkit(world, arena), settings.vigilanteBossHealth()
);
}
boss.setTarget(null);
} else {
boss.setTarget(challenger);
ArenaBossState.engage(boss, challenger);
}
for (Player player : world.getPlayers()) {
enforcePosition(player, null, player.getLocation());
@@ -193,6 +198,24 @@ public final class RoleArenaController implements Listener, Runnable {
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onBlockPlace(BlockPlaceEvent event) {
if (protects(event.getBlock().getLocation())) {
event.setCancelled(true);
event.getPlayer().sendMessage(ChatColor.RED
+ "Blocks cannot be changed during an arena fight.");
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onBlockBreak(BlockBreakEvent event) {
if (protects(event.getBlock().getLocation())) {
event.setCancelled(true);
event.getPlayer().sendMessage(ChatColor.RED
+ "Blocks cannot be changed during an arena fight.");
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onCombust(EntityCombustEvent event) {
if (event.getEntity().getUniqueId().equals(bossId)) {
@@ -274,12 +297,15 @@ public final class RoleArenaController implements Listener, Runnable {
challengerId = player.getUniqueId();
World world = server.getWorld(arena.worldName());
if (world != null) {
barrier.build(world, arena, settings.vigilanteArenaRadiusBlocks());
barrier.build(
world, arena, settings.vigilanteArenaRadiusBlocks(),
settings.arenaBarrierHeightBlocks()
);
Zombie boss = boss(world);
if (boss != null) {
boss.setHealth(settings.vigilanteBossHealth());
boss.teleport(toBukkit(world, arena));
boss.setTarget(player);
ArenaBossState.engage(boss, player);
}
}
player.sendMessage(ChatColor.GOLD + "Defeat the arena boss to become "
@@ -312,9 +338,10 @@ public final class RoleArenaController implements Listener, Runnable {
World world = server.getWorld(configured.orElseThrow().worldName());
Zombie boss = world == null ? null : boss(world);
if (boss != null && !boss.isDead()) {
boss.setHealth(settings.vigilanteBossHealth());
boss.teleport(toBukkit(world, configured.orElseThrow()));
boss.setTarget(null);
ArenaBossState.idle(
boss, toBukkit(world, configured.orElseThrow()),
settings.vigilanteBossHealth()
);
} else {
bossId = null;
}
@@ -349,6 +376,7 @@ public final class RoleArenaController implements Listener, Runnable {
zombie.setCustomNameVisible(true);
zombie.setRemoveWhenFarAway(false);
zombie.setCanPickupItems(false);
zombie.setAI(false);
zombie.addScoreboardTag(BOSS_TAG);
setAttribute(zombie, Attribute.MAX_HEALTH, settings.vigilanteBossHealth());
setAttribute(zombie, Attribute.ATTACK_DAMAGE, settings.vigilanteBossDamage());
@@ -372,7 +400,7 @@ public final class RoleArenaController implements Listener, Runnable {
&& !zombie.isDead() ? zombie : null;
}
private void assignRole(UUID playerId, ArenaRole role) {
void assignRole(UUID playerId, ArenaRole role) {
LifecycleState assigned;
try {
assigned = role == ArenaRole.TYRANT
@@ -386,13 +414,42 @@ public final class RoleArenaController implements Listener, Runnable {
stateManager.saveIfDirty();
challengerId = null;
barrier.clear();
String name = Optional.ofNullable(server.getPlayer(playerId))
Player winner = server.getPlayer(playerId);
moveWinnerOutside(winner);
if (role == ArenaRole.VIGILANTE) {
if (winner != null) {
winner.sendMessage(ChatColor.YELLOW
+ "You defeated the arena boss and became the Vigilante!");
}
return;
}
String name = Optional.ofNullable(winner)
.map(Player::getName).orElse(playerId.toString());
String message = ChatColor.YELLOW + name + " defeated the arena boss and is the "
+ readable(role) + "!";
server.getOnlinePlayers().forEach(player -> player.sendMessage(message));
}
private void moveWinnerOutside(Player winner) {
Optional<ArenaLocation> configured = locations.location();
if (winner == null || configured.isEmpty()) {
return;
}
ArenaLocation arena = configured.orElseThrow();
Location winnerLocation = winner.getLocation();
if (!contains(arena, winnerLocation)) {
return;
}
relocating.add(winner.getUniqueId());
try {
winner.teleport(
outside(arena, winnerLocation), PlayerTeleportEvent.TeleportCause.PLUGIN
);
} finally {
relocating.remove(winner.getUniqueId());
}
}
private void announceVacancy(ArenaLocation arena, ArenaRole role) {
String message = vacancyMessage(arena, role);
for (Player player : server.getOnlinePlayers()) {
@@ -442,29 +499,46 @@ public final class RoleArenaController implements Listener, Runnable {
}
private void drawBoundary(World world, ArenaLocation arena, ArenaRole role) {
if (barrier.active()) {
return;
}
double radius = settings.vigilanteArenaRadiusBlocks();
Color color = boundaryColor(role);
Particle.DustOptions dust = color == null
? null : new Particle.DustOptions(color, 1.25F);
for (int index = 0; index < 48; index++) {
double angle = 2.0 * Math.PI * index / 48.0;
Location point = new Location(
world,
arena.x() + Math.cos(angle) * radius,
arena.y() + 1.0,
arena.z() + Math.sin(angle) * radius
);
if (dust == null) {
world.spawnParticle(Particle.END_ROD, point, 1, 0.0, 0.0, 0.0, 0.0);
} else {
world.spawnParticle(Particle.DUST, point, 1, 0.0, 0.0, 0.0, 0.0, dust);
int[] heightOffsets = boundaryHeightOffsets(
barrier.active(), settings.arenaBarrierHeightBlocks()
);
for (int heightOffset : heightOffsets) {
for (int index = 0; index < 48; index++) {
double angle = 2.0 * Math.PI * index / 48.0;
Location point = new Location(
world,
arena.x() + Math.cos(angle) * radius,
arena.y() + heightOffset,
arena.z() + Math.sin(angle) * radius
);
if (dust == null) {
world.spawnParticle(
Particle.END_ROD, point, 1, 0.0, 0.0, 0.0, 0.0
);
} else {
world.spawnParticle(
Particle.DUST, point, 1, 0.0, 0.0, 0.0, 0.0, dust
);
}
}
}
}
private boolean protects(Location location) {
Optional<ArenaLocation> configured = locations.location();
World world = location.getWorld();
return configured.isPresent() && world != null
&& ArenaBuildProtection.isProtected(
barrier.active(), configured.orElseThrow(),
settings.vigilanteArenaRadiusBlocks(), world.getName(),
location.getX(), location.getY(), location.getZ()
);
}
private boolean contains(ArenaLocation arena, Location location) {
World world = location.getWorld();
return world != null && ArenaGeometry.contains(
@@ -535,6 +609,18 @@ public final class RoleArenaController implements Listener, Runnable {
}
}
static int[] boundaryHeightOffsets(boolean fightActive, int wallHeight) {
if (!fightActive) {
return new int[] {1};
}
int count = (wallHeight - 2) / 3 + 1;
int[] offsets = new int[count];
for (int index = 0; index < count; index++) {
offsets[index] = 1 + index * 3;
}
return offsets;
}
static Color boundaryColor(ArenaRole role) {
if (role == null) {
return null;
@@ -17,6 +17,7 @@ public final class TyrantAdminCommand implements CommandExecutor {
private final ManagedRoleEffects effects;
private final Clock clock;
private final ArenaLocationStore arenaLocations;
private final TyrantProgressionService progression = new TyrantProgressionService();
public TyrantAdminCommand(
TyrantStateManager stateManager,
@@ -69,6 +70,7 @@ public final class TyrantAdminCommand implements CommandExecutor {
case "resume" -> resume(sender);
case "reset" -> reset(sender, arguments);
case "arena" -> arena(sender, arguments);
case "grantpoint" -> grantPoint(sender, arguments);
default -> usage(sender);
}
} catch (IllegalArgumentException | IllegalStateException exception) {
@@ -141,6 +143,20 @@ public final class TyrantAdminCommand implements CommandExecutor {
sender.sendMessage(ChatColor.GREEN + "Tyrant game reset.");
}
private void grantPoint(CommandSender sender, String[] arguments) {
if (arguments.length != 1) {
sender.sendMessage(ChatColor.YELLOW + "Usage: /tyrantadmin grantpoint");
return;
}
PersistentState snapshot = stateManager.snapshot();
GameState updated = progression.grantPoint(snapshot.game());
stateManager.replaceState(new LifecycleState(updated, snapshot.players()));
stateManager.saveIfDirty();
sender.sendMessage(ChatColor.GREEN + "Granted the Tyrant one point; now level "
+ updated.tyrantLevel() + " and " + updated.unspentChoices()
+ " unspent choices.");
}
private void arena(CommandSender sender, String[] arguments) {
if (arguments.length == 1 || arguments[1].equalsIgnoreCase("status")) {
sender.sendMessage("Shared role arena: " + arenaLocations.location()
@@ -194,6 +210,6 @@ public final class TyrantAdminCommand implements CommandExecutor {
private static void usage(CommandSender sender) {
sender.sendMessage(ChatColor.YELLOW
+ "Usage: /tyrantadmin <status|start|pause|resume|reset confirm|arena set>");
+ "Usage: /tyrantadmin <status|start|pause|resume|reset confirm|arena set|grantpoint>");
}
}
@@ -11,7 +11,7 @@ import org.bukkit.entity.Player;
public final class TyrantAdminTabCompleter implements TabCompleter {
private static final String PERMISSION = "spigottyrant.admin";
private static final List<String> SUBCOMMANDS = List.of(
"status", "start", "pause", "resume", "reset", "arena"
"status", "start", "pause", "resume", "reset", "arena", "grantpoint"
);
private final OnlinePlayerDirectory onlinePlayers;
@@ -20,6 +20,25 @@ public final class TyrantProgressionService {
);
}
public GameState grantPoint(GameState state) {
if (state.lifecycle() != GameLifecycle.RUNNING) {
throw new IllegalStateException("Tyrant game must be running.");
}
if (state.tyrantId().isEmpty()) {
throw new IllegalStateException("There is no current Tyrant.");
}
if (state.tyrantLevel() == Integer.MAX_VALUE
|| state.unspentChoices() == Integer.MAX_VALUE) {
throw new IllegalStateException("Tyrant progression cannot be increased further.");
}
return copyProgress(
state,
state.tyrantLevel() + 1,
state.unspentChoices() + 1,
state.purchases()
);
}
public PurchaseResult purchase(
GameState state,
UUID buyerId,
+1
View File
@@ -4,6 +4,7 @@ follower-range-blocks: 50
# Shared Tyrant and Vigilante arena and boss
vigilante-arena-radius-blocks: 10
arena-barrier-height-blocks: 12
vigilante-boss-health: 80
vigilante-boss-damage: 8
vigilante-boss-armor: 10
+1 -1
View File
@@ -13,7 +13,7 @@ commands:
usage: /vigilante <menu|item|invite <player>|accept|dismiss <player>|leave>
tyrantadmin:
description: Administer the Spigot Tyrant game.
usage: /tyrantadmin <status|start|pause|resume|reset confirm|arena <set|status>>
usage: /tyrantadmin <status|start|pause|resume|reset confirm|arena <set|status>|grantpoint>
permission: spigottyrant.admin
permissions:
spigottyrant.admin:
@@ -0,0 +1,35 @@
package games.dmg.spigottyrant;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.bukkit.entity.Zombie;
import org.junit.jupiter.api.Test;
final class ArenaBossStateTest {
@Test
void idleBossIsCenteredHealedUntargetedAndFrozen() {
Zombie boss = mock(Zombie.class);
Location center = mock(Location.class);
ArenaBossState.idle(boss, center, 80.0);
verify(boss).setAI(false);
verify(boss).setTarget(null);
verify(boss).setHealth(80.0);
verify(boss).teleport(center);
}
@Test
void challengerEntryEnablesBossAndSetsTarget() {
Zombie boss = mock(Zombie.class);
Player challenger = mock(Player.class);
ArenaBossState.engage(boss, challenger);
verify(boss).setAI(true);
verify(boss).setTarget(challenger);
}
}
@@ -0,0 +1,25 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
final class ArenaBuildProtectionTest {
private static final ArenaLocation CENTER = new ArenaLocation(
"world", 0.0, 64.0, 0.0, 0.0F, 0.0F
);
@Test
void protectsOnlyInsideConfiguredArenaDuringFight() {
assertTrue(ArenaBuildProtection.isProtected(
true, CENTER, 10.0, "world", 5.0, 70.0, 5.0
));
assertFalse(ArenaBuildProtection.isProtected(
false, CENTER, 10.0, "world", 5.0, 70.0, 5.0
));
assertFalse(ArenaBuildProtection.isProtected(
true, CENTER, 10.0, "world", 11.0, 70.0, 0.0
));
}
}
@@ -21,6 +21,7 @@ final class DefaultConfigurationTest {
assertEquals(50.0, settings.tyrantRangeBlocks());
assertEquals(Duration.ofDays(7), settings.optOutDuration());
assertEquals(Duration.ofMinutes(1), settings.roleMaintenanceInterval());
assertEquals(12, settings.arenaBarrierHeightBlocks());
assertEquals("FISHING_ROD", settings.tamerItem().material());
}
@@ -24,6 +24,7 @@ final class PluginSettingsTest {
assertEquals(Duration.ofMinutes(10), settings.fixerEffectDuration());
assertEquals(Duration.ofHours(24), settings.rosterIntelligenceCooldown());
assertEquals(10.0, settings.vigilanteArenaRadiusBlocks());
assertEquals(12, settings.arenaBarrierHeightBlocks());
assertEquals(80.0, settings.vigilanteBossHealth());
assertEquals(8.0, settings.vigilanteBossDamage());
assertEquals(10.0, settings.vigilanteBossArmor());
@@ -84,6 +85,10 @@ final class PluginSettingsTest {
IllegalArgumentException.class,
() -> PluginSettings.from(Map.of("vigilante-arena-radius-blocks", 0))
);
assertThrows(
IllegalArgumentException.class,
() -> PluginSettings.from(Map.of("arena-barrier-height-blocks", 2))
);
assertThrows(
IllegalArgumentException.class,
() -> PluginSettings.from(Map.of("vigilante-boss-health", Double.NaN))
@@ -1,5 +1,6 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.atLeastOnce;
@@ -8,14 +9,16 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
final class VigilanteArenaBarrierTest {
final class RoleArenaBarrierTest {
private static final ArenaLocation CENTER = new ArenaLocation(
"world", 0.0, 64.0, 0.0, 0.0F, 0.0F
);
@@ -28,7 +31,7 @@ final class VigilanteArenaBarrierTest {
when(solid.getLocation()).thenReturn(new Location(world, 1.0, 64.0, 0.0));
when(solid.getType()).thenReturn(Material.CHEST);
new VigilanteArenaBarrier().build(world, CENTER, 10.0);
new RoleArenaBarrier().build(world, CENTER, 10.0, 12);
verify(solid, never()).setType(Material.BARRIER, false);
}
@@ -45,12 +48,30 @@ final class VigilanteArenaBarrierTest {
when(air.getType()).thenReturn(Material.AIR);
when(air.getBlockData()).thenReturn(original);
when(original.clone()).thenReturn(snapshot);
VigilanteArenaBarrier barrier = new VigilanteArenaBarrier();
RoleArenaBarrier barrier = new RoleArenaBarrier();
barrier.build(world, CENTER, 10.0);
barrier.build(world, CENTER, 10.0, 12);
barrier.clear();
verify(air, atLeastOnce()).setType(Material.BARRIER, false);
verify(air).setBlockData(snapshot, false);
}
@Test
void wallExtendsOneBelowFloorForTwelveBlocks() {
World world = mock(World.class);
Block solid = mock(Block.class);
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(solid);
when(solid.getLocation()).thenReturn(new Location(world, 1.0, 64.0, 0.0));
when(solid.getType()).thenReturn(Material.CHEST);
RoleArenaBarrier barrier = new RoleArenaBarrier();
barrier.build(world, CENTER, 10.0, 12);
ArgumentCaptor<Integer> y = ArgumentCaptor.forClass(Integer.class);
verify(world, atLeastOnce()).getBlockAt(anyInt(), y.capture(), anyInt());
List<Integer> values = y.getAllValues();
assertEquals(63, values.stream().mapToInt(Integer::intValue).min().orElseThrow());
assertEquals(74, values.stream().mapToInt(Integer::intValue).max().orElseThrow());
}
}
@@ -1,8 +1,12 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -15,7 +19,10 @@ import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerJoinEvent;
import org.junit.jupiter.api.Test;
@@ -23,12 +30,29 @@ import org.junit.jupiter.api.Test;
final class RoleArenaControllerTest {
private static final UUID TYRANT = UUID.fromString("11111111-1111-1111-1111-111111111111");
private static final UUID PLAYER = UUID.fromString("22222222-2222-2222-2222-222222222222");
private static final UUID OTHER = UUID.fromString("33333333-3333-3333-3333-333333333333");
@Test
void boundaryColorsIdentifyTheOpenRole() {
assertEquals(Color.RED, RoleArenaController.boundaryColor(ArenaRole.TYRANT));
assertEquals(Color.BLUE, RoleArenaController.boundaryColor(ArenaRole.VIGILANTE));
assertNull(RoleArenaController.boundaryColor(null));
assertArrayEquals(
new int[] {1}, RoleArenaController.boundaryHeightOffsets(false, 12)
);
assertArrayEquals(
new int[] {1, 4, 7, 10}, RoleArenaController.boundaryHeightOffsets(true, 12)
);
}
@Test
void tyrantArenaWinnerIsMovedOutsideAfterAssignment() {
assertWinnerMovedOutside(ArenaRole.TYRANT, tyrantVacancy());
}
@Test
void vigilanteArenaWinnerIsMovedOutsideAfterAssignment() {
assertWinnerMovedOutside(ArenaRole.VIGILANTE, openGame());
}
@Test
@@ -95,6 +119,53 @@ final class RoleArenaControllerTest {
verify(player, never()).sendMessage(org.mockito.ArgumentMatchers.anyString());
}
private static void assertWinnerMovedOutside(ArenaRole role, GameState game) {
ArenaLocation arena = new ArenaLocation("world", 10.0, 64.0, -5.0, 0.0F, 0.0F);
World world = mock(World.class);
when(world.getName()).thenReturn("world");
when(world.getMinHeight()).thenReturn(-64);
when(world.getMaxHeight()).thenReturn(320);
when(world.getHighestBlockYAt(org.mockito.ArgumentMatchers.anyInt(),
org.mockito.ArgumentMatchers.anyInt())).thenReturn(63);
when(world.getBlockAt(org.mockito.ArgumentMatchers.anyInt(),
org.mockito.ArgumentMatchers.anyInt(),
org.mockito.ArgumentMatchers.anyInt())).thenReturn(mock(Block.class));
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(PLAYER);
when(player.getName()).thenReturn("Player");
when(player.getLocation()).thenReturn(new Location(world, arena.x(), arena.y(), arena.z()));
Player other = mock(Player.class);
when(other.getUniqueId()).thenReturn(OTHER);
PlayerState playerState = PlayerState.newPlayer(PLAYER, "Player");
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.snapshot()).thenReturn(new PersistentState(game, Map.of(PLAYER, playerState)));
Server server = mock(Server.class);
when(server.getPlayer(PLAYER)).thenReturn(player);
when(server.getWorld("world")).thenReturn(world);
doReturn(Set.of(player, other)).when(server).getOnlinePlayers();
ArenaLocationStore locations = mock(ArenaLocationStore.class);
when(locations.location()).thenReturn(Optional.of(arena));
RoleArenaController controller = new RoleArenaController(
manager, server, locations, PluginSettings.from(Map.of())
);
controller.assignRole(PLAYER, role);
verify(manager).replaceState(org.mockito.ArgumentMatchers.any(LifecycleState.class));
verify(player).teleport(argThat((Location destination) ->
!ArenaGeometry.contains(arena, 10.0, "world",
destination.getX(), destination.getY(), destination.getZ())
), eq(org.bukkit.event.player.PlayerTeleportEvent.TeleportCause.PLUGIN));
if (role == ArenaRole.VIGILANTE) {
verify(player).sendMessage(contains(
"You defeated the arena boss and became the Vigilante"
));
verify(other, never()).sendMessage(contains("Vigilante"));
} else {
verify(other).sendMessage(contains("Player defeated the arena boss"));
}
}
private static PlayerJoinEvent mockJoin(Player player) {
PlayerJoinEvent event = mock(PlayerJoinEvent.class);
when(event.getPlayer()).thenReturn(player);
@@ -62,6 +62,93 @@ final class TyrantAdminCommandTest {
verify(effects).suppressAll();
}
@Test
void grantPointAddsAndPersistsOneLevelAndChoice() {
UUID tyrant = UUID.fromString("11111111-1111-1111-1111-111111111111");
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(tyrant), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
2, 1, Set.of(TyrantUnlock.ASSASSIN)
);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.snapshot()).thenReturn(new PersistentState(
game, Map.of(tyrant, PlayerState.newPlayer(tyrant, "Tyrant"))
));
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigottyrant.admin")).thenReturn(true);
command(manager, mock(ManagedRoleEffects.class)).onCommand(
sender, mock(Command.class), "tyrantadmin", new String[] {"grantpoint"}
);
verify(manager).replaceState(org.mockito.ArgumentMatchers.argThat(state ->
state.game().tyrantLevel() == 3
&& state.game().unspentChoices() == 2
&& state.game().purchases().equals(Set.of(TyrantUnlock.ASSASSIN))
));
verify(manager).saveIfDirty();
verify(sender).sendMessage(contains("level 3 and 2 unspent choices"));
}
@Test
void grantPointRejectsPausedGameWithoutChangingState() {
UUID tyrant = UUID.fromString("11111111-1111-1111-1111-111111111111");
GameState paused = new GameState(
GameLifecycle.PAUSED, Optional.of(tyrant), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.of(Instant.parse("2026-08-14T12:00:00Z")), Duration.ZERO,
2, 1, Set.of()
);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.snapshot()).thenReturn(new PersistentState(
paused, Map.of(tyrant, PlayerState.newPlayer(tyrant, "Tyrant"))
));
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigottyrant.admin")).thenReturn(true);
command(manager, mock(ManagedRoleEffects.class)).onCommand(
sender, mock(Command.class), "tyrantadmin", new String[] {"grantpoint"}
);
verify(manager, never()).replaceState(org.mockito.ArgumentMatchers.any());
verify(sender).sendMessage(contains("must be running"));
}
@Test
void grantPointRejectsAdditionalArgumentsWithoutChangingState() {
TyrantStateManager manager = mock(TyrantStateManager.class);
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigottyrant.admin")).thenReturn(true);
command(manager, mock(ManagedRoleEffects.class)).onCommand(
sender, mock(Command.class), "tyrantadmin",
new String[] {"grantpoint", "unexpected"}
);
verify(manager, never()).replaceState(org.mockito.ArgumentMatchers.any());
verify(sender).sendMessage(contains("Usage: /tyrantadmin grantpoint"));
}
@Test
void grantPointRejectsTyrantVacancyWithoutChangingState() {
GameState vacant = new GameState(
GameLifecycle.RUNNING, Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
0, 0, Set.of()
);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.snapshot()).thenReturn(new PersistentState(vacant, Map.of()));
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigottyrant.admin")).thenReturn(true);
command(manager, mock(ManagedRoleEffects.class)).onCommand(
sender, mock(Command.class), "tyrantadmin", new String[] {"grantpoint"}
);
verify(manager, never()).replaceState(org.mockito.ArgumentMatchers.any());
verify(sender).sendMessage(contains("no current Tyrant"));
}
@Test
void playerCanPersistArenaAtCurrentLocation() {
TyrantStateManager manager = mock(TyrantStateManager.class);
@@ -35,7 +35,7 @@ final class TyrantAdminTabCompleterTest {
assertTrue(completer.onTabComplete(
admin, command, "tyrantadmin", new String[] {""}
).containsAll(java.util.List.of(
"status", "start", "pause", "resume", "reset", "arena"
"status", "start", "pause", "resume", "reset", "arena", "grantpoint"
)));
assertEquals(java.util.List.of("Alpha", "Beta"), completer.onTabComplete(
admin, command, "tyrantadmin", new String[] {"start", ""}
@@ -49,6 +49,9 @@ final class TyrantAdminTabCompleterTest {
assertEquals(java.util.List.of("set", "status"), completer.onTabComplete(
admin, command, "tyrantadmin", new String[] {"arena", "s"}
));
assertEquals(java.util.List.of(), completer.onTabComplete(
admin, command, "tyrantadmin", new String[] {"grantpoint", ""}
));
}
private static Player player(UUID id, String name) {
@@ -2,6 +2,7 @@ package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
@@ -47,6 +48,38 @@ final class TyrantProgressionServiceTest {
assertEquals(paused, service.recordVigilanteDeath(paused, Optional.of(TYRANT)));
}
@Test
void administrativePointGrantAddsOneLevelAndChoicePerInvocation() {
GameState before = runningState(2, 1, Set.of(TyrantUnlock.ASSASSIN));
GameState first = service.grantPoint(before);
GameState second = service.grantPoint(first);
assertEquals(4, second.tyrantLevel());
assertEquals(3, second.unspentChoices());
assertEquals(before.purchases(), second.purchases());
assertEquals(before.tyrantId(), second.tyrantId());
assertEquals(before.vigilanteId(), second.vigilanteId());
}
@Test
void administrativePointGrantRequiresRunningGameAndCurrentTyrant() {
GameState running = runningState(0, 1, Set.of());
GameState paused = new GameState(
GameLifecycle.PAUSED, running.tyrantId(), running.vigilanteId(),
running.pendingTyrant(), running.pendingVigilante(),
Optional.of(java.time.Instant.parse("2026-08-14T12:00:00Z")),
Duration.ZERO, 0, 1, Set.of()
);
GameState vacant = new GameState(
GameLifecycle.RUNNING, Optional.empty(), Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(), Duration.ZERO, 0, 0, Set.of()
);
assertThrows(IllegalStateException.class, () -> service.grantPoint(paused));
assertThrows(IllegalStateException.class, () -> service.grantPoint(vacant));
}
@Test
void purchaseConsumesOneChoiceAndCannotBeBoughtTwice() {
GameState before = runningState(1, 2, Set.of());