diff --git a/design/log.md b/design/log.md index 63ee817..b056fdb 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 — 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 - 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. diff --git a/design/user-stories/us-006-use-fixer-abilities.md b/design/user-stories/us-006-use-fixer-abilities.md index 6d5e302..c8c8222 100644 --- a/design/user-stories/us-006-use-fixer-abilities.md +++ b/design/user-stories/us-006-use-fixer-abilities.md @@ -2,7 +2,7 @@ type: User Story title: "US-006: Use Fixer abilities" description: Give the Fixer a periodic offensive and health enhancement strengthened near the Tyrant. -status: backlog +status: done --- # 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 -- [ ] 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. -- [ ] 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. -- [ ] 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. -- [ ] The player can inspect the remaining cooldown and receives clear feedback when activation is unavailable. +- [x] The Fixer can activate ten minutes of Strength and Health Boost once per rolling hour using the named Fixer ability item. +- [x] Normal activation provides two total rows of hearts when combined with the player's normal maximum health. +- [x] Activation in the same world and within the configured Tyrant range provides three total rows of hearts. +- [x] Proximity is evaluated when the ability is activated and does not alter the active tier afterward. +- [x] Health is safely clamped when the boost expires, is suppressed, or changes tier. +- [x] The cooldown uses unpaused elapsed time and survives death, logout, and restart. +- [x] The player can inspect the remaining cooldown and receives clear feedback when activation is unavailable. ## Related diff --git a/src/main/java/games/dmg/spigottyrant/Ability.java b/src/main/java/games/dmg/spigottyrant/Ability.java index 57ed729..a9b1297 100644 --- a/src/main/java/games/dmg/spigottyrant/Ability.java +++ b/src/main/java/games/dmg/spigottyrant/Ability.java @@ -8,6 +8,7 @@ public enum Ability { ASSASSIN_WEAKNESS_ACTIVE, FIXER_BOOST, FIXER_BOOST_ACTIVE, + FIXER_NEAR_TYRANT_ACTIVE, TAMER_CAPTURE, ROSTER_INTELLIGENCE } diff --git a/src/main/java/games/dmg/spigottyrant/FixerAbilityService.java b/src/main/java/games/dmg/spigottyrant/FixerAbilityService.java new file mode 100644 index 0000000..49b486b --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/FixerAbilityService.java @@ -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 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); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/FixerEffectController.java b/src/main/java/games/dmg/spigottyrant/FixerEffectController.java new file mode 100644 index 0000000..ca9ba32 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/FixerEffectController.java @@ -0,0 +1,74 @@ +package games.dmg.spigottyrant; + +import java.time.Clock; +import java.time.Instant; +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; + + 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) { + clear(player); + continue; + } + 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() { + server.getOnlinePlayers().forEach(FixerEffectController::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()); + } + } +} diff --git a/src/main/java/games/dmg/spigottyrant/FixerItemListener.java b/src/main/java/games/dmg/spigottyrant/FixerItemListener.java new file mode 100644 index 0000000..5d47933 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/FixerItemListener.java @@ -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 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(); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/FixerUseResult.java b/src/main/java/games/dmg/spigottyrant/FixerUseResult.java new file mode 100644 index 0000000..fe8bd37 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/FixerUseResult.java @@ -0,0 +1,8 @@ +package games.dmg.spigottyrant; + +public record FixerUseResult( + PlayerState player, + AbilityUseStatus status, + int heartRows +) { +} diff --git a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java index 88902bb..ddbb4b0 100644 --- a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java +++ b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java @@ -13,6 +13,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin { private TyrantPresentation tyrantPresentation; private VigilanteEffectController vigilanteEffects; private AssassinEffectController assassinEffects; + private FixerEffectController fixerEffects; @Override public void onEnable() { @@ -57,6 +58,8 @@ public final class SpigotTyrantPlugin extends JavaPlugin { assassinEffects = new AssassinEffectController( stateManager, getServer(), settings, clock ); + FixerAbilityService fixerAbilities = new FixerAbilityService(readiness, settings); + fixerEffects = new FixerEffectController(stateManager, getServer(), settings, clock); VigilanteCombatTracker combatTracker = new VigilanteCombatTracker(); vigilanteEffects = new VigilanteEffectController( stateManager, getServer(), combatTracker, settings, clock @@ -129,6 +132,12 @@ public final class SpigotTyrantPlugin extends JavaPlugin { new AssassinJumpListener(stateManager, assassinAbilities, clock), this ); + getServer().getPluginManager().registerEvents( + new FixerItemListener( + stateManager, abilityItems, fixerAbilities, settings, clock + ), + this + ); long maintenanceTicks = Math.max( 1L, Math.multiplyExact(settings.selectionRetryInterval().toSeconds(), 20L) ); @@ -142,6 +151,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, new AbilityItemRefreshTask( @@ -156,6 +166,9 @@ public final class SpigotTyrantPlugin extends JavaPlugin { @Override public void onDisable() { + if (fixerEffects != null) { + fixerEffects.clearAll(); + } if (assassinEffects != null) { assassinEffects.clearAll(); } diff --git a/src/test/java/games/dmg/spigottyrant/FixerAbilityServiceTest.java b/src/test/java/games/dmg/spigottyrant/FixerAbilityServiceTest.java new file mode 100644 index 0000000..9fa455a --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/FixerAbilityServiceTest.java @@ -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)); + } +}