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
|
# 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
|
## 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
|
||||||
|
|
||||||
|
|||||||
@@ -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,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 TyrantPresentation tyrantPresentation;
|
||||||
private VigilanteEffectController vigilanteEffects;
|
private VigilanteEffectController vigilanteEffects;
|
||||||
private AssassinEffectController assassinEffects;
|
private AssassinEffectController assassinEffects;
|
||||||
|
private FixerEffectController fixerEffects;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onEnable() {
|
public void onEnable() {
|
||||||
@@ -57,6 +58,8 @@ 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);
|
||||||
VigilanteCombatTracker combatTracker = new VigilanteCombatTracker();
|
VigilanteCombatTracker combatTracker = new VigilanteCombatTracker();
|
||||||
vigilanteEffects = new VigilanteEffectController(
|
vigilanteEffects = new VigilanteEffectController(
|
||||||
stateManager, getServer(), combatTracker, settings, clock
|
stateManager, getServer(), combatTracker, settings, clock
|
||||||
@@ -129,6 +132,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 +151,7 @@ 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(
|
getServer().getScheduler().runTaskTimer(
|
||||||
this,
|
this,
|
||||||
new AbilityItemRefreshTask(
|
new AbilityItemRefreshTask(
|
||||||
@@ -156,6 +166,9 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onDisable() {
|
public void onDisable() {
|
||||||
|
if (fixerEffects != null) {
|
||||||
|
fixerEffects.clearAll();
|
||||||
|
}
|
||||||
if (assassinEffects != null) {
|
if (assassinEffects != null) {
|
||||||
assassinEffects.clearAll();
|
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