diff --git a/design/log.md b/design/log.md index 28e1786..711265a 100644 --- a/design/log.md +++ b/design/log.md @@ -6,6 +6,17 @@ description: Chronological record of material decisions affecting the Spigot Tyr # Spigot Tyrant Design Log +## 2026-08-14 — Tyrant class assignment completed + +- Completed US-004 with `/tyrant assign `, purchased-class authorization, UUID state, one holder per class, and one Tyrant-side class per player. +- Preserved dual alignment so Vigilantes and Followers can hold a Tyrant-side class, while rejecting the Tyrant and opted-out targets. +- Added reassignment and reign cleanup for class cooldown/item state, online assignment/removal notices, and login control reminders. +- Verified assignment constraints, dual alignment, cleanup, commands, notifications, and reminders with the Gradle test suite. + +## 2026-08-14 — Tyrant class assignment implementation started + +- US-004 begins with test-first purchased-class authorization, one-holder constraints, dual alignment, reassignment cleanup, and reign-wide removal. + ## 2026-08-14 — Tyrant progression completed - Completed US-003 with exact Tyrant-on-Vigilante kill attribution, level and choice rewards, pause guards, one-time purchases, and reign reset behavior. diff --git a/design/user-stories/us-004-assign-unlocked-classes.md b/design/user-stories/us-004-assign-unlocked-classes.md index b9dc5fa..d47dc99 100644 --- a/design/user-stories/us-004-assign-unlocked-classes.md +++ b/design/user-stories/us-004-assign-unlocked-classes.md @@ -2,7 +2,7 @@ type: User Story title: "US-004: Assign unlocked classes" description: Let the Tyrant assign each purchased class to one eligible participant. -status: backlog +status: done --- # US-004: Assign unlocked classes @@ -11,14 +11,14 @@ As the **Tyrant**, I want to assign unlocked specialist classes so that I can bu ## Acceptance criteria -- [ ] The Tyrant can assign only Assassin, Fixer, and Tamer classes purchased during the current reign. -- [ ] Each class can be assigned to at most one player at a time, and a player can hold at most one Tyrant-side class. -- [ ] Role alignment and Tyrant-side class are independent: the Vigilante or a Follower may simultaneously be the Assassin, Fixer, or Tamer. -- [ ] The Tyrant cannot assign a class to the Tyrant, an opted-out player, or another ineligible player. -- [ ] Reassignment removes the class and its bound items and effects from the previous holder before granting it to the new holder. -- [ ] Assignment and removal are stored by UUID and communicated clearly to affected players. -- [ ] A class holder is reminded of the assignment and its controls on login. -- [ ] All class assignments end when the Tyrant's reign ends. +- [x] The Tyrant can assign only Assassin, Fixer, and Tamer classes purchased during the current reign. +- [x] Each class can be assigned to at most one player at a time, and a player can hold at most one Tyrant-side class. +- [x] Role alignment and Tyrant-side class are independent: the Vigilante or a Follower may simultaneously be the Assassin, Fixer, or Tamer. +- [x] The Tyrant cannot assign a class to the Tyrant, an opted-out player, or another ineligible player. +- [x] Reassignment removes the class and its bound items and effects from the previous holder before granting it to the new holder. +- [x] Assignment and removal are stored by UUID and communicated clearly to affected players. +- [x] A class holder is reminded of the assignment and its controls on login. +- [x] All class assignments end when the Tyrant's reign ends. ## Related diff --git a/src/main/java/games/dmg/spigottyrant/BukkitOnlinePlayerDirectory.java b/src/main/java/games/dmg/spigottyrant/BukkitOnlinePlayerDirectory.java new file mode 100644 index 0000000..06ae107 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/BukkitOnlinePlayerDirectory.java @@ -0,0 +1,17 @@ +package games.dmg.spigottyrant; + +import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +public final class BukkitOnlinePlayerDirectory implements OnlinePlayerDirectory { + @Override + public Player findByName(String name) { + return Bukkit.getPlayerExact(name); + } + + @Override + public Player findById(UUID playerId) { + return Bukkit.getPlayer(playerId); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/ClassAssignmentMessages.java b/src/main/java/games/dmg/spigottyrant/ClassAssignmentMessages.java new file mode 100644 index 0000000..ed0ec47 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/ClassAssignmentMessages.java @@ -0,0 +1,18 @@ +package games.dmg.spigottyrant; + +public final class ClassAssignmentMessages { + private ClassAssignmentMessages() { + } + + public static String loginReminder(PlayerState player) { + return switch (player.tyrantClass()) { + case ASSASSIN -> "Class: ASSASSIN. Use your Assassin Cloak; double-jump while " + + "airborne. Use /tyrant item to recover ready class items."; + case FIXER -> "Class: FIXER. Use your Fixer's Wrench to activate your boost. " + + "Use /tyrant item to recover ready class items."; + case TAMER -> "Class: TAMER. Use your Tamer's Lead to capture mobs. " + + "Use /tyrant item to recover ready class items."; + case NONE -> ""; + }; + } +} diff --git a/src/main/java/games/dmg/spigottyrant/ClassAssignmentResult.java b/src/main/java/games/dmg/spigottyrant/ClassAssignmentResult.java new file mode 100644 index 0000000..d60ef3d --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/ClassAssignmentResult.java @@ -0,0 +1,16 @@ +package games.dmg.spigottyrant; + +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +public record ClassAssignmentResult( + Map players, + ClassAssignmentStatus status, + Optional previousHolderId +) { + public ClassAssignmentResult { + players = Map.copyOf(players); + previousHolderId = previousHolderId == null ? Optional.empty() : previousHolderId; + } +} diff --git a/src/main/java/games/dmg/spigottyrant/ClassAssignmentService.java b/src/main/java/games/dmg/spigottyrant/ClassAssignmentService.java new file mode 100644 index 0000000..184b97c --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/ClassAssignmentService.java @@ -0,0 +1,129 @@ +package games.dmg.spigottyrant; + +import java.time.Instant; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +public final class ClassAssignmentService { + public ClassAssignmentResult assign( + GameState game, + Map players, + UUID assignerId, + UUID targetId, + TyrantClass tyrantClass, + Instant now + ) { + ClassAssignmentStatus rejected = rejection( + game, players, assignerId, targetId, tyrantClass, now + ); + if (rejected != null) { + return new ClassAssignmentResult(players, rejected, Optional.empty()); + } + + Map updated = new HashMap<>(players); + Optional previousHolder = players.values().stream() + .filter(player -> player.tyrantClass() == tyrantClass) + .map(PlayerState::playerId) + .filter(playerId -> !playerId.equals(targetId)) + .findFirst(); + previousHolder.ifPresent(playerId -> updated.put( + playerId, + withClass(updated.get(playerId), TyrantClass.NONE) + )); + updated.put(targetId, withClass(updated.get(targetId), tyrantClass)); + return new ClassAssignmentResult( + updated, + ClassAssignmentStatus.ASSIGNED, + previousHolder + ); + } + + public Map clearAll(Map players) { + Map updated = new HashMap<>(); + for (PlayerState player : players.values()) { + updated.put(player.playerId(), withClass(player, TyrantClass.NONE)); + } + return Map.copyOf(updated); + } + + private static ClassAssignmentStatus rejection( + GameState game, + Map players, + UUID assignerId, + UUID targetId, + TyrantClass tyrantClass, + Instant now + ) { + if (game.lifecycle() != GameLifecycle.RUNNING) { + return ClassAssignmentStatus.GAME_NOT_RUNNING; + } + if (game.tyrantId().isEmpty() || !game.tyrantId().orElseThrow().equals(assignerId)) { + return ClassAssignmentStatus.NOT_TYRANT; + } + if (tyrantClass == null || tyrantClass == TyrantClass.NONE) { + return ClassAssignmentStatus.INVALID_CLASS; + } + if (!game.purchases().contains(unlockFor(tyrantClass))) { + return ClassAssignmentStatus.CLASS_NOT_UNLOCKED; + } + PlayerState target = players.get(targetId); + if (target == null) { + return ClassAssignmentStatus.UNKNOWN_PLAYER; + } + if (targetId.equals(assignerId)) { + return ClassAssignmentStatus.TARGET_IS_TYRANT; + } + if (target.optedOutUntil().filter(until -> until.isAfter(now)).isPresent()) { + return ClassAssignmentStatus.TARGET_OPTED_OUT; + } + return null; + } + + private static TyrantUnlock unlockFor(TyrantClass tyrantClass) { + return switch (tyrantClass) { + case ASSASSIN -> TyrantUnlock.ASSASSIN; + case FIXER -> TyrantUnlock.FIXER; + case TAMER -> TyrantUnlock.TAMER; + case NONE -> throw new IllegalArgumentException("NONE is not assignable"); + }; + } + + private static PlayerState withClass(PlayerState player, TyrantClass tyrantClass) { + Map cooldowns = new EnumMap<>(Ability.class); + cooldowns.putAll(player.cooldownEnds()); + Set readyItems = EnumSet.noneOf(Ability.class); + readyItems.addAll(player.readyAbilityItems()); + for (Ability ability : abilitiesFor(player.tyrantClass())) { + cooldowns.remove(ability); + readyItems.remove(ability); + } + return new PlayerState( + player.playerId(), + player.latestName(), + player.lastLogin(), + player.optedOutUntil(), + tyrantClass, + player.followerOf(), + cooldowns, + readyItems, + player.capturedMobs() + ); + } + + private static Set abilitiesFor(TyrantClass tyrantClass) { + return switch (tyrantClass) { + case ASSASSIN -> Set.of( + Ability.ASSASSIN_INVISIBILITY, + Ability.ASSASSIN_DOUBLE_JUMP + ); + case FIXER -> Set.of(Ability.FIXER_BOOST); + case TAMER -> Set.of(Ability.TAMER_CAPTURE); + case NONE -> Set.of(); + }; + } +} diff --git a/src/main/java/games/dmg/spigottyrant/ClassAssignmentStatus.java b/src/main/java/games/dmg/spigottyrant/ClassAssignmentStatus.java new file mode 100644 index 0000000..6165e9c --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/ClassAssignmentStatus.java @@ -0,0 +1,12 @@ +package games.dmg.spigottyrant; + +public enum ClassAssignmentStatus { + ASSIGNED, + GAME_NOT_RUNNING, + NOT_TYRANT, + CLASS_NOT_UNLOCKED, + INVALID_CLASS, + UNKNOWN_PLAYER, + TARGET_IS_TYRANT, + TARGET_OPTED_OUT +} diff --git a/src/main/java/games/dmg/spigottyrant/OnlinePlayerDirectory.java b/src/main/java/games/dmg/spigottyrant/OnlinePlayerDirectory.java new file mode 100644 index 0000000..b11eba5 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/OnlinePlayerDirectory.java @@ -0,0 +1,10 @@ +package games.dmg.spigottyrant; + +import java.util.UUID; +import org.bukkit.entity.Player; + +public interface OnlinePlayerDirectory { + Player findByName(String name); + + Player findById(UUID playerId); +} diff --git a/src/main/java/games/dmg/spigottyrant/PlayerJoinListener.java b/src/main/java/games/dmg/spigottyrant/PlayerJoinListener.java new file mode 100644 index 0000000..fec92fa --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/PlayerJoinListener.java @@ -0,0 +1,33 @@ +package games.dmg.spigottyrant; + +import java.time.Clock; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; + +public final class PlayerJoinListener implements Listener { + private final TyrantStateManager stateManager; + private final Clock clock; + + public PlayerJoinListener(TyrantStateManager stateManager, Clock clock) { + this.stateManager = stateManager; + this.clock = clock; + } + + @EventHandler + public void onJoin(PlayerJoinEvent event) { + PlayerState player = stateManager.updatePlayer( + event.getPlayer().getUniqueId(), + event.getPlayer().getName(), + current -> new PlayerState( + current.playerId(), current.latestName(), java.util.Optional.of(clock.instant()), + current.optedOutUntil(), current.tyrantClass(), current.followerOf(), + current.cooldownEnds(), current.readyAbilityItems(), current.capturedMobs() + ) + ); + String reminder = ClassAssignmentMessages.loginReminder(player); + if (!reminder.isEmpty()) { + event.getPlayer().sendMessage(reminder); + } + } +} diff --git a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java index bdf603b..dd2e839 100644 --- a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java +++ b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java @@ -1,6 +1,7 @@ package games.dmg.spigottyrant; import java.io.IOException; +import java.time.Clock; import java.util.Map; import java.util.Objects; import java.util.logging.Level; @@ -28,6 +29,10 @@ public final class SpigotTyrantPlugin extends JavaPlugin { Objects.requireNonNull(getCommand("tyrant"), "Missing tyrant command metadata") .setExecutor(new TyrantCommand(stateManager, new TyrantProgressionService())); + getServer().getPluginManager().registerEvents( + new PlayerJoinListener(stateManager, Clock.systemUTC()), + this + ); getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L); getLogger().info("Spigot Tyrant enabled."); } diff --git a/src/main/java/games/dmg/spigottyrant/TyrantCommand.java b/src/main/java/games/dmg/spigottyrant/TyrantCommand.java index 628d660..36a7915 100644 --- a/src/main/java/games/dmg/spigottyrant/TyrantCommand.java +++ b/src/main/java/games/dmg/spigottyrant/TyrantCommand.java @@ -1,5 +1,6 @@ package games.dmg.spigottyrant; +import java.time.Clock; import java.util.Locale; import org.bukkit.ChatColor; import org.bukkit.command.Command; @@ -10,13 +11,35 @@ import org.bukkit.entity.Player; public final class TyrantCommand implements CommandExecutor { private final TyrantStateManager stateManager; private final TyrantProgressionService progression; + private final ClassAssignmentService assignments; + private final OnlinePlayerDirectory onlinePlayers; + private final Clock clock; public TyrantCommand( TyrantStateManager stateManager, TyrantProgressionService progression + ) { + this( + stateManager, + progression, + new ClassAssignmentService(), + new BukkitOnlinePlayerDirectory(), + Clock.systemUTC() + ); + } + + TyrantCommand( + TyrantStateManager stateManager, + TyrantProgressionService progression, + ClassAssignmentService assignments, + OnlinePlayerDirectory onlinePlayers, + Clock clock ) { this.stateManager = stateManager; this.progression = progression; + this.assignments = assignments; + this.onlinePlayers = onlinePlayers; + this.clock = clock; } @Override @@ -38,7 +61,15 @@ public final class TyrantCommand implements CommandExecutor { buy(player, arguments[1]); return true; } - player.sendMessage(ChatColor.YELLOW + "Usage: /tyrant choices | /tyrant buy "); + if (arguments.length == 3 && arguments[0].equalsIgnoreCase("assign")) { + assign(player, arguments[1], arguments[2]); + return true; + } + player.sendMessage( + ChatColor.YELLOW + + "Usage: /tyrant choices | /tyrant buy | " + + "/tyrant assign " + ); return true; } @@ -79,8 +110,53 @@ public final class TyrantCommand implements CommandExecutor { } else { player.sendMessage( ChatColor.RED + "Could not purchase " + unlock.name() + ": " - + result[0].status().name().toLowerCase(Locale.ROOT).replace('_', ' ') + + readable(result[0].status()) ); } } + + private void assign(Player tyrant, String requestedClass, String targetName) { + TyrantClass tyrantClass; + try { + tyrantClass = TyrantClass.valueOf(requestedClass.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException exception) { + tyrant.sendMessage(ChatColor.RED + "Unknown class. Use assassin, fixer, or tamer."); + return; + } + Player target = onlinePlayers.findByName(targetName); + if (target == null) { + tyrant.sendMessage(ChatColor.RED + "That player must be online."); + return; + } + stateManager.player(target.getUniqueId(), target.getName()); + ClassAssignmentResult result = assignments.assign( + stateManager.game(), + stateManager.players(), + tyrant.getUniqueId(), + target.getUniqueId(), + tyrantClass, + clock.instant() + ); + if (result.status() != ClassAssignmentStatus.ASSIGNED) { + tyrant.sendMessage( + ChatColor.RED + "Could not assign " + tyrantClass.name() + ": " + + readable(result.status()) + ); + return; + } + stateManager.replacePlayers(result.players()); + stateManager.saveIfDirty(); + result.previousHolderId().map(onlinePlayers::findById).ifPresent(previous -> + previous.sendMessage(ChatColor.YELLOW + "You are no longer the " + + tyrantClass.name() + ".") + ); + target.sendMessage(ChatColor.GREEN + "You are now the " + tyrantClass.name() + "."); + tyrant.sendMessage( + ChatColor.GREEN + "Assigned " + tyrantClass.name() + " to " + target.getName() + "." + ); + } + + private static String readable(Enum status) { + return status.name().toLowerCase(Locale.ROOT).replace('_', ' '); + } } diff --git a/src/main/java/games/dmg/spigottyrant/TyrantStateManager.java b/src/main/java/games/dmg/spigottyrant/TyrantStateManager.java index 864a200..9d778a4 100644 --- a/src/main/java/games/dmg/spigottyrant/TyrantStateManager.java +++ b/src/main/java/games/dmg/spigottyrant/TyrantStateManager.java @@ -64,6 +64,12 @@ public final class TyrantStateManager { return changed; } + public void replacePlayers(Map replacement) { + players.clear(); + players.putAll(replacement); + dirty = true; + } + public GameState updateGame(UnaryOperator update) { game = update.apply(game); dirty = true; diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 22c85ec..dc1940f 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -7,4 +7,4 @@ author: dmg.games commands: tyrant: description: View and use Spigot Tyrant game features. - usage: /tyrant [choices|buy ] + usage: /tyrant [choices|buy |assign ] diff --git a/src/test/java/games/dmg/spigottyrant/ClassAssignmentMessagesTest.java b/src/test/java/games/dmg/spigottyrant/ClassAssignmentMessagesTest.java new file mode 100644 index 0000000..c9319ce --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/ClassAssignmentMessagesTest.java @@ -0,0 +1,29 @@ +package games.dmg.spigottyrant; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.UUID; +import org.junit.jupiter.api.Test; + +final class ClassAssignmentMessagesTest { + @Test + void loginReminderNamesClassAndControls() { + PlayerState assassin = new PlayerState( + UUID.fromString("11111111-1111-1111-1111-111111111111"), + "Player", + java.util.Optional.empty(), + java.util.Optional.empty(), + TyrantClass.ASSASSIN, + java.util.Optional.empty(), + java.util.Map.of(), + java.util.Set.of(), + java.util.List.of() + ); + + assertEquals( + "Class: ASSASSIN. Use your Assassin Cloak; double-jump while airborne. " + + "Use /tyrant item to recover ready class items.", + ClassAssignmentMessages.loginReminder(assassin) + ); + } +} diff --git a/src/test/java/games/dmg/spigottyrant/ClassAssignmentServiceTest.java b/src/test/java/games/dmg/spigottyrant/ClassAssignmentServiceTest.java new file mode 100644 index 0000000..0a69e40 --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/ClassAssignmentServiceTest.java @@ -0,0 +1,127 @@ +package games.dmg.spigottyrant; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +final class ClassAssignmentServiceTest { + private static final UUID TYRANT = UUID.fromString("11111111-1111-1111-1111-111111111111"); + private static final UUID VIGILANTE = UUID.fromString("22222222-2222-2222-2222-222222222222"); + private static final UUID FOLLOWER = UUID.fromString("33333333-3333-3333-3333-333333333333"); + private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z"); + private final ClassAssignmentService service = new ClassAssignmentService(); + + @Test + void purchasedClassCanBeAssignedToVigilanteOrFollower() { + GameState game = gameWith(TyrantUnlock.ASSASSIN); + PlayerState vigilante = PlayerState.newPlayer(VIGILANTE, "Vigilante"); + PlayerState follower = withFollower( + PlayerState.newPlayer(FOLLOWER, "Follower"), + VIGILANTE + ); + + ClassAssignmentResult vigilanteResult = service.assign( + game, + Map.of(VIGILANTE, vigilante, FOLLOWER, follower), + TYRANT, + VIGILANTE, + TyrantClass.ASSASSIN, + NOW + ); + ClassAssignmentResult followerResult = service.assign( + game, + vigilanteResult.players(), + TYRANT, + FOLLOWER, + TyrantClass.ASSASSIN, + NOW + ); + + assertEquals(ClassAssignmentStatus.ASSIGNED, vigilanteResult.status()); + assertEquals(TyrantClass.ASSASSIN, + vigilanteResult.players().get(VIGILANTE).tyrantClass()); + assertEquals(ClassAssignmentStatus.ASSIGNED, followerResult.status()); + assertEquals(TyrantClass.NONE, + followerResult.players().get(VIGILANTE).tyrantClass()); + assertEquals(TyrantClass.ASSASSIN, + followerResult.players().get(FOLLOWER).tyrantClass()); + assertEquals(Optional.of(VIGILANTE), followerResult.previousHolderId()); + } + + @Test + void rejectsLockedSelfAndOptedOutAssignments() { + PlayerState target = PlayerState.newPlayer(FOLLOWER, "Target"); + PlayerState optedOut = new PlayerState( + target.playerId(), target.latestName(), target.lastLogin(), + Optional.of(NOW.plusSeconds(60)), target.tyrantClass(), target.followerOf(), + target.cooldownEnds(), target.readyAbilityItems(), target.capturedMobs() + ); + + assertEquals(ClassAssignmentStatus.CLASS_NOT_UNLOCKED, service.assign( + gameWith(TyrantUnlock.FIXER), Map.of(FOLLOWER, target), TYRANT, FOLLOWER, + TyrantClass.ASSASSIN, NOW + ).status()); + assertEquals(ClassAssignmentStatus.TARGET_IS_TYRANT, service.assign( + gameWith(TyrantUnlock.ASSASSIN), Map.of(TYRANT, + PlayerState.newPlayer(TYRANT, "Tyrant")), TYRANT, TYRANT, + TyrantClass.ASSASSIN, NOW + ).status()); + assertEquals(ClassAssignmentStatus.TARGET_OPTED_OUT, service.assign( + gameWith(TyrantUnlock.ASSASSIN), Map.of(FOLLOWER, optedOut), TYRANT, + FOLLOWER, TyrantClass.ASSASSIN, NOW + ).status()); + } + + @Test + void reassignmentAndReignEndClearClassAbilityState() { + Instant cooldown = NOW.plusSeconds(3600); + PlayerState holder = new PlayerState( + VIGILANTE, "Holder", Optional.empty(), Optional.empty(), + TyrantClass.ASSASSIN, Optional.empty(), + Map.of(Ability.ASSASSIN_INVISIBILITY, cooldown), + Set.of(Ability.ASSASSIN_INVISIBILITY), java.util.List.of() + ); + PlayerState replacement = PlayerState.newPlayer(FOLLOWER, "Replacement"); + + ClassAssignmentResult reassigned = service.assign( + gameWith(TyrantUnlock.ASSASSIN), + Map.of(VIGILANTE, holder, FOLLOWER, replacement), + TYRANT, FOLLOWER, TyrantClass.ASSASSIN, NOW + ); + PlayerState former = reassigned.players().get(VIGILANTE); + Map cleared = service.clearAll(reassigned.players()); + + assertEquals(Map.of(), former.cooldownEnds()); + assertEquals(Set.of(), former.readyAbilityItems()); + assertEquals(TyrantClass.NONE, cleared.get(FOLLOWER).tyrantClass()); + } + + private static GameState gameWith(TyrantUnlock unlock) { + return new GameState( + GameLifecycle.RUNNING, + Optional.of(TYRANT), + Optional.of(VIGILANTE), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Duration.ZERO, + 1, + 0, + Set.of(unlock) + ); + } + + private static PlayerState withFollower(PlayerState player, UUID vigilanteId) { + return new PlayerState( + player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(), + player.tyrantClass(), Optional.of(vigilanteId), player.cooldownEnds(), + player.readyAbilityItems(), player.capturedMobs() + ); + } +} diff --git a/src/test/java/games/dmg/spigottyrant/TyrantCommandTest.java b/src/test/java/games/dmg/spigottyrant/TyrantCommandTest.java index 36625f0..3485964 100644 --- a/src/test/java/games/dmg/spigottyrant/TyrantCommandTest.java +++ b/src/test/java/games/dmg/spigottyrant/TyrantCommandTest.java @@ -2,10 +2,14 @@ package games.dmg.spigottyrant; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.time.Clock; import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; import java.util.List; import java.util.Optional; import java.util.Set; @@ -47,4 +51,45 @@ final class TyrantCommandTest { assertTrue(all.stream().anyMatch(message -> message.contains("AVAILABLE") && message.contains("ASSASSIN"))); } + + @Test + void assigningClassNotifiesTyrantAndNewHolder() { + UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111"); + UUID targetId = UUID.fromString("22222222-2222-2222-2222-222222222222"); + GameState game = new GameState( + GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(), + Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO, + 0, 0, Set.of(TyrantUnlock.FIXER) + ); + PlayerState targetState = PlayerState.newPlayer(targetId, "Target"); + TyrantStateManager manager = mock(TyrantStateManager.class); + when(manager.game()).thenReturn(game); + when(manager.player(targetId, "Target")).thenReturn(targetState); + when(manager.players()).thenReturn(java.util.Map.of(targetId, targetState)); + Player tyrant = mock(Player.class); + when(tyrant.getUniqueId()).thenReturn(tyrantId); + Player target = mock(Player.class); + when(target.getUniqueId()).thenReturn(targetId); + when(target.getName()).thenReturn("Target"); + OnlinePlayerDirectory directory = mock(OnlinePlayerDirectory.class); + when(directory.findByName("Target")).thenReturn(target); + TyrantCommand command = new TyrantCommand( + manager, + new TyrantProgressionService(), + new ClassAssignmentService(), + directory, + Clock.fixed(Instant.parse("2026-08-14T12:00:00Z"), ZoneOffset.UTC) + ); + + command.onCommand( + tyrant, + mock(Command.class), + "tyrant", + new String[] {"assign", "fixer", "Target"} + ); + + verify(manager).replacePlayers(any()); + verify(target).sendMessage(org.mockito.ArgumentMatchers.contains("now the FIXER")); + verify(tyrant).sendMessage(org.mockito.ArgumentMatchers.contains("Assigned FIXER")); + } }