feat(tyrant): add permanent powers and intelligence
Release / release (push) Successful in 2m15s
CI / build (push) Successful in 1m3s

This commit is contained in:
dmg
2026-08-14 23:14:26 -04:00
parent a82d20c7e6
commit cd38e87487
10 changed files with 260 additions and 16 deletions
+11
View File
@@ -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.
@@ -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
@@ -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<UUID> 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) {
@@ -0,0 +1,8 @@
package games.dmg.spigottyrant;
public record IntelligenceResult(
PlayerState tyrant,
AbilityUseStatus status,
int memberCount
) {
}
@@ -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();
}
@@ -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<java.util.UUID, PlayerState> 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<Ability, Instant> 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);
}
}
@@ -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 <status|choices|buy|assign|item|optout|optin|"
+ "Usage: /tyrant <status|choices|buy|assign|item|intelligence|optout|optin|"
+ "relinquish confirm>"
);
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.");
@@ -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<UUID> 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);
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ author: dmg.games
commands:
tyrant:
description: View and use Spigot Tyrant game features.
usage: /tyrant <status|choices|buy|assign|optout|optin|relinquish confirm>
usage: /tyrant <status|choices|buy|assign|item|intelligence|optout|optin|relinquish confirm>
vigilante:
description: Manage Vigilante Followers.
usage: /vigilante <invite <player>|accept|dismiss <player>|leave>
@@ -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());
}
}