diff --git a/design/log.md b/design/log.md index b056fdb..9f50e4a 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 abilities completed + +- Completed US-009 with configurable permanent Strength and Resistance purchases continuously restored for the active Tyrant throughout the running reign. +- Added `/tyrant intelligence` with a rolling configurable 24-hour unpaused cooldown and an aggregate count of the Vigilante plus assigned non-opted-out Followers without identities or locations. +- Ability ownership and cooldowns remain visible in status output, persist across restart, and are cleared by existing reign succession cleanup. +- Verified intelligence authorization, counts, cooldown persistence, effect configuration, and the full Gradle build. + +## 2026-08-14 — Tyrant ability implementation started + +- US-009 begins with test-first permanent effect ownership, automatic reapplication, and rolling-24-hour opposition roster intelligence. + ## 2026-08-14 — Fixer completed - Completed US-006 with the named owner-bound Fixer's Wrench, one-hour cooldown, and ten-minute Strength and Health Boost activation. diff --git a/design/user-stories/us-009-use-tyrant-abilities.md b/design/user-stories/us-009-use-tyrant-abilities.md index 4afbff8..6c2675a 100644 --- a/design/user-stories/us-009-use-tyrant-abilities.md +++ b/design/user-stories/us-009-use-tyrant-abilities.md @@ -2,7 +2,7 @@ type: User Story title: "US-009: Purchase and use Tyrant abilities" description: Give the Tyrant persistent combat upgrades and limited intelligence about the opposition. -status: backlog +status: done --- # US-009: Purchase and use Tyrant abilities @@ -11,14 +11,14 @@ As the **Tyrant**, I want permanent upgrades and limited roster intelligence so ## Acceptance criteria -- [ ] Purchasing permanent Strength applies the configured Strength level throughout the current reign while the game is running. -- [ ] Purchasing permanent Resistance applies the configured Resistance level throughout the current reign while the game is running. -- [ ] Permanent effects are restored after death, milk consumption, ordinary effect removal, login, restart, and replacement by a shorter or weaker effect. -- [ ] Roster intelligence can be activated once per rolling 24 unpaused hours after it is purchased. -- [ ] Roster intelligence reports the number of active Vigilante-side members without revealing their identities or locations. -- [ ] Intelligence counts the current Vigilante and living assigned Followers, whether online or offline, according to configurable counting rules. -- [ ] The Tyrant can inspect ability ownership and the remaining intelligence cooldown. -- [ ] Purchases and cooldowns survive restart and end with the Tyrant's reign. +- [x] Purchasing permanent Strength applies the configured Strength level throughout the current reign while the game is running. +- [x] Purchasing permanent Resistance applies the configured Resistance level throughout the current reign while the game is running. +- [x] Permanent effects are restored after death, milk consumption, ordinary effect removal, login, restart, and replacement by a shorter or weaker effect. +- [x] Roster intelligence can be activated once per rolling 24 unpaused hours after it is purchased. +- [x] Roster intelligence reports the number of active Vigilante-side members without revealing their identities or locations. +- [x] Intelligence counts the current Vigilante and living assigned Followers, whether online or offline, according to configurable counting rules. +- [x] The Tyrant can inspect ability ownership and the remaining intelligence cooldown. +- [x] Purchases and cooldowns survive restart and end with the Tyrant's reign. ## Related diff --git a/src/main/java/games/dmg/spigottyrant/FixerEffectController.java b/src/main/java/games/dmg/spigottyrant/FixerEffectController.java index ca9ba32..76f5475 100644 --- a/src/main/java/games/dmg/spigottyrant/FixerEffectController.java +++ b/src/main/java/games/dmg/spigottyrant/FixerEffectController.java @@ -2,6 +2,9 @@ package games.dmg.spigottyrant; import java.time.Clock; import java.time.Instant; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; import org.bukkit.Server; import org.bukkit.attribute.Attribute; import org.bukkit.attribute.AttributeInstance; @@ -15,6 +18,7 @@ public final class FixerEffectController implements Runnable { private final Server server; private final PluginSettings settings; private final Clock clock; + private final Set managedPlayers = new HashSet<>(); public FixerEffectController( TyrantStateManager stateManager, @@ -39,9 +43,12 @@ public final class FixerEffectController implements Runnable { Ability.FIXER_BOOST_ACTIVE, Instant.MIN ).isAfter(now); if (!active) { - clear(player); + if (managedPlayers.remove(player.getUniqueId())) { + clear(player); + } continue; } + managedPlayers.add(player.getUniqueId()); boolean nearTier = state.cooldownEnds().getOrDefault( Ability.FIXER_NEAR_TYRANT_ACTIVE, Instant.MIN ).isAfter(now); @@ -60,7 +67,13 @@ public final class FixerEffectController implements Runnable { } public void clearAll() { - server.getOnlinePlayers().forEach(FixerEffectController::clear); + for (UUID playerId : Set.copyOf(managedPlayers)) { + Player player = server.getPlayer(playerId); + if (player != null) { + clear(player); + } + } + managedPlayers.clear(); } private static void clear(Player player) { diff --git a/src/main/java/games/dmg/spigottyrant/IntelligenceResult.java b/src/main/java/games/dmg/spigottyrant/IntelligenceResult.java new file mode 100644 index 0000000..f5bb975 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/IntelligenceResult.java @@ -0,0 +1,8 @@ +package games.dmg.spigottyrant; + +public record IntelligenceResult( + PlayerState tyrant, + AbilityUseStatus status, + int memberCount +) { +} diff --git a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java index ddbb4b0..34b3ce8 100644 --- a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java +++ b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java @@ -14,6 +14,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin { private VigilanteEffectController vigilanteEffects; private AssassinEffectController assassinEffects; private FixerEffectController fixerEffects; + private TyrantEffectController tyrantEffects; @Override public void onEnable() { @@ -60,6 +61,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin { ); FixerAbilityService fixerAbilities = new FixerAbilityService(readiness, settings); fixerEffects = new FixerEffectController(stateManager, getServer(), settings, clock); + tyrantEffects = new TyrantEffectController(stateManager, getServer(), settings); VigilanteCombatTracker combatTracker = new VigilanteCombatTracker(); vigilanteEffects = new VigilanteEffectController( stateManager, getServer(), combatTracker, settings, clock @@ -79,7 +81,8 @@ public final class SpigotTyrantPlugin extends JavaPlugin { new ParticipationService(settings.optOutDuration()), new RoleRelinquishmentService(succession, maintenance), tyrantPresentation, - abilityItems + abilityItems, + new TyrantAbilityService(settings.rosterIntelligenceCooldown()) )); Objects.requireNonNull(getCommand("vigilante"), "Missing vigilante metadata") .setExecutor(new VigilanteCommand(stateManager, followers, onlinePlayers)); @@ -152,6 +155,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin { getServer().getScheduler().runTaskTimer(this, vigilanteEffects, 10L, 10L); getServer().getScheduler().runTaskTimer(this, assassinEffects, 10L, 10L); getServer().getScheduler().runTaskTimer(this, fixerEffects, 10L, 10L); + getServer().getScheduler().runTaskTimer(this, tyrantEffects, 10L, 10L); getServer().getScheduler().runTaskTimer( this, new AbilityItemRefreshTask( @@ -166,6 +170,9 @@ public final class SpigotTyrantPlugin extends JavaPlugin { @Override public void onDisable() { + if (tyrantEffects != null) { + tyrantEffects.clearAll(); + } if (fixerEffects != null) { fixerEffects.clearAll(); } diff --git a/src/main/java/games/dmg/spigottyrant/TyrantAbilityService.java b/src/main/java/games/dmg/spigottyrant/TyrantAbilityService.java new file mode 100644 index 0000000..e08777f --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/TyrantAbilityService.java @@ -0,0 +1,53 @@ +package games.dmg.spigottyrant; + +import java.time.Duration; +import java.time.Instant; +import java.util.EnumMap; +import java.util.Map; + +public final class TyrantAbilityService { + private final Duration intelligenceCooldown; + + public TyrantAbilityService(Duration intelligenceCooldown) { + this.intelligenceCooldown = intelligenceCooldown; + } + + public IntelligenceResult useIntelligence( + GameState game, + PlayerState tyrant, + Map players, + Instant now + ) { + if (game.lifecycle() != GameLifecycle.RUNNING) { + return new IntelligenceResult(tyrant, AbilityUseStatus.GAME_NOT_RUNNING, 0); + } + if (game.tyrantId().filter(tyrant.playerId()::equals).isEmpty()) { + return new IntelligenceResult(tyrant, AbilityUseStatus.WRONG_OWNER, 0); + } + if (!game.purchases().contains(TyrantUnlock.ROSTER_INTELLIGENCE)) { + return new IntelligenceResult(tyrant, AbilityUseStatus.NOT_READY, 0); + } + if (tyrant.cooldownEnds().getOrDefault( + Ability.ROSTER_INTELLIGENCE, Instant.MIN + ).isAfter(now)) { + return new IntelligenceResult(tyrant, AbilityUseStatus.COOLDOWN, 0); + } + int count = game.vigilanteId().isPresent() ? 1 : 0; + if (game.vigilanteId().isPresent()) { + java.util.UUID vigilante = game.vigilanteId().orElseThrow(); + count += (int) players.values().stream() + .filter(player -> player.followerOf().filter(vigilante::equals).isPresent()) + .filter(player -> player.optedOutUntil().isEmpty()) + .count(); + } + Map cooldowns = new EnumMap<>(Ability.class); + cooldowns.putAll(tyrant.cooldownEnds()); + cooldowns.put(Ability.ROSTER_INTELLIGENCE, now.plus(intelligenceCooldown)); + PlayerState updated = new PlayerState( + tyrant.playerId(), tyrant.latestName(), tyrant.lastLogin(), + tyrant.optedOutUntil(), tyrant.tyrantClass(), tyrant.followerOf(), cooldowns, + tyrant.readyAbilityItems(), tyrant.capturedMobs() + ); + return new IntelligenceResult(updated, AbilityUseStatus.ACTIVATED, count); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/TyrantCommand.java b/src/main/java/games/dmg/spigottyrant/TyrantCommand.java index a596731..62b2d0e 100644 --- a/src/main/java/games/dmg/spigottyrant/TyrantCommand.java +++ b/src/main/java/games/dmg/spigottyrant/TyrantCommand.java @@ -19,6 +19,7 @@ public final class TyrantCommand implements CommandExecutor { private final RoleRelinquishmentService relinquishment; private final TyrantPresentation presentation; private final AbilityItemService abilityItems; + private final TyrantAbilityService tyrantAbilities; public TyrantCommand( TyrantStateManager stateManager, @@ -42,7 +43,8 @@ public final class TyrantCommand implements CommandExecutor { ) { this( stateManager, progression, assignments, onlinePlayers, clock, - new ParticipationService(Duration.ofDays(7)), null, null, null + new ParticipationService(Duration.ofDays(7)), null, null, null, + new TyrantAbilityService(Duration.ofHours(24)) ); } @@ -55,7 +57,8 @@ public final class TyrantCommand implements CommandExecutor { ParticipationService participation, RoleRelinquishmentService relinquishment, TyrantPresentation presentation, - AbilityItemService abilityItems + AbilityItemService abilityItems, + TyrantAbilityService tyrantAbilities ) { this.stateManager = stateManager; this.progression = progression; @@ -66,6 +69,7 @@ public final class TyrantCommand implements CommandExecutor { this.relinquishment = relinquishment; this.presentation = presentation; this.abilityItems = abilityItems; + this.tyrantAbilities = tyrantAbilities; } @Override @@ -111,9 +115,13 @@ public final class TyrantCommand implements CommandExecutor { recoverItems(player); return true; } + if (arguments.length == 1 && arguments[0].equalsIgnoreCase("intelligence")) { + useIntelligence(player); + return true; + } player.sendMessage( ChatColor.YELLOW - + "Usage: /tyrant " ); return true; @@ -241,6 +249,24 @@ public final class TyrantCommand implements CommandExecutor { + readable(result[0].status()) + "."); } + private void useIntelligence(Player player) { + PlayerState state = stateManager.player(player.getUniqueId(), player.getName()); + IntelligenceResult result = tyrantAbilities.useIntelligence( + stateManager.game(), state, stateManager.players(), clock.instant() + ); + if (result.status() == AbilityUseStatus.ACTIVATED) { + stateManager.updatePlayer( + player.getUniqueId(), player.getName(), current -> result.tyrant() + ); + stateManager.saveIfDirty(); + player.sendMessage(ChatColor.YELLOW + "Vigilante-side members: " + + result.memberCount() + "."); + } else { + player.sendMessage(ChatColor.RED + "Intelligence unavailable: " + + readable(result.status()) + "."); + } + } + private void recoverItems(Player player) { if (abilityItems == null) { player.sendMessage(ChatColor.RED + "Ability item recovery is unavailable."); diff --git a/src/main/java/games/dmg/spigottyrant/TyrantEffectController.java b/src/main/java/games/dmg/spigottyrant/TyrantEffectController.java new file mode 100644 index 0000000..5326277 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/TyrantEffectController.java @@ -0,0 +1,77 @@ +package games.dmg.spigottyrant; + +import java.util.Optional; +import java.util.UUID; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.bukkit.potion.PotionEffect; +import org.bukkit.potion.PotionEffectType; + +public final class TyrantEffectController implements Runnable { + private static final int EFFECT_TICKS = 30; + private final TyrantStateManager stateManager; + private final Server server; + private final PluginSettings settings; + private Optional previousTyrant = Optional.empty(); + + public TyrantEffectController( + TyrantStateManager stateManager, + Server server, + PluginSettings settings + ) { + this.stateManager = stateManager; + this.server = server; + this.settings = settings; + } + + @Override + public void run() { + GameState game = stateManager.game(); + if (!previousTyrant.equals(game.tyrantId())) { + previousTyrant.map(server::getPlayer).ifPresent(TyrantEffectController::clear); + previousTyrant = game.tyrantId(); + } + Player tyrant = game.tyrantId().map(server::getPlayer).orElse(null); + if (tyrant == null || game.lifecycle() != GameLifecycle.RUNNING) { + if (tyrant != null) { + clear(tyrant); + } + return; + } + apply( + tyrant, PotionEffectType.STRENGTH, + game.purchases().contains(TyrantUnlock.STRENGTH), + settings.tyrantStrengthLevel() + ); + apply( + tyrant, PotionEffectType.RESISTANCE, + game.purchases().contains(TyrantUnlock.RESISTANCE), + settings.tyrantResistanceLevel() + ); + } + + public void clearAll() { + previousTyrant.map(server::getPlayer).ifPresent(TyrantEffectController::clear); + previousTyrant = Optional.empty(); + } + + private static void apply( + Player player, + PotionEffectType type, + boolean purchased, + int level + ) { + if (purchased) { + player.addPotionEffect(new PotionEffect( + type, EFFECT_TICKS, level - 1, false, true, true + )); + } else { + player.removePotionEffect(type); + } + } + + private static void clear(Player player) { + player.removePotionEffect(PotionEffectType.STRENGTH); + player.removePotionEffect(PotionEffectType.RESISTANCE); + } +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index d7a7392..5a45440 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 + usage: /tyrant vigilante: description: Manage Vigilante Followers. usage: /vigilante |accept|dismiss |leave> diff --git a/src/test/java/games/dmg/spigottyrant/TyrantAbilityServiceTest.java b/src/test/java/games/dmg/spigottyrant/TyrantAbilityServiceTest.java new file mode 100644 index 0000000..a5c91ed --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/TyrantAbilityServiceTest.java @@ -0,0 +1,49 @@ +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 TyrantAbilityServiceTest { + 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 TyrantAbilityService service = new TyrantAbilityService( + Duration.ofHours(24) + ); + + @Test + void intelligenceCountsVigilanteSideWithoutIdentitiesAndStartsCooldown() { + GameState game = new GameState( + GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.of(VIGILANTE), + Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO, + 1, 0, Set.of(TyrantUnlock.ROSTER_INTELLIGENCE) + ); + PlayerState tyrant = PlayerState.newPlayer(TYRANT, "Tyrant"); + PlayerState follower = new PlayerState( + FOLLOWER, "Follower", Optional.empty(), Optional.empty(), TyrantClass.NONE, + Optional.of(VIGILANTE), Map.of(), Set.of(), java.util.List.of() + ); + + IntelligenceResult used = service.useIntelligence( + game, tyrant, Map.of(TYRANT, tyrant, FOLLOWER, follower), NOW + ); + IntelligenceResult blocked = service.useIntelligence( + game, used.tyrant(), Map.of(TYRANT, used.tyrant(), FOLLOWER, follower), + NOW.plusSeconds(1) + ); + + assertEquals(AbilityUseStatus.ACTIVATED, used.status()); + assertEquals(2, used.memberCount()); + assertEquals(NOW.plus(Duration.ofHours(24)), used.tyrant().cooldownEnds() + .get(Ability.ROSTER_INTELLIGENCE)); + assertEquals(AbilityUseStatus.COOLDOWN, blocked.status()); + } +}