Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5de6b76e7 | ||
|
|
4b6c709157 | ||
|
|
17a5c66575 |
@@ -6,6 +6,24 @@ description: Chronological record of material decisions affecting the Spigot Tyr
|
|||||||
|
|
||||||
# Spigot Tyrant Design Log
|
# 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
|
## 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.
|
- Corrected shared-arena containment with a validated 12-block barrier wall extending below the arena floor and active-fight block placement and breaking protection.
|
||||||
|
|||||||
@@ -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] 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] 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] 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.
|
- [x] Destructive administrative operations require explicit confirmation.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ As the **Tyrant**, I want to earn one meaningful choice for defeating the Vigila
|
|||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [x] The Tyrant gains one level and one unlock choice only when personally credited with killing the current Vigilante.
|
- [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] 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] 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.
|
- [x] Each class or ability can be purchased at most once during a reign.
|
||||||
|
|||||||
@@ -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 start` suggests online Tyrant and Vigilante candidates.
|
||||||
- [x] `/tyrantadmin reset` suggests `confirm`.
|
- [x] `/tyrantadmin reset` suggests `confirm`.
|
||||||
- [x] `/tyrantadmin arena` suggests `set` and `status`.
|
- [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 are filtered case-insensitively by the partially typed argument.
|
||||||
- [x] Suggestions never include syntactically invalid options for the current argument position.
|
- [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.
|
- [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
|
## 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
|
## Related
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ As an **opted-in participant**, I want a visible one-player boss challenge for a
|
|||||||
- [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] 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] 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] 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] 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.
|
- [x] If the arena is not configured when a challenge should open, the Vigilante remains vacant and administrators receive a clear warning.
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ As an **opted-in participant**, I want to earn a vacant Tyrant role through the
|
|||||||
- [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] 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] 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] 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] 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] 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.
|
- [x] Pausing, resetting, restarting, reloading, and repeated or concurrent events cannot duplicate either role boss, challenger, barrier, or assignment.
|
||||||
|
|||||||
@@ -400,7 +400,7 @@ public final class RoleArenaController implements Listener, Runnable {
|
|||||||
&& !zombie.isDead() ? zombie : null;
|
&& !zombie.isDead() ? zombie : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void assignRole(UUID playerId, ArenaRole role) {
|
void assignRole(UUID playerId, ArenaRole role) {
|
||||||
LifecycleState assigned;
|
LifecycleState assigned;
|
||||||
try {
|
try {
|
||||||
assigned = role == ArenaRole.TYRANT
|
assigned = role == ArenaRole.TYRANT
|
||||||
@@ -414,13 +414,42 @@ public final class RoleArenaController implements Listener, Runnable {
|
|||||||
stateManager.saveIfDirty();
|
stateManager.saveIfDirty();
|
||||||
challengerId = null;
|
challengerId = null;
|
||||||
barrier.clear();
|
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());
|
.map(Player::getName).orElse(playerId.toString());
|
||||||
String message = ChatColor.YELLOW + name + " defeated the arena boss and is the "
|
String message = ChatColor.YELLOW + name + " defeated the arena boss and is the "
|
||||||
+ readable(role) + "!";
|
+ readable(role) + "!";
|
||||||
server.getOnlinePlayers().forEach(player -> player.sendMessage(message));
|
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) {
|
private void announceVacancy(ArenaLocation arena, ArenaRole role) {
|
||||||
String message = vacancyMessage(arena, role);
|
String message = vacancyMessage(arena, role);
|
||||||
for (Player player : server.getOnlinePlayers()) {
|
for (Player player : server.getOnlinePlayers()) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public final class TyrantAdminCommand implements CommandExecutor {
|
|||||||
private final ManagedRoleEffects effects;
|
private final ManagedRoleEffects effects;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
private final ArenaLocationStore arenaLocations;
|
private final ArenaLocationStore arenaLocations;
|
||||||
|
private final TyrantProgressionService progression = new TyrantProgressionService();
|
||||||
|
|
||||||
public TyrantAdminCommand(
|
public TyrantAdminCommand(
|
||||||
TyrantStateManager stateManager,
|
TyrantStateManager stateManager,
|
||||||
@@ -69,6 +70,7 @@ public final class TyrantAdminCommand implements CommandExecutor {
|
|||||||
case "resume" -> resume(sender);
|
case "resume" -> resume(sender);
|
||||||
case "reset" -> reset(sender, arguments);
|
case "reset" -> reset(sender, arguments);
|
||||||
case "arena" -> arena(sender, arguments);
|
case "arena" -> arena(sender, arguments);
|
||||||
|
case "grantpoint" -> grantPoint(sender, arguments);
|
||||||
default -> usage(sender);
|
default -> usage(sender);
|
||||||
}
|
}
|
||||||
} catch (IllegalArgumentException | IllegalStateException exception) {
|
} catch (IllegalArgumentException | IllegalStateException exception) {
|
||||||
@@ -141,6 +143,20 @@ public final class TyrantAdminCommand implements CommandExecutor {
|
|||||||
sender.sendMessage(ChatColor.GREEN + "Tyrant game reset.");
|
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) {
|
private void arena(CommandSender sender, String[] arguments) {
|
||||||
if (arguments.length == 1 || arguments[1].equalsIgnoreCase("status")) {
|
if (arguments.length == 1 || arguments[1].equalsIgnoreCase("status")) {
|
||||||
sender.sendMessage("Shared role arena: " + arenaLocations.location()
|
sender.sendMessage("Shared role arena: " + arenaLocations.location()
|
||||||
@@ -194,6 +210,6 @@ public final class TyrantAdminCommand implements CommandExecutor {
|
|||||||
|
|
||||||
private static void usage(CommandSender sender) {
|
private static void usage(CommandSender sender) {
|
||||||
sender.sendMessage(ChatColor.YELLOW
|
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 {
|
public final class TyrantAdminTabCompleter implements TabCompleter {
|
||||||
private static final String PERMISSION = "spigottyrant.admin";
|
private static final String PERMISSION = "spigottyrant.admin";
|
||||||
private static final List<String> SUBCOMMANDS = List.of(
|
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;
|
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(
|
public PurchaseResult purchase(
|
||||||
GameState state,
|
GameState state,
|
||||||
UUID buyerId,
|
UUID buyerId,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ commands:
|
|||||||
usage: /vigilante <menu|item|invite <player>|accept|dismiss <player>|leave>
|
usage: /vigilante <menu|item|invite <player>|accept|dismiss <player>|leave>
|
||||||
tyrantadmin:
|
tyrantadmin:
|
||||||
description: Administer the Spigot Tyrant game.
|
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
|
permission: spigottyrant.admin
|
||||||
permissions:
|
permissions:
|
||||||
spigottyrant.admin:
|
spigottyrant.admin:
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
import static org.mockito.ArgumentMatchers.contains;
|
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.mock;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
@@ -16,7 +19,10 @@ import java.util.Optional;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.bukkit.Color;
|
import org.bukkit.Color;
|
||||||
|
import org.bukkit.Location;
|
||||||
import org.bukkit.Server;
|
import org.bukkit.Server;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.block.Block;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
import org.bukkit.event.player.PlayerJoinEvent;
|
import org.bukkit.event.player.PlayerJoinEvent;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -24,6 +30,7 @@ import org.junit.jupiter.api.Test;
|
|||||||
final class RoleArenaControllerTest {
|
final class RoleArenaControllerTest {
|
||||||
private static final UUID TYRANT = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
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 PLAYER = UUID.fromString("22222222-2222-2222-2222-222222222222");
|
||||||
|
private static final UUID OTHER = UUID.fromString("33333333-3333-3333-3333-333333333333");
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void boundaryColorsIdentifyTheOpenRole() {
|
void boundaryColorsIdentifyTheOpenRole() {
|
||||||
@@ -38,6 +45,16 @@ final class RoleArenaControllerTest {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tyrantArenaWinnerIsMovedOutsideAfterAssignment() {
|
||||||
|
assertWinnerMovedOutside(ArenaRole.TYRANT, tyrantVacancy());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void vigilanteArenaWinnerIsMovedOutsideAfterAssignment() {
|
||||||
|
assertWinnerMovedOutside(ArenaRole.VIGILANTE, openGame());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void optedInPlayerJoiningDuringVacancyReceivesArenaCoordinates() {
|
void optedInPlayerJoiningDuringVacancyReceivesArenaCoordinates() {
|
||||||
Player player = mock(Player.class);
|
Player player = mock(Player.class);
|
||||||
@@ -102,6 +119,53 @@ final class RoleArenaControllerTest {
|
|||||||
verify(player, never()).sendMessage(org.mockito.ArgumentMatchers.anyString());
|
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) {
|
private static PlayerJoinEvent mockJoin(Player player) {
|
||||||
PlayerJoinEvent event = mock(PlayerJoinEvent.class);
|
PlayerJoinEvent event = mock(PlayerJoinEvent.class);
|
||||||
when(event.getPlayer()).thenReturn(player);
|
when(event.getPlayer()).thenReturn(player);
|
||||||
|
|||||||
@@ -62,6 +62,93 @@ final class TyrantAdminCommandTest {
|
|||||||
verify(effects).suppressAll();
|
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
|
@Test
|
||||||
void playerCanPersistArenaAtCurrentLocation() {
|
void playerCanPersistArenaAtCurrentLocation() {
|
||||||
TyrantStateManager manager = mock(TyrantStateManager.class);
|
TyrantStateManager manager = mock(TyrantStateManager.class);
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ final class TyrantAdminTabCompleterTest {
|
|||||||
assertTrue(completer.onTabComplete(
|
assertTrue(completer.onTabComplete(
|
||||||
admin, command, "tyrantadmin", new String[] {""}
|
admin, command, "tyrantadmin", new String[] {""}
|
||||||
).containsAll(java.util.List.of(
|
).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(
|
assertEquals(java.util.List.of("Alpha", "Beta"), completer.onTabComplete(
|
||||||
admin, command, "tyrantadmin", new String[] {"start", ""}
|
admin, command, "tyrantadmin", new String[] {"start", ""}
|
||||||
@@ -49,6 +49,9 @@ final class TyrantAdminTabCompleterTest {
|
|||||||
assertEquals(java.util.List.of("set", "status"), completer.onTabComplete(
|
assertEquals(java.util.List.of("set", "status"), completer.onTabComplete(
|
||||||
admin, command, "tyrantadmin", new String[] {"arena", "s"}
|
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) {
|
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.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
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 static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
@@ -47,6 +48,38 @@ final class TyrantProgressionServiceTest {
|
|||||||
assertEquals(paused, service.recordVigilanteDeath(paused, Optional.of(TYRANT)));
|
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
|
@Test
|
||||||
void purchaseConsumesOneChoiceAndCannotBeBoughtTwice() {
|
void purchaseConsumesOneChoiceAndCannotBeBoughtTwice() {
|
||||||
GameState before = runningState(1, 2, Set.of());
|
GameState before = runningState(1, 2, Set.of());
|
||||||
|
|||||||
Reference in New Issue
Block a user