Compare commits

...
2 Commits
Author SHA1 Message Date
dmg cd38e87487 feat(tyrant): add permanent powers and intelligence
Release / release (push) Successful in 2m15s
CI / build (push) Successful in 1m3s
2026-08-14 23:14:26 -04:00
dmg a82d20c7e6 feat(fixer): add timed combat boost
Release / release (push) Successful in 2m17s
CI / build (push) Successful in 1m2s
2026-08-14 23:11:16 -04:00
16 changed files with 534 additions and 22 deletions
+22
View File
@@ -6,6 +6,28 @@ description: Chronological record of material decisions affecting the Spigot Tyr
# Spigot Tyrant Design Log # 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.
- Activation records a fixed normal two-row or near-Tyrant three-row heart tier using same-world configurable proximity at use time.
- Continuously refreshed effects survive death, login, milk, and ordinary removal; expiry and class loss remove effects and clamp health safely to the resulting maximum.
- Verified activation tiers, active and cooldown deadlines, item consumption/readiness, persistence, and the full Gradle build.
## 2026-08-14 — Fixer implementation started
- US-006 begins with test-first bound-item combat boosts, activation-time proximity tiers, persistent cooldowns, and safe health cleanup.
## 2026-08-14 — Assassin completed ## 2026-08-14 — Assassin completed
- Completed US-005 with named owner-bound Assassin Cloak activation, one-hour cooldown, particle-free ten-minute invisibility, and activation-time doubling within the Tyrant's configurable range. - Completed US-005 with named owner-bound Assassin Cloak activation, one-hour cooldown, particle-free ten-minute invisibility, and activation-time doubling within the Tyrant's configurable range.
@@ -2,7 +2,7 @@
type: User Story type: User Story
title: "US-006: Use Fixer abilities" title: "US-006: Use Fixer abilities"
description: Give the Fixer a periodic offensive and health enhancement strengthened near the Tyrant. description: Give the Fixer a periodic offensive and health enhancement strengthened near the Tyrant.
status: backlog status: done
--- ---
# US-006: Use Fixer abilities # US-006: Use Fixer abilities
@@ -11,13 +11,13 @@ As the **Fixer**, I want a substantial temporary combat boost so that I can prot
## Acceptance criteria ## Acceptance criteria
- [ ] The Fixer can activate ten minutes of Strength and Health Boost once per rolling hour using the named Fixer ability item. - [x] The Fixer can activate ten minutes of Strength and Health Boost once per rolling hour using the named Fixer ability item.
- [ ] Normal activation provides two total rows of hearts when combined with the player's normal maximum health. - [x] Normal activation provides two total rows of hearts when combined with the player's normal maximum health.
- [ ] Activation in the same world and within the configured Tyrant range provides three total rows of hearts. - [x] Activation in the same world and within the configured Tyrant range provides three total rows of hearts.
- [ ] Proximity is evaluated when the ability is activated and does not alter the active tier afterward. - [x] Proximity is evaluated when the ability is activated and does not alter the active tier afterward.
- [ ] Health is safely clamped when the boost expires, is suppressed, or changes tier. - [x] Health is safely clamped when the boost expires, is suppressed, or changes tier.
- [ ] The cooldown uses unpaused elapsed time and survives death, logout, and restart. - [x] The cooldown uses unpaused elapsed time and survives death, logout, and restart.
- [ ] The player can inspect the remaining cooldown and receives clear feedback when activation is unavailable. - [x] The player can inspect the remaining cooldown and receives clear feedback when activation is unavailable.
## Related ## Related
@@ -2,7 +2,7 @@
type: User Story type: User Story
title: "US-009: Purchase and use Tyrant abilities" title: "US-009: Purchase and use Tyrant abilities"
description: Give the Tyrant persistent combat upgrades and limited intelligence about the opposition. description: Give the Tyrant persistent combat upgrades and limited intelligence about the opposition.
status: backlog status: done
--- ---
# US-009: Purchase and use Tyrant abilities # 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 ## Acceptance criteria
- [ ] Purchasing permanent Strength applies the configured Strength level throughout the current reign while the game is running. - [x] 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. - [x] 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. - [x] 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. - [x] 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. - [x] 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. - [x] 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. - [x] The Tyrant can inspect ability ownership and the remaining intelligence cooldown.
- [ ] Purchases and cooldowns survive restart and end with the Tyrant's reign. - [x] Purchases and cooldowns survive restart and end with the Tyrant's reign.
## Related ## Related
@@ -8,6 +8,7 @@ public enum Ability {
ASSASSIN_WEAKNESS_ACTIVE, ASSASSIN_WEAKNESS_ACTIVE,
FIXER_BOOST, FIXER_BOOST,
FIXER_BOOST_ACTIVE, FIXER_BOOST_ACTIVE,
FIXER_NEAR_TYRANT_ACTIVE,
TAMER_CAPTURE, TAMER_CAPTURE,
ROSTER_INTELLIGENCE ROSTER_INTELLIGENCE
} }
@@ -0,0 +1,45 @@
package games.dmg.spigottyrant;
import java.time.Instant;
import java.util.EnumMap;
import java.util.Map;
public final class FixerAbilityService {
private final AbilityReadinessService readiness;
private final PluginSettings settings;
public FixerAbilityService(
AbilityReadinessService readiness,
PluginSettings settings
) {
this.readiness = readiness;
this.settings = settings;
}
public FixerUseResult activate(PlayerState player, Instant now, boolean nearTyrant) {
if (player.tyrantClass() != TyrantClass.FIXER) {
return new FixerUseResult(player, AbilityUseStatus.WRONG_CLASS, 0);
}
if (!player.readyAbilityItems().contains(Ability.FIXER_BOOST)) {
return new FixerUseResult(player, AbilityUseStatus.NOT_READY, 0);
}
PlayerState used = readiness.consume(
player, Ability.FIXER_BOOST, now, settings.fixerCooldown()
);
Instant activeUntil = now.plus(settings.fixerEffectDuration());
Map<Ability, Instant> deadlines = new EnumMap<>(Ability.class);
deadlines.putAll(used.cooldownEnds());
deadlines.put(Ability.FIXER_BOOST_ACTIVE, activeUntil);
if (nearTyrant) {
deadlines.put(Ability.FIXER_NEAR_TYRANT_ACTIVE, activeUntil);
}
PlayerState updated = new PlayerState(
used.playerId(), used.latestName(), used.lastLogin(), used.optedOutUntil(),
used.tyrantClass(), used.followerOf(), deadlines, used.readyAbilityItems(),
used.capturedMobs()
);
int rows = nearTyrant
? settings.fixerNearTyrantHeartRows() : settings.fixerNormalHeartRows();
return new FixerUseResult(updated, AbilityUseStatus.ACTIVATED, rows);
}
}
@@ -0,0 +1,87 @@
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;
import org.bukkit.entity.Player;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
public final class FixerEffectController implements Runnable {
private static final int EFFECT_TICKS = 30;
private final TyrantStateManager stateManager;
private final Server server;
private final PluginSettings settings;
private final Clock clock;
private final Set<UUID> managedPlayers = new HashSet<>();
public FixerEffectController(
TyrantStateManager stateManager,
Server server,
PluginSettings settings,
Clock clock
) {
this.stateManager = stateManager;
this.server = server;
this.settings = settings;
this.clock = clock;
}
@Override
public void run() {
Instant now = clock.instant();
for (Player player : server.getOnlinePlayers()) {
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
boolean active = stateManager.game().lifecycle() == GameLifecycle.RUNNING
&& state.tyrantClass() == TyrantClass.FIXER
&& state.cooldownEnds().getOrDefault(
Ability.FIXER_BOOST_ACTIVE, Instant.MIN
).isAfter(now);
if (!active) {
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);
int rows = nearTier
? settings.fixerNearTyrantHeartRows() : settings.fixerNormalHeartRows();
int healthBoostAmplifier = (rows - 1) * 5 - 1;
player.addPotionEffect(new PotionEffect(
PotionEffectType.STRENGTH, EFFECT_TICKS,
settings.fixerStrengthLevel() - 1, false, true, true
));
player.addPotionEffect(new PotionEffect(
PotionEffectType.HEALTH_BOOST, EFFECT_TICKS,
healthBoostAmplifier, false, true, true
));
}
}
public void clearAll() {
for (UUID playerId : Set.copyOf(managedPlayers)) {
Player player = server.getPlayer(playerId);
if (player != null) {
clear(player);
}
}
managedPlayers.clear();
}
private static void clear(Player player) {
player.removePotionEffect(PotionEffectType.STRENGTH);
player.removePotionEffect(PotionEffectType.HEALTH_BOOST);
AttributeInstance maximumHealth = player.getAttribute(Attribute.MAX_HEALTH);
if (maximumHealth != null && player.getHealth() > maximumHealth.getValue()) {
player.setHealth(maximumHealth.getValue());
}
}
}
@@ -0,0 +1,79 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
public final class FixerItemListener implements Listener {
private final TyrantStateManager stateManager;
private final AbilityItemService items;
private final FixerAbilityService abilities;
private final PluginSettings settings;
private final Clock clock;
public FixerItemListener(
TyrantStateManager stateManager,
AbilityItemService items,
FixerAbilityService abilities,
PluginSettings settings,
Clock clock
) {
this.stateManager = stateManager;
this.items = items;
this.abilities = abilities;
this.settings = settings;
this.clock = clock;
}
@EventHandler(priority = EventPriority.HIGH)
public void onInteract(PlayerInteractEvent event) {
Action action = event.getAction();
if (action != Action.RIGHT_CLICK_AIR && action != Action.RIGHT_CLICK_BLOCK
&& action != Action.LEFT_CLICK_AIR && action != Action.LEFT_CLICK_BLOCK) {
return;
}
if (items.ability(event.getItem()).filter(Ability.FIXER_BOOST::equals).isEmpty()) {
return;
}
event.setCancelled(true);
UUID playerId = event.getPlayer().getUniqueId();
if (items.owner(event.getItem()).filter(playerId::equals).isEmpty()) {
event.getPlayer().getInventory().setItem(event.getHand(), null);
event.getPlayer().sendMessage(ChatColor.RED + "That ability item is not yours.");
return;
}
if (stateManager.game().lifecycle() != GameLifecycle.RUNNING) {
event.getPlayer().sendMessage(ChatColor.RED + "The Tyrant game is not running.");
return;
}
PlayerState player = stateManager.player(playerId, event.getPlayer().getName());
FixerUseResult result = abilities.activate(
player, clock.instant(), isNearTyrant(event.getPlayer())
);
if (result.status() == AbilityUseStatus.ACTIVATED) {
stateManager.updatePlayer(playerId, event.getPlayer().getName(), current -> result.player());
stateManager.saveIfDirty();
event.getPlayer().getInventory().setItem(event.getHand(), null);
event.getPlayer().sendMessage(ChatColor.GREEN + "Fixer boost activated with "
+ result.heartRows() + " rows of hearts.");
} else {
event.getPlayer().sendMessage(ChatColor.RED + "Ability unavailable: "
+ result.status().name().toLowerCase(java.util.Locale.ROOT) + ".");
}
}
private boolean isNearTyrant(org.bukkit.entity.Player player) {
Optional<org.bukkit.entity.Player> tyrant = stateManager.game().tyrantId()
.map(org.bukkit.Bukkit::getPlayer);
return tyrant.isPresent()
&& tyrant.orElseThrow().getWorld().equals(player.getWorld())
&& tyrant.orElseThrow().getLocation().distanceSquared(player.getLocation())
<= settings.tyrantRangeBlocks() * settings.tyrantRangeBlocks();
}
}
@@ -0,0 +1,8 @@
package games.dmg.spigottyrant;
public record FixerUseResult(
PlayerState player,
AbilityUseStatus status,
int heartRows
) {
}
@@ -0,0 +1,8 @@
package games.dmg.spigottyrant;
public record IntelligenceResult(
PlayerState tyrant,
AbilityUseStatus status,
int memberCount
) {
}
@@ -13,6 +13,8 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
private TyrantPresentation tyrantPresentation; private TyrantPresentation tyrantPresentation;
private VigilanteEffectController vigilanteEffects; private VigilanteEffectController vigilanteEffects;
private AssassinEffectController assassinEffects; private AssassinEffectController assassinEffects;
private FixerEffectController fixerEffects;
private TyrantEffectController tyrantEffects;
@Override @Override
public void onEnable() { public void onEnable() {
@@ -57,6 +59,9 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
assassinEffects = new AssassinEffectController( assassinEffects = new AssassinEffectController(
stateManager, getServer(), settings, clock stateManager, getServer(), settings, clock
); );
FixerAbilityService fixerAbilities = new FixerAbilityService(readiness, settings);
fixerEffects = new FixerEffectController(stateManager, getServer(), settings, clock);
tyrantEffects = new TyrantEffectController(stateManager, getServer(), settings);
VigilanteCombatTracker combatTracker = new VigilanteCombatTracker(); VigilanteCombatTracker combatTracker = new VigilanteCombatTracker();
vigilanteEffects = new VigilanteEffectController( vigilanteEffects = new VigilanteEffectController(
stateManager, getServer(), combatTracker, settings, clock stateManager, getServer(), combatTracker, settings, clock
@@ -76,7 +81,8 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
new ParticipationService(settings.optOutDuration()), new ParticipationService(settings.optOutDuration()),
new RoleRelinquishmentService(succession, maintenance), new RoleRelinquishmentService(succession, maintenance),
tyrantPresentation, tyrantPresentation,
abilityItems abilityItems,
new TyrantAbilityService(settings.rosterIntelligenceCooldown())
)); ));
Objects.requireNonNull(getCommand("vigilante"), "Missing vigilante metadata") Objects.requireNonNull(getCommand("vigilante"), "Missing vigilante metadata")
.setExecutor(new VigilanteCommand(stateManager, followers, onlinePlayers)); .setExecutor(new VigilanteCommand(stateManager, followers, onlinePlayers));
@@ -129,6 +135,12 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
new AssassinJumpListener(stateManager, assassinAbilities, clock), new AssassinJumpListener(stateManager, assassinAbilities, clock),
this this
); );
getServer().getPluginManager().registerEvents(
new FixerItemListener(
stateManager, abilityItems, fixerAbilities, settings, clock
),
this
);
long maintenanceTicks = Math.max( long maintenanceTicks = Math.max(
1L, Math.multiplyExact(settings.selectionRetryInterval().toSeconds(), 20L) 1L, Math.multiplyExact(settings.selectionRetryInterval().toSeconds(), 20L)
); );
@@ -142,6 +154,8 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
); );
getServer().getScheduler().runTaskTimer(this, vigilanteEffects, 10L, 10L); getServer().getScheduler().runTaskTimer(this, vigilanteEffects, 10L, 10L);
getServer().getScheduler().runTaskTimer(this, assassinEffects, 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( getServer().getScheduler().runTaskTimer(
this, this,
new AbilityItemRefreshTask( new AbilityItemRefreshTask(
@@ -156,6 +170,12 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
@Override @Override
public void onDisable() { public void onDisable() {
if (tyrantEffects != null) {
tyrantEffects.clearAll();
}
if (fixerEffects != null) {
fixerEffects.clearAll();
}
if (assassinEffects != null) { if (assassinEffects != null) {
assassinEffects.clearAll(); assassinEffects.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 RoleRelinquishmentService relinquishment;
private final TyrantPresentation presentation; private final TyrantPresentation presentation;
private final AbilityItemService abilityItems; private final AbilityItemService abilityItems;
private final TyrantAbilityService tyrantAbilities;
public TyrantCommand( public TyrantCommand(
TyrantStateManager stateManager, TyrantStateManager stateManager,
@@ -42,7 +43,8 @@ public final class TyrantCommand implements CommandExecutor {
) { ) {
this( this(
stateManager, progression, assignments, onlinePlayers, clock, 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, ParticipationService participation,
RoleRelinquishmentService relinquishment, RoleRelinquishmentService relinquishment,
TyrantPresentation presentation, TyrantPresentation presentation,
AbilityItemService abilityItems AbilityItemService abilityItems,
TyrantAbilityService tyrantAbilities
) { ) {
this.stateManager = stateManager; this.stateManager = stateManager;
this.progression = progression; this.progression = progression;
@@ -66,6 +69,7 @@ public final class TyrantCommand implements CommandExecutor {
this.relinquishment = relinquishment; this.relinquishment = relinquishment;
this.presentation = presentation; this.presentation = presentation;
this.abilityItems = abilityItems; this.abilityItems = abilityItems;
this.tyrantAbilities = tyrantAbilities;
} }
@Override @Override
@@ -111,9 +115,13 @@ public final class TyrantCommand implements CommandExecutor {
recoverItems(player); recoverItems(player);
return true; return true;
} }
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("intelligence")) {
useIntelligence(player);
return true;
}
player.sendMessage( player.sendMessage(
ChatColor.YELLOW ChatColor.YELLOW
+ "Usage: /tyrant <status|choices|buy|assign|item|optout|optin|" + "Usage: /tyrant <status|choices|buy|assign|item|intelligence|optout|optin|"
+ "relinquish confirm>" + "relinquish confirm>"
); );
return true; return true;
@@ -241,6 +249,24 @@ public final class TyrantCommand implements CommandExecutor {
+ readable(result[0].status()) + "."); + 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) { private void recoverItems(Player player) {
if (abilityItems == null) { if (abilityItems == null) {
player.sendMessage(ChatColor.RED + "Ability item recovery is unavailable."); 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: commands:
tyrant: tyrant:
description: View and use Spigot Tyrant game features. 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: vigilante:
description: Manage Vigilante Followers. description: Manage Vigilante Followers.
usage: /vigilante <invite <player>|accept|dismiss <player>|leave> usage: /vigilante <invite <player>|accept|dismiss <player>|leave>
@@ -0,0 +1,37 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
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 FixerAbilityServiceTest {
private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z");
private final FixerAbilityService service = new FixerAbilityService(
new AbilityReadinessService(), PluginSettings.from(Map.of())
);
@Test
void boostConsumesItemAndRecordsNormalOrNearTyrantHeartTier() {
PlayerState ready = new PlayerState(
UUID.fromString("11111111-1111-1111-1111-111111111111"), "Fixer",
Optional.empty(), Optional.empty(), TyrantClass.FIXER, Optional.empty(),
Map.of(), Set.of(Ability.FIXER_BOOST), java.util.List.of()
);
FixerUseResult normal = service.activate(ready, NOW, false);
FixerUseResult near = service.activate(ready, NOW, true);
assertEquals(AbilityUseStatus.ACTIVATED, normal.status());
assertEquals(2, normal.heartRows());
assertEquals(3, near.heartRows());
assertEquals(NOW.plusSeconds(600), normal.player().cooldownEnds()
.get(Ability.FIXER_BOOST_ACTIVE));
assertEquals(NOW.plusSeconds(3600), normal.player().cooldownEnds()
.get(Ability.FIXER_BOOST));
}
}
@@ -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());
}
}