feat(fixer): add timed combat boost
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ public enum Ability {
|
||||
ASSASSIN_WEAKNESS_ACTIVE,
|
||||
FIXER_BOOST,
|
||||
FIXER_BOOST_ACTIVE,
|
||||
FIXER_NEAR_TYRANT_ACTIVE,
|
||||
TAMER_CAPTURE,
|
||||
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,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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user