diff --git a/design/log.md b/design/log.md index cc5ab93..3043306 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 — Participation completed + +- Completed US-011 with `/tyrant status`, `optout`, `optin`, and confirmed `relinquish` flows plus comprehensive join messages for lifecycle, roles, classes, abilities, and cooldowns. +- Opt-out clears ordinary classes and Follower membership, persists until the player explicitly opts back in, and enforces the configurable seven-day unpaused waiting period. +- Central role holders must safely relinquish first; Tyrant relinquishment performs full succession and Vigilante relinquishment performs a normal Vigilante reroll. +- Updated all assignment and candidate paths to exclude opted-out players and verified status, persistence, cleanup, cooldown, and relinquishment behavior with the Gradle suite. + +## 2026-08-14 — Participation implementation started + +- US-011 begins with test-first opt-out/opt-in state, seven-day re-entry gating, safe role relinquishment guidance, and complete join status messages. + ## 2026-08-14 — Role maintenance completed - Completed US-010 with configurable periodic role maintenance, exact 48-hour inactivity handling, and 24-hour recently-active candidate filtering. diff --git a/design/user-stories/us-011-inform-and-manage-participation.md b/design/user-stories/us-011-inform-and-manage-participation.md index 2f27a93..9f122d7 100644 --- a/design/user-stories/us-011-inform-and-manage-participation.md +++ b/design/user-stories/us-011-inform-and-manage-participation.md @@ -2,7 +2,7 @@ type: User Story title: "US-011: Inform players and manage participation" description: Tell players their current place in the game and let unassigned players opt out. -status: backlog +status: done --- # US-011: Inform players and manage participation @@ -11,15 +11,15 @@ As a **player**, I want to understand my status and control my eligibility so th ## Acceptance criteria -- [ ] On join, a player is told the game status and their role, class, subclass, abilities, and relevant cooldowns. -- [ ] An unassigned opted-in player is told that they are eligible and shown the opt-out command. -- [ ] An opted-out player is told when they may opt back in. -- [ ] An unassigned player can opt out through a command. -- [ ] Opting out removes an ordinary class or Follower assignment immediately; the Tyrant and Vigilante must relinquish through a safe role command before opting out. -- [ ] Opted-out players cannot be randomly selected, recruited, or assigned a class. -- [ ] A player cannot opt back in until seven days have elapsed by default. -- [ ] The opt-out duration is configurable, uses unpaused elapsed time, and survives restart. -- [ ] Participation commands are idempotent and clearly report current status and remaining time. +- [x] On join, a player is told the game status and their role, class, subclass, abilities, and relevant cooldowns. +- [x] An unassigned opted-in player is told that they are eligible and shown the opt-out command. +- [x] An opted-out player is told when they may opt back in. +- [x] An unassigned player can opt out through a command. +- [x] Opting out removes an ordinary class or Follower assignment immediately; the Tyrant and Vigilante must relinquish through a safe role command before opting out. +- [x] Opted-out players cannot be randomly selected, recruited, or assigned a class. +- [x] A player cannot opt back in until seven days have elapsed by default. +- [x] The opt-out duration is configurable, uses unpaused elapsed time, and survives restart. +- [x] Participation commands are idempotent and clearly report current status and remaining time. ## Related diff --git a/src/main/java/games/dmg/spigottyrant/BukkitOnlinePlayerDirectory.java b/src/main/java/games/dmg/spigottyrant/BukkitOnlinePlayerDirectory.java index 06ae107..4b057fb 100644 --- a/src/main/java/games/dmg/spigottyrant/BukkitOnlinePlayerDirectory.java +++ b/src/main/java/games/dmg/spigottyrant/BukkitOnlinePlayerDirectory.java @@ -14,4 +14,11 @@ public final class BukkitOnlinePlayerDirectory implements OnlinePlayerDirectory public Player findById(UUID playerId) { return Bukkit.getPlayer(playerId); } + + @Override + public java.util.Set onlinePlayerIds() { + return Bukkit.getOnlinePlayers().stream() + .map(Player::getUniqueId) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + } } diff --git a/src/main/java/games/dmg/spigottyrant/ClassAssignmentService.java b/src/main/java/games/dmg/spigottyrant/ClassAssignmentService.java index 184b97c..5fff88f 100644 --- a/src/main/java/games/dmg/spigottyrant/ClassAssignmentService.java +++ b/src/main/java/games/dmg/spigottyrant/ClassAssignmentService.java @@ -78,7 +78,7 @@ public final class ClassAssignmentService { if (targetId.equals(assignerId)) { return ClassAssignmentStatus.TARGET_IS_TYRANT; } - if (target.optedOutUntil().filter(until -> until.isAfter(now)).isPresent()) { + if (target.optedOutUntil().isPresent()) { return ClassAssignmentStatus.TARGET_OPTED_OUT; } return null; diff --git a/src/main/java/games/dmg/spigottyrant/OnlinePlayerDirectory.java b/src/main/java/games/dmg/spigottyrant/OnlinePlayerDirectory.java index b11eba5..33fdccd 100644 --- a/src/main/java/games/dmg/spigottyrant/OnlinePlayerDirectory.java +++ b/src/main/java/games/dmg/spigottyrant/OnlinePlayerDirectory.java @@ -7,4 +7,6 @@ public interface OnlinePlayerDirectory { Player findByName(String name); Player findById(UUID playerId); + + java.util.Set onlinePlayerIds(); } diff --git a/src/main/java/games/dmg/spigottyrant/ParticipationResult.java b/src/main/java/games/dmg/spigottyrant/ParticipationResult.java new file mode 100644 index 0000000..7be4224 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/ParticipationResult.java @@ -0,0 +1,4 @@ +package games.dmg.spigottyrant; + +public record ParticipationResult(PlayerState player, ParticipationStatus status) { +} diff --git a/src/main/java/games/dmg/spigottyrant/ParticipationService.java b/src/main/java/games/dmg/spigottyrant/ParticipationService.java new file mode 100644 index 0000000..72640f4 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/ParticipationService.java @@ -0,0 +1,68 @@ +package games.dmg.spigottyrant; + +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +public final class ParticipationService { + private final Duration optOutDuration; + + public ParticipationService(Duration optOutDuration) { + if (optOutDuration.isNegative() || optOutDuration.isZero()) { + throw new IllegalArgumentException("optOutDuration must be positive"); + } + this.optOutDuration = optOutDuration; + } + + public ParticipationResult optOut(GameState game, PlayerState player, Instant now) { + if (game.tyrantId().filter(player.playerId()::equals).isPresent() + || game.vigilanteId().filter(player.playerId()::equals).isPresent()) { + return new ParticipationResult( + player, ParticipationStatus.MUST_RELINQUISH_ROLE + ); + } + if (player.optedOutUntil().isPresent()) { + return new ParticipationResult(player, ParticipationStatus.ALREADY_OPTED_OUT); + } + PlayerState updated = new PlayerState( + player.playerId(), + player.latestName(), + player.lastLogin(), + Optional.of(now.plus(optOutDuration)), + TyrantClass.NONE, + Optional.empty(), + Map.of(), + Set.of(), + player.capturedMobs() + ); + return new ParticipationResult(updated, ParticipationStatus.OPTED_OUT); + } + + public ParticipationResult optIn(PlayerState player, Instant now) { + if (player.optedOutUntil().isEmpty()) { + return new ParticipationResult(player, ParticipationStatus.ALREADY_OPTED_IN); + } + if (now.isBefore(player.optedOutUntil().orElseThrow())) { + return new ParticipationResult(player, ParticipationStatus.COOLDOWN_ACTIVE); + } + PlayerState updated = new PlayerState( + player.playerId(), + player.latestName(), + player.lastLogin(), + Optional.empty(), + player.tyrantClass(), + player.followerOf(), + player.cooldownEnds(), + player.readyAbilityItems(), + player.capturedMobs() + ); + return new ParticipationResult(updated, ParticipationStatus.OPTED_IN); + } + + public ParticipationStatus status(PlayerState player, Instant now) { + return player.optedOutUntil().isPresent() + ? ParticipationStatus.OPTED_OUT : ParticipationStatus.OPTED_IN; + } +} diff --git a/src/main/java/games/dmg/spigottyrant/ParticipationStatus.java b/src/main/java/games/dmg/spigottyrant/ParticipationStatus.java new file mode 100644 index 0000000..acc6d64 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/ParticipationStatus.java @@ -0,0 +1,10 @@ +package games.dmg.spigottyrant; + +public enum ParticipationStatus { + OPTED_IN, + OPTED_OUT, + COOLDOWN_ACTIVE, + MUST_RELINQUISH_ROLE, + ALREADY_OPTED_IN, + ALREADY_OPTED_OUT +} diff --git a/src/main/java/games/dmg/spigottyrant/PlayerJoinListener.java b/src/main/java/games/dmg/spigottyrant/PlayerJoinListener.java index 4f9dc7c..60702de 100644 --- a/src/main/java/games/dmg/spigottyrant/PlayerJoinListener.java +++ b/src/main/java/games/dmg/spigottyrant/PlayerJoinListener.java @@ -35,9 +35,10 @@ public final class PlayerJoinListener implements Listener { current.cooldownEnds(), current.readyAbilityItems(), current.capturedMobs() ) ); - String reminder = ClassAssignmentMessages.loginReminder(player); - if (!reminder.isEmpty()) { - event.getPlayer().sendMessage(reminder); + for (String message : PlayerStatusMessages.forJoin( + stateManager.game(), player, clock.instant() + )) { + event.getPlayer().sendMessage(message); } if (presentation != null && stateManager.game().lifecycle() == GameLifecycle.RUNNING diff --git a/src/main/java/games/dmg/spigottyrant/PlayerStatusMessages.java b/src/main/java/games/dmg/spigottyrant/PlayerStatusMessages.java new file mode 100644 index 0000000..f43c287 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/PlayerStatusMessages.java @@ -0,0 +1,53 @@ +package games.dmg.spigottyrant; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +public final class PlayerStatusMessages { + private PlayerStatusMessages() { + } + + public static List forJoin( + GameState game, + PlayerState player, + Instant now + ) { + List messages = new ArrayList<>(); + messages.add("Game: " + game.lifecycle()); + boolean hasRole = false; + if (game.tyrantId().filter(player.playerId()::equals).isPresent()) { + messages.add("Role: TYRANT. Level " + game.tyrantLevel() + ", choices " + + game.unspentChoices() + ", abilities " + game.purchases() + "."); + hasRole = true; + } + if (game.vigilanteId().filter(player.playerId()::equals).isPresent()) { + messages.add("Role: VIGILANTE."); + hasRole = true; + } else if (player.followerOf().isPresent()) { + messages.add("Role: FOLLOWER of " + player.followerOf().orElseThrow() + "."); + hasRole = true; + } + if (player.tyrantClass() != TyrantClass.NONE) { + messages.add(ClassAssignmentMessages.loginReminder(player)); + hasRole = true; + } + player.cooldownEnds().entrySet().stream() + .sorted(java.util.Map.Entry.comparingByKey()) + .forEach(entry -> { + long seconds = Math.max( + 0L, Duration.between(now, entry.getValue()).toSeconds() + ); + messages.add("Cooldown: " + entry.getKey() + " " + seconds + "s remaining."); + }); + if (player.optedOutUntil().isPresent()) { + messages.add("Participation: OPTED OUT. You may use /tyrant optin at " + + player.optedOutUntil().orElseThrow() + "."); + } else if (!hasRole) { + messages.add("You currently have no role or class. Use /tyrant optout " + + "if you do not want to participate."); + } + return List.copyOf(messages); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/RelinquishmentResult.java b/src/main/java/games/dmg/spigottyrant/RelinquishmentResult.java new file mode 100644 index 0000000..37b0446 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/RelinquishmentResult.java @@ -0,0 +1,7 @@ +package games.dmg.spigottyrant; + +public record RelinquishmentResult( + LifecycleState state, + RelinquishmentStatus status +) { +} diff --git a/src/main/java/games/dmg/spigottyrant/RelinquishmentStatus.java b/src/main/java/games/dmg/spigottyrant/RelinquishmentStatus.java new file mode 100644 index 0000000..92f5198 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/RelinquishmentStatus.java @@ -0,0 +1,7 @@ +package games.dmg.spigottyrant; + +public enum RelinquishmentStatus { + RELINQUISHED, + NO_CENTRAL_ROLE, + GAME_NOT_RUNNING +} diff --git a/src/main/java/games/dmg/spigottyrant/RoleCandidateSelector.java b/src/main/java/games/dmg/spigottyrant/RoleCandidateSelector.java index fbc0082..7f5b134 100644 --- a/src/main/java/games/dmg/spigottyrant/RoleCandidateSelector.java +++ b/src/main/java/games/dmg/spigottyrant/RoleCandidateSelector.java @@ -30,7 +30,7 @@ public final class RoleCandidateSelector { || player.lastLogin().orElseThrow().isBefore(cutoff)) { continue; } - if (player.optedOutUntil().filter(deadline -> deadline.isAfter(now)).isPresent()) { + if (player.optedOutUntil().isPresent()) { continue; } eligible.add(player.playerId()); diff --git a/src/main/java/games/dmg/spigottyrant/RoleMaintenanceService.java b/src/main/java/games/dmg/spigottyrant/RoleMaintenanceService.java index e70095f..68e2519 100644 --- a/src/main/java/games/dmg/spigottyrant/RoleMaintenanceService.java +++ b/src/main/java/games/dmg/spigottyrant/RoleMaintenanceService.java @@ -195,8 +195,7 @@ public final class RoleMaintenanceService { } private static boolean eligibleAtConfirmation(PlayerState player, Instant now) { - return player != null - && player.optedOutUntil().filter(deadline -> deadline.isAfter(now)).isEmpty(); + return player != null && player.optedOutUntil().isEmpty(); } private static Map clearFollowers( diff --git a/src/main/java/games/dmg/spigottyrant/RoleRelinquishmentService.java b/src/main/java/games/dmg/spigottyrant/RoleRelinquishmentService.java new file mode 100644 index 0000000..41fd1b2 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/RoleRelinquishmentService.java @@ -0,0 +1,51 @@ +package games.dmg.spigottyrant; + +import java.time.Instant; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +public final class RoleRelinquishmentService { + private final TyrantSuccessionService succession; + private final RoleMaintenanceService maintenance; + + public RoleRelinquishmentService( + TyrantSuccessionService succession, + RoleMaintenanceService maintenance + ) { + this.succession = succession; + this.maintenance = maintenance; + } + + public RelinquishmentResult relinquish( + PersistentState state, + UUID playerId, + Set onlinePlayerIds, + Instant now + ) { + if (state.game().lifecycle() != GameLifecycle.RUNNING) { + return new RelinquishmentResult( + new LifecycleState(state.game(), state.players()), + RelinquishmentStatus.GAME_NOT_RUNNING + ); + } + if (state.game().tyrantId().filter(playerId::equals).isPresent()) { + return new RelinquishmentResult( + succession.succeed(state, Optional.empty(), onlinePlayerIds, now), + RelinquishmentStatus.RELINQUISHED + ); + } + if (state.game().vigilanteId().filter(playerId::equals).isPresent()) { + return new RelinquishmentResult( + maintenance.rerollVigilante( + state, Optional.of(playerId), onlinePlayerIds, now + ), + RelinquishmentStatus.RELINQUISHED + ); + } + return new RelinquishmentResult( + new LifecycleState(state.game(), state.players()), + RelinquishmentStatus.NO_CENTRAL_ROLE + ); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java index fe3bb51..a8af3be 100644 --- a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java +++ b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java @@ -37,18 +37,36 @@ public final class SpigotTyrantPlugin extends JavaPlugin { settings.pendingSelectionTimeout(), random ); + RoleMaintenanceService maintenance = new RoleMaintenanceService( + candidateSelector, + succession, + settings.roleInactivity(), + settings.candidateActivityWindow(), + settings.pendingSelectionTimeout(), + random + ); + BukkitOnlinePlayerDirectory onlinePlayers = new BukkitOnlinePlayerDirectory(); tyrantPresentation = new BukkitTyrantPresentation(getServer()); ManagedRoleEffects managedEffects = new BukkitManagedRoleEffects( stateManager, tyrantPresentation ); managedEffects.restoreAll(); Objects.requireNonNull(getCommand("tyrant"), "Missing tyrant command metadata") - .setExecutor(new TyrantCommand(stateManager, new TyrantProgressionService())); + .setExecutor(new TyrantCommand( + stateManager, + new TyrantProgressionService(), + new ClassAssignmentService(), + onlinePlayers, + clock, + new ParticipationService(settings.optOutDuration()), + new RoleRelinquishmentService(succession, maintenance), + tyrantPresentation + )); Objects.requireNonNull(getCommand("tyrantadmin"), "Missing tyrantadmin metadata") .setExecutor(new TyrantAdminCommand( stateManager, new GameLifecycleService(), - new BukkitOnlinePlayerDirectory(), + onlinePlayers, managedEffects, clock, candidateSelector, @@ -66,14 +84,6 @@ public final class SpigotTyrantPlugin extends JavaPlugin { ), this ); - RoleMaintenanceService maintenance = new RoleMaintenanceService( - candidateSelector, - succession, - settings.roleInactivity(), - settings.candidateActivityWindow(), - settings.pendingSelectionTimeout(), - random - ); long maintenanceTicks = Math.max( 1L, Math.multiplyExact(settings.selectionRetryInterval().toSeconds(), 20L) ); diff --git a/src/main/java/games/dmg/spigottyrant/TyrantAdminCommand.java b/src/main/java/games/dmg/spigottyrant/TyrantAdminCommand.java index 2845a98..bc27042 100644 --- a/src/main/java/games/dmg/spigottyrant/TyrantAdminCommand.java +++ b/src/main/java/games/dmg/spigottyrant/TyrantAdminCommand.java @@ -187,9 +187,7 @@ public final class TyrantAdminCommand implements CommandExecutor { sender.sendMessage("Pending Tyrant: " + game.pendingTyrant()); sender.sendMessage("Pending Vigilante: " + game.pendingVigilante()); for (PlayerState player : stateManager.players().values()) { - boolean optedOut = player.optedOutUntil() - .filter(deadline -> deadline.isAfter(clock.instant())) - .isPresent(); + boolean optedOut = player.optedOutUntil().isPresent(); sender.sendMessage( player.latestName() + " (" + player.playerId() + "): class=" + player.tyrantClass() + ", followerOf=" diff --git a/src/main/java/games/dmg/spigottyrant/TyrantCommand.java b/src/main/java/games/dmg/spigottyrant/TyrantCommand.java index 36a7915..6eee591 100644 --- a/src/main/java/games/dmg/spigottyrant/TyrantCommand.java +++ b/src/main/java/games/dmg/spigottyrant/TyrantCommand.java @@ -1,6 +1,7 @@ package games.dmg.spigottyrant; import java.time.Clock; +import java.time.Duration; import java.util.Locale; import org.bukkit.ChatColor; import org.bukkit.command.Command; @@ -14,6 +15,9 @@ public final class TyrantCommand implements CommandExecutor { private final ClassAssignmentService assignments; private final OnlinePlayerDirectory onlinePlayers; private final Clock clock; + private final ParticipationService participation; + private final RoleRelinquishmentService relinquishment; + private final TyrantPresentation presentation; public TyrantCommand( TyrantStateManager stateManager, @@ -34,12 +38,31 @@ public final class TyrantCommand implements CommandExecutor { ClassAssignmentService assignments, OnlinePlayerDirectory onlinePlayers, Clock clock + ) { + this( + stateManager, progression, assignments, onlinePlayers, clock, + new ParticipationService(Duration.ofDays(7)), null, null + ); + } + + public TyrantCommand( + TyrantStateManager stateManager, + TyrantProgressionService progression, + ClassAssignmentService assignments, + OnlinePlayerDirectory onlinePlayers, + Clock clock, + ParticipationService participation, + RoleRelinquishmentService relinquishment, + TyrantPresentation presentation ) { this.stateManager = stateManager; this.progression = progression; this.assignments = assignments; this.onlinePlayers = onlinePlayers; this.clock = clock; + this.participation = participation; + this.relinquishment = relinquishment; + this.presentation = presentation; } @Override @@ -65,10 +88,26 @@ public final class TyrantCommand implements CommandExecutor { assign(player, arguments[1], arguments[2]); return true; } + if (arguments.length == 1 && arguments[0].equalsIgnoreCase("status")) { + showPlayerStatus(player); + return true; + } + if (arguments.length == 1 && arguments[0].equalsIgnoreCase("optout")) { + changeParticipation(player, false); + return true; + } + if (arguments.length == 1 && arguments[0].equalsIgnoreCase("optin")) { + changeParticipation(player, true); + return true; + } + if (arguments.length >= 1 && arguments[0].equalsIgnoreCase("relinquish")) { + relinquish(player, arguments); + return true; + } player.sendMessage( ChatColor.YELLOW - + "Usage: /tyrant choices | /tyrant buy | " - + "/tyrant assign " + + "Usage: /tyrant " ); return true; } @@ -156,6 +195,63 @@ public final class TyrantCommand implements CommandExecutor { ); } + private void showPlayerStatus(Player player) { + PlayerState state = stateManager.player(player.getUniqueId(), player.getName()); + PlayerStatusMessages.forJoin(stateManager.game(), state, clock.instant()) + .forEach(player::sendMessage); + } + + private void changeParticipation(Player player, boolean optIn) { + ParticipationResult[] result = new ParticipationResult[1]; + stateManager.updatePlayer(player.getUniqueId(), player.getName(), current -> { + result[0] = optIn + ? participation.optIn(current, clock.instant()) + : participation.optOut(stateManager.game(), current, clock.instant()); + return result[0].player(); + }); + if (result[0].status() == ParticipationStatus.OPTED_IN + || result[0].status() == ParticipationStatus.OPTED_OUT) { + stateManager.saveIfDirty(); + } + if (result[0].status() == ParticipationStatus.MUST_RELINQUISH_ROLE) { + player.sendMessage(ChatColor.RED + + "Use /tyrant relinquish confirm before opting out."); + return; + } + if (result[0].status() == ParticipationStatus.COOLDOWN_ACTIVE) { + player.sendMessage(ChatColor.RED + "You may opt in at " + + result[0].player().optedOutUntil().orElseThrow() + "."); + return; + } + player.sendMessage(ChatColor.YELLOW + "Participation: " + + readable(result[0].status()) + "."); + } + + private void relinquish(Player player, String[] arguments) { + if (arguments.length != 2 || !arguments[1].equalsIgnoreCase("confirm")) { + player.sendMessage(ChatColor.RED + + "Relinquishing rerolls your role. Use /tyrant relinquish confirm."); + return; + } + if (relinquishment == null) { + player.sendMessage(ChatColor.RED + "Role relinquishment is unavailable."); + return; + } + RelinquishmentResult result = relinquishment.relinquish( + stateManager.snapshot(), player.getUniqueId(), onlinePlayers.onlinePlayerIds(), + clock.instant() + ); + if (result.status() == RelinquishmentStatus.RELINQUISHED) { + stateManager.replaceState(result.state()); + stateManager.saveIfDirty(); + if (presentation != null) { + presentation.reconcile(result.state().game().tyrantId()); + } + } + player.sendMessage(ChatColor.YELLOW + "Relinquishment: " + + readable(result.status()) + "."); + } + private static String readable(Enum status) { return status.name().toLowerCase(Locale.ROOT).replace('_', ' '); } diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index d355d52..4891f9c 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -7,7 +7,7 @@ author: dmg.games commands: tyrant: description: View and use Spigot Tyrant game features. - usage: /tyrant [choices|buy |assign ] + usage: /tyrant tyrantadmin: description: Administer the Spigot Tyrant game. usage: /tyrantadmin diff --git a/src/test/java/games/dmg/spigottyrant/ParticipationServiceTest.java b/src/test/java/games/dmg/spigottyrant/ParticipationServiceTest.java new file mode 100644 index 0000000..1b6c080 --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/ParticipationServiceTest.java @@ -0,0 +1,76 @@ +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 ParticipationServiceTest { + private static final UUID PLAYER = UUID.fromString("11111111-1111-1111-1111-111111111111"); + private static final UUID TYRANT = UUID.fromString("22222222-2222-2222-2222-222222222222"); + private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z"); + private final ParticipationService service = new ParticipationService(Duration.ofDays(7)); + + @Test + void optingOutClearsOrdinaryClassAndFollowerButKeepsPlayerOutAfterCooldown() { + PlayerState assigned = new PlayerState( + PLAYER, "Player", Optional.of(NOW), Optional.empty(), TyrantClass.ASSASSIN, + Optional.of(UUID.randomUUID()), + Map.of(Ability.ASSASSIN_INVISIBILITY, NOW.plusSeconds(30)), + Set.of(Ability.ASSASSIN_INVISIBILITY), java.util.List.of() + ); + GameState game = runningGame(); + + ParticipationResult result = service.optOut(game, assigned, NOW); + + assertEquals(ParticipationStatus.OPTED_OUT, result.status()); + assertEquals(Optional.of(NOW.plus(Duration.ofDays(7))), + result.player().optedOutUntil()); + assertEquals(TyrantClass.NONE, result.player().tyrantClass()); + assertEquals(Optional.empty(), result.player().followerOf()); + assertEquals(Map.of(), result.player().cooldownEnds()); + assertEquals(ParticipationStatus.OPTED_OUT, + service.status(result.player(), NOW.plus(Duration.ofDays(8)))); + } + + @Test + void optingBackInIsBlockedUntilSevenDaysAndThenClearsOptOut() { + PlayerState player = service.optOut( + runningGame(), PlayerState.newPlayer(PLAYER, "Player"), NOW + ).player(); + + ParticipationResult early = service.optIn(player, NOW.plus(Duration.ofDays(6))); + ParticipationResult ready = service.optIn(player, NOW.plus(Duration.ofDays(7))); + + assertEquals(ParticipationStatus.COOLDOWN_ACTIVE, early.status()); + assertEquals(ParticipationStatus.OPTED_IN, ready.status()); + assertEquals(Optional.empty(), ready.player().optedOutUntil()); + } + + @Test + void currentCentralRoleMustRelinquishBeforeOptingOut() { + GameState game = new GameState( + GameLifecycle.RUNNING, Optional.of(PLAYER), Optional.empty(), Optional.empty(), + Optional.empty(), Optional.empty(), Duration.ZERO, 0, 1, Set.of() + ); + + ParticipationResult result = service.optOut( + game, PlayerState.newPlayer(PLAYER, "Tyrant"), NOW + ); + + assertEquals(ParticipationStatus.MUST_RELINQUISH_ROLE, result.status()); + assertEquals(Optional.empty(), result.player().optedOutUntil()); + } + + private static GameState runningGame() { + return new GameState( + GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.empty(), Optional.empty(), + Optional.empty(), Optional.empty(), Duration.ZERO, 0, 1, Set.of() + ); + } +} diff --git a/src/test/java/games/dmg/spigottyrant/PlayerStatusMessagesTest.java b/src/test/java/games/dmg/spigottyrant/PlayerStatusMessagesTest.java new file mode 100644 index 0000000..ad237d5 --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/PlayerStatusMessagesTest.java @@ -0,0 +1,47 @@ +package games.dmg.spigottyrant; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +final class PlayerStatusMessagesTest { + private static final UUID PLAYER = UUID.fromString("11111111-1111-1111-1111-111111111111"); + private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z"); + + @Test + void assignedPlayerSeesGameRoleClassAbilitiesAndCooldowns() { + GameState game = new GameState( + GameLifecycle.RUNNING, Optional.empty(), Optional.of(PLAYER), Optional.empty(), + Optional.empty(), Optional.empty(), Duration.ZERO, 0, 0, Set.of() + ); + PlayerState player = new PlayerState( + PLAYER, "Player", Optional.of(NOW), Optional.empty(), TyrantClass.ASSASSIN, + Optional.empty(), Map.of(Ability.ASSASSIN_INVISIBILITY, NOW.plusSeconds(60)), + Set.of(), List.of() + ); + + List messages = PlayerStatusMessages.forJoin(game, player, NOW); + + assertTrue(messages.stream().anyMatch(message -> message.contains("Game: RUNNING"))); + assertTrue(messages.stream().anyMatch(message -> message.contains("Role: VIGILANTE"))); + assertTrue(messages.stream().anyMatch(message -> message.contains("Class: ASSASSIN"))); + assertTrue(messages.stream().anyMatch(message -> message.contains("ASSASSIN_INVISIBILITY") + && message.contains("60s"))); + } + + @Test + void unassignedPlayerIsToldHowToOptOut() { + List messages = PlayerStatusMessages.forJoin( + GameState.empty(), PlayerState.newPlayer(PLAYER, "Player"), NOW + ); + + assertTrue(messages.stream().anyMatch(message -> message.contains("/tyrant optout"))); + } +} diff --git a/src/test/java/games/dmg/spigottyrant/RoleRelinquishmentServiceTest.java b/src/test/java/games/dmg/spigottyrant/RoleRelinquishmentServiceTest.java new file mode 100644 index 0000000..a296626 --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/RoleRelinquishmentServiceTest.java @@ -0,0 +1,62 @@ +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 java.util.random.RandomGenerator; +import org.junit.jupiter.api.Test; + +final class RoleRelinquishmentServiceTest { + @Test + void vigilanteCanRelinquishAndTriggerNormalReroll() { + UUID tyrant = UUID.fromString("11111111-1111-1111-1111-111111111111"); + UUID vigilante = UUID.fromString("22222222-2222-2222-2222-222222222222"); + UUID candidate = UUID.fromString("33333333-3333-3333-3333-333333333333"); + Instant now = Instant.parse("2026-08-14T12:00:00Z"); + RoleCandidateSelector selector = new RoleCandidateSelector(); + RandomGenerator random = RandomGenerator.of("L64X128MixRandom"); + TyrantSuccessionService succession = new TyrantSuccessionService( + selector, Duration.ofHours(24), Duration.ofHours(24), random + ); + RoleMaintenanceService maintenance = new RoleMaintenanceService( + selector, succession, Duration.ofHours(48), Duration.ofHours(24), + Duration.ofHours(24), random + ); + RoleRelinquishmentService service = new RoleRelinquishmentService( + succession, maintenance + ); + GameState game = new GameState( + GameLifecycle.RUNNING, Optional.of(tyrant), Optional.of(vigilante), + Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO, + 0, 0, Set.of() + ); + PlayerState tyrantState = active(tyrant, now); + PlayerState vigilanteState = active(vigilante, now); + PlayerState candidateState = active(candidate, now); + + RelinquishmentResult result = service.relinquish( + new PersistentState(game, Map.of( + tyrant, tyrantState, vigilante, vigilanteState, candidate, candidateState + )), + vigilante, + Set.of(tyrant, candidate), + now + ); + + assertEquals(RelinquishmentStatus.RELINQUISHED, result.status()); + assertEquals(Optional.of(candidate), result.state().game().vigilanteId()); + } + + private static PlayerState active(UUID id, Instant now) { + PlayerState player = PlayerState.newPlayer(id, id.toString()); + return new PlayerState( + id, player.latestName(), Optional.of(now.minusSeconds(60)), Optional.empty(), + TyrantClass.NONE, Optional.empty(), Map.of(), Set.of(), java.util.List.of() + ); + } +}