diff --git a/design/log.md b/design/log.md index a738ac7..63ee817 100644 --- a/design/log.md +++ b/design/log.md @@ -6,6 +6,21 @@ description: Chronological record of material decisions affecting the Spigot Tyr # Spigot Tyrant Design Log +## 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. +- Added 60-second double-jump gating, upward launch, 15-second Speed, 20-second Weakness III, and grounded readiness restoration. +- Active effects and cooldowns persist as UTC deadlines and are continuously restored after death, login, milk, or ordinary removal while valid. +- Verified duration tiers, cooldown consumption and refresh, jump effect deadlines, class constraints, persistence, and the full Gradle build. + +## 2026-08-14 — Assassin implementation started + +- US-005 begins with test-first bound-item invisibility, near-Tyrant duration doubling, and cooldown-gated double-jump effects. + +## 2026-08-14 — Bound ability item implementation started + +- US-014 begins with test-first item readiness and recovery, owner metadata, transfer prevention, cooldown redelivery, and Tamer item permanence. + ## 2026-08-14 — Vigilante and Followers completed - Completed US-008 with `/vigilante` invitation, acceptance, dismissal, and leave flows while preserving independent Tyrant-side class assignments. diff --git a/design/user-stories/us-005-use-assassin-abilities.md b/design/user-stories/us-005-use-assassin-abilities.md index 21e52f7..4212fd6 100644 --- a/design/user-stories/us-005-use-assassin-abilities.md +++ b/design/user-stories/us-005-use-assassin-abilities.md @@ -2,7 +2,7 @@ type: User Story title: "US-005: Use Assassin abilities" description: Give the Assassin timed stealth and a risky burst-mobility ability. -status: backlog +status: done --- # US-005: Use Assassin abilities @@ -11,15 +11,15 @@ As the **Assassin**, I want stealth and burst mobility so that I can ambush oppo ## Acceptance criteria -- [ ] The Assassin can activate ten minutes of invisibility once per rolling hour using the named Assassin ability item. -- [ ] Invisibility displays no potion particles. -- [ ] Activation while in the same world and within the configured Tyrant range grants twenty minutes instead. -- [ ] Proximity is evaluated when the ability is activated and does not alter the active duration afterward. -- [ ] The Assassin can double-jump once every 60 seconds without requiring an item. -- [ ] A successful double-jump grants Speed for 15 seconds and Weakness III for 20 seconds. -- [ ] Grounding and supported movement reset jump availability only after the 60-second cooldown has elapsed. -- [ ] Ability cooldowns use unpaused elapsed time and survive death, logout, and restart. -- [ ] The player can inspect remaining cooldowns and receives clear feedback when an activation is unavailable. +- [x] The Assassin can activate ten minutes of invisibility once per rolling hour using the named Assassin ability item. +- [x] Invisibility displays no potion particles. +- [x] Activation while in the same world and within the configured Tyrant range grants twenty minutes instead. +- [x] Proximity is evaluated when the ability is activated and does not alter the active duration afterward. +- [x] The Assassin can double-jump once every 60 seconds without requiring an item. +- [x] A successful double-jump grants Speed for 15 seconds and Weakness III for 20 seconds. +- [x] Grounding and supported movement reset jump availability only after the 60-second cooldown has elapsed. +- [x] Ability cooldowns use unpaused elapsed time and survive death, logout, and restart. +- [x] The player can inspect remaining cooldowns and receives clear feedback when an activation is unavailable. ## Related diff --git a/design/user-stories/us-014-use-class-ability-items.md b/design/user-stories/us-014-use-class-ability-items.md index 9088716..7fae31d 100644 --- a/design/user-stories/us-014-use-class-ability-items.md +++ b/design/user-stories/us-014-use-class-ability-items.md @@ -2,7 +2,7 @@ type: User Story title: "US-014: Use class ability items" description: Give class holders named bound items that activate abilities without enabling transfer or cooldown bypasses. -status: backlog +status: in-progress --- # US-014: Use class ability items diff --git a/src/main/java/games/dmg/spigottyrant/Ability.java b/src/main/java/games/dmg/spigottyrant/Ability.java index fb46467..57ed729 100644 --- a/src/main/java/games/dmg/spigottyrant/Ability.java +++ b/src/main/java/games/dmg/spigottyrant/Ability.java @@ -2,8 +2,12 @@ package games.dmg.spigottyrant; public enum Ability { ASSASSIN_INVISIBILITY, + ASSASSIN_INVISIBILITY_ACTIVE, ASSASSIN_DOUBLE_JUMP, + ASSASSIN_SPEED_ACTIVE, + ASSASSIN_WEAKNESS_ACTIVE, FIXER_BOOST, + FIXER_BOOST_ACTIVE, TAMER_CAPTURE, ROSTER_INTELLIGENCE } diff --git a/src/main/java/games/dmg/spigottyrant/AbilityItemRefreshTask.java b/src/main/java/games/dmg/spigottyrant/AbilityItemRefreshTask.java new file mode 100644 index 0000000..fdf3811 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/AbilityItemRefreshTask.java @@ -0,0 +1,45 @@ +package games.dmg.spigottyrant; + +import java.time.Clock; +import org.bukkit.Server; +import org.bukkit.entity.Player; + +public final class AbilityItemRefreshTask implements Runnable { + private final TyrantStateManager stateManager; + private final AbilityReadinessService readiness; + private final AbilityItemService items; + private final Server server; + private final Clock clock; + + public AbilityItemRefreshTask( + TyrantStateManager stateManager, + AbilityReadinessService readiness, + AbilityItemService items, + Server server, + Clock clock + ) { + this.stateManager = stateManager; + this.readiness = readiness; + this.items = items; + this.server = server; + this.clock = clock; + } + + @Override + public void run() { + if (stateManager.game().lifecycle() != GameLifecycle.RUNNING) { + return; + } + for (Player player : server.getOnlinePlayers()) { + PlayerState before = stateManager.player(player.getUniqueId(), player.getName()); + PlayerState after = readiness.refresh(before, clock.instant()); + if (!after.equals(before)) { + stateManager.updatePlayer( + player.getUniqueId(), player.getName(), current -> after + ); + } + items.removeInvalid(player, after); + items.recover(player, after); + } + } +} diff --git a/src/main/java/games/dmg/spigottyrant/AbilityItemService.java b/src/main/java/games/dmg/spigottyrant/AbilityItemService.java new file mode 100644 index 0000000..1dea4a1 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/AbilityItemService.java @@ -0,0 +1,20 @@ +package games.dmg.spigottyrant; + +import java.util.Optional; +import java.util.UUID; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +public interface AbilityItemService { + void recover(Player player, PlayerState state); + + boolean isBoundAbilityItem(ItemStack item); + + Optional ability(ItemStack item); + + Optional owner(ItemStack item); + + void removeInvalid(Player player, PlayerState state); + + void removeAll(Player player); +} diff --git a/src/main/java/games/dmg/spigottyrant/AbilityReadinessService.java b/src/main/java/games/dmg/spigottyrant/AbilityReadinessService.java new file mode 100644 index 0000000..bb5b61c --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/AbilityReadinessService.java @@ -0,0 +1,68 @@ +package games.dmg.spigottyrant; + +import java.time.Duration; +import java.time.Instant; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.Map; +import java.util.Set; + +public final class AbilityReadinessService { + public PlayerState consume( + PlayerState player, + Ability ability, + Instant now, + Duration cooldown + ) { + if (!player.readyAbilityItems().contains(ability)) { + throw new IllegalStateException("ability item is not ready"); + } + if (ability == Ability.TAMER_CAPTURE && cooldown.isZero()) { + return player; + } + Set ready = EnumSet.noneOf(Ability.class); + ready.addAll(player.readyAbilityItems()); + ready.remove(ability); + Map cooldowns = new EnumMap<>(Ability.class); + cooldowns.putAll(player.cooldownEnds()); + cooldowns.put(ability, now.plus(cooldown)); + return copy(player, cooldowns, ready); + } + + public PlayerState refresh(PlayerState player, Instant now) { + Map cooldowns = new EnumMap<>(Ability.class); + cooldowns.putAll(player.cooldownEnds()); + Set ready = EnumSet.noneOf(Ability.class); + ready.addAll(player.readyAbilityItems()); + for (Map.Entry entry : player.cooldownEnds().entrySet()) { + if (!entry.getValue().isAfter(now)) { + cooldowns.remove(entry.getKey()); + if (isItemAbilityForClass(entry.getKey(), player.tyrantClass())) { + ready.add(entry.getKey()); + } + } + } + return copy(player, cooldowns, ready); + } + + private static boolean isItemAbilityForClass(Ability ability, TyrantClass tyrantClass) { + return switch (tyrantClass) { + case ASSASSIN -> ability == Ability.ASSASSIN_INVISIBILITY; + case FIXER -> ability == Ability.FIXER_BOOST; + case TAMER -> ability == Ability.TAMER_CAPTURE; + case NONE -> false; + }; + } + + private static PlayerState copy( + PlayerState player, + Map cooldowns, + Set ready + ) { + return new PlayerState( + player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(), + player.tyrantClass(), player.followerOf(), cooldowns, ready, + player.capturedMobs() + ); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/AbilityUseResult.java b/src/main/java/games/dmg/spigottyrant/AbilityUseResult.java new file mode 100644 index 0000000..99d5b3c --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/AbilityUseResult.java @@ -0,0 +1,4 @@ +package games.dmg.spigottyrant; + +public record AbilityUseResult(PlayerState player, AbilityUseStatus status) { +} diff --git a/src/main/java/games/dmg/spigottyrant/AbilityUseStatus.java b/src/main/java/games/dmg/spigottyrant/AbilityUseStatus.java new file mode 100644 index 0000000..1208502 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/AbilityUseStatus.java @@ -0,0 +1,10 @@ +package games.dmg.spigottyrant; + +public enum AbilityUseStatus { + ACTIVATED, + WRONG_CLASS, + NOT_READY, + COOLDOWN, + GAME_NOT_RUNNING, + WRONG_OWNER +} diff --git a/src/main/java/games/dmg/spigottyrant/AssassinAbilityService.java b/src/main/java/games/dmg/spigottyrant/AssassinAbilityService.java new file mode 100644 index 0000000..a2104f6 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/AssassinAbilityService.java @@ -0,0 +1,88 @@ +package games.dmg.spigottyrant; + +import java.time.Instant; +import java.util.EnumMap; +import java.util.Map; + +public final class AssassinAbilityService { + private final AbilityReadinessService readiness; + private final PluginSettings settings; + + public AssassinAbilityService( + AbilityReadinessService readiness, + PluginSettings settings + ) { + this.readiness = readiness; + this.settings = settings; + } + + public AbilityUseResult activateInvisibility( + PlayerState player, + Instant now, + boolean nearTyrant + ) { + if (player.tyrantClass() != TyrantClass.ASSASSIN) { + return new AbilityUseResult(player, AbilityUseStatus.WRONG_CLASS); + } + if (!player.readyAbilityItems().contains(Ability.ASSASSIN_INVISIBILITY)) { + return new AbilityUseResult(player, AbilityUseStatus.NOT_READY); + } + PlayerState used = readiness.consume( + player, Ability.ASSASSIN_INVISIBILITY, now, settings.assassinCooldown() + ); + Instant activeUntil = now.plus( + nearTyrant + ? settings.assassinInvisibilityDuration().multipliedBy(2) + : settings.assassinInvisibilityDuration() + ); + return new AbilityUseResult( + withDeadline(used, Ability.ASSASSIN_INVISIBILITY_ACTIVE, activeUntil), + AbilityUseStatus.ACTIVATED + ); + } + + public AbilityUseResult doubleJump(PlayerState player, Instant now) { + if (player.tyrantClass() != TyrantClass.ASSASSIN) { + return new AbilityUseResult(player, AbilityUseStatus.WRONG_CLASS); + } + if (player.cooldownEnds().getOrDefault( + Ability.ASSASSIN_DOUBLE_JUMP, Instant.MIN + ).isAfter(now)) { + return new AbilityUseResult(player, AbilityUseStatus.COOLDOWN); + } + Map deadlines = new EnumMap<>(Ability.class); + deadlines.putAll(player.cooldownEnds()); + deadlines.put( + Ability.ASSASSIN_DOUBLE_JUMP, + now.plus(settings.assassinDoubleJumpCooldown()) + ); + deadlines.put( + Ability.ASSASSIN_SPEED_ACTIVE, + now.plus(settings.assassinSpeedDuration()) + ); + deadlines.put( + Ability.ASSASSIN_WEAKNESS_ACTIVE, + now.plus(settings.assassinWeaknessDuration()) + ); + return new AbilityUseResult(copy(player, deadlines), AbilityUseStatus.ACTIVATED); + } + + private static PlayerState withDeadline( + PlayerState player, + Ability ability, + Instant deadline + ) { + Map deadlines = new EnumMap<>(Ability.class); + deadlines.putAll(player.cooldownEnds()); + deadlines.put(ability, deadline); + return copy(player, deadlines); + } + + private static PlayerState copy(PlayerState player, Map deadlines) { + return new PlayerState( + player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(), + player.tyrantClass(), player.followerOf(), deadlines, + player.readyAbilityItems(), player.capturedMobs() + ); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/AssassinEffectController.java b/src/main/java/games/dmg/spigottyrant/AssassinEffectController.java new file mode 100644 index 0000000..588e95a --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/AssassinEffectController.java @@ -0,0 +1,106 @@ +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.GameMode; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.bukkit.potion.PotionEffect; +import org.bukkit.potion.PotionEffectType; + +public final class AssassinEffectController 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 grantedFlight = new HashSet<>(); + + public AssassinEffectController( + 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()); + if (stateManager.game().lifecycle() != GameLifecycle.RUNNING + || state.tyrantClass() != TyrantClass.ASSASSIN) { + clear(player); + continue; + } + applyWhileActive( + player, state, Ability.ASSASSIN_INVISIBILITY_ACTIVE, + PotionEffectType.INVISIBILITY, 0, false + ); + applyWhileActive( + player, state, Ability.ASSASSIN_SPEED_ACTIVE, + PotionEffectType.SPEED, 0, true + ); + applyWhileActive( + player, state, Ability.ASSASSIN_WEAKNESS_ACTIVE, + PotionEffectType.WEAKNESS, settings.assassinWeaknessLevel() - 1, true + ); + boolean jumpReady = !state.cooldownEnds().getOrDefault( + Ability.ASSASSIN_DOUBLE_JUMP, Instant.MIN + ).isAfter(now); + if (jumpReady && isGrounded(player) && isSurvivalLike(player)) { + player.setAllowFlight(true); + grantedFlight.add(player.getUniqueId()); + } + } + } + + public void clearAll() { + server.getOnlinePlayers().forEach(this::clear); + } + + private void applyWhileActive( + Player player, + PlayerState state, + Ability ability, + PotionEffectType type, + int amplifier, + boolean particles + ) { + if (state.cooldownEnds().getOrDefault(ability, Instant.MIN).isAfter(clock.instant())) { + player.addPotionEffect(new PotionEffect( + type, EFFECT_TICKS, amplifier, false, particles, true + )); + } else { + player.removePotionEffect(type); + } + } + + private void clear(Player player) { + player.removePotionEffect(PotionEffectType.INVISIBILITY); + player.removePotionEffect(PotionEffectType.SPEED); + player.removePotionEffect(PotionEffectType.WEAKNESS); + if (grantedFlight.remove(player.getUniqueId()) && isSurvivalLike(player)) { + player.setAllowFlight(false); + player.setFlying(false); + } + } + + private static boolean isGrounded(Player player) { + return !player.getLocation().clone().subtract(0.0, 0.1, 0.0) + .getBlock().isPassable(); + } + + private static boolean isSurvivalLike(Player player) { + return player.getGameMode() != GameMode.CREATIVE + && player.getGameMode() != GameMode.SPECTATOR; + } +} diff --git a/src/main/java/games/dmg/spigottyrant/AssassinItemListener.java b/src/main/java/games/dmg/spigottyrant/AssassinItemListener.java new file mode 100644 index 0000000..3307adc --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/AssassinItemListener.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 AssassinItemListener implements Listener { + private final TyrantStateManager stateManager; + private final AbilityItemService items; + private final AssassinAbilityService abilities; + private final PluginSettings settings; + private final Clock clock; + + public AssassinItemListener( + TyrantStateManager stateManager, + AbilityItemService items, + AssassinAbilityService 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.ASSASSIN_INVISIBILITY::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()); + AbilityUseResult result = abilities.activateInvisibility( + 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 + "Invisibility activated."); + } 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/AssassinJumpListener.java b/src/main/java/games/dmg/spigottyrant/AssassinJumpListener.java new file mode 100644 index 0000000..ebd236a --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/AssassinJumpListener.java @@ -0,0 +1,55 @@ +package games.dmg.spigottyrant; + +import java.time.Clock; +import org.bukkit.ChatColor; +import org.bukkit.GameMode; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerToggleFlightEvent; +import org.bukkit.util.Vector; + +public final class AssassinJumpListener implements Listener { + private final TyrantStateManager stateManager; + private final AssassinAbilityService abilities; + private final Clock clock; + + public AssassinJumpListener( + TyrantStateManager stateManager, + AssassinAbilityService abilities, + Clock clock + ) { + this.stateManager = stateManager; + this.abilities = abilities; + this.clock = clock; + } + + @EventHandler + public void onToggleFlight(PlayerToggleFlightEvent event) { + if (event.getPlayer().getGameMode() == GameMode.CREATIVE + || event.getPlayer().getGameMode() == GameMode.SPECTATOR) { + return; + } + PlayerState state = stateManager.player( + event.getPlayer().getUniqueId(), event.getPlayer().getName() + ); + if (stateManager.game().lifecycle() != GameLifecycle.RUNNING + || state.tyrantClass() != TyrantClass.ASSASSIN) { + return; + } + event.setCancelled(true); + event.getPlayer().setFlying(false); + event.getPlayer().setAllowFlight(false); + AbilityUseResult result = abilities.doubleJump(state, clock.instant()); + if (result.status() != AbilityUseStatus.ACTIVATED) { + event.getPlayer().sendMessage(ChatColor.RED + "Double jump is cooling down."); + return; + } + stateManager.updatePlayer( + event.getPlayer().getUniqueId(), event.getPlayer().getName(), + current -> result.player() + ); + stateManager.saveIfDirty(); + Vector velocity = event.getPlayer().getVelocity(); + event.getPlayer().setVelocity(velocity.setY(Math.max(0.8, velocity.getY()))); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/BoundItemListener.java b/src/main/java/games/dmg/spigottyrant/BoundItemListener.java new file mode 100644 index 0000000..2288e06 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/BoundItemListener.java @@ -0,0 +1,82 @@ +package games.dmg.spigottyrant; + +import org.bukkit.entity.ItemFrame; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.block.BlockDispenseEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.entity.EntityPickupItemEvent; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.player.PlayerDropItemEvent; +import org.bukkit.event.player.PlayerInteractEntityEvent; +import org.bukkit.inventory.ItemStack; + +public final class BoundItemListener implements Listener { + private final AbilityItemService items; + + public BoundItemListener(AbilityItemService items) { + this.items = items; + } + + @EventHandler + public void onDrop(PlayerDropItemEvent event) { + if (items.isBoundAbilityItem(event.getItemDrop().getItemStack())) { + event.getItemDrop().remove(); + } + } + + @EventHandler + public void onPickup(EntityPickupItemEvent event) { + if (!(event.getEntity() instanceof Player player)) { + return; + } + ItemStack item = event.getItem().getItemStack(); + if (items.isBoundAbilityItem(item) + && items.owner(item).filter(player.getUniqueId()::equals).isEmpty()) { + event.setCancelled(true); + event.getItem().remove(); + } + } + + @EventHandler + public void onInventoryClick(InventoryClickEvent event) { + if (items.isBoundAbilityItem(event.getCurrentItem()) + || items.isBoundAbilityItem(event.getCursor())) { + if (!(event.getWhoClicked() instanceof Player player) + || event.getClickedInventory() == null + || !event.getClickedInventory().equals(player.getInventory())) { + event.setCancelled(true); + } + } + } + + @EventHandler + public void onPlace(BlockPlaceEvent event) { + if (items.isBoundAbilityItem(event.getItemInHand())) { + event.setCancelled(true); + } + } + + @EventHandler + public void onDispense(BlockDispenseEvent event) { + if (items.isBoundAbilityItem(event.getItem())) { + event.setCancelled(true); + } + } + + @EventHandler + public void onFrame(PlayerInteractEntityEvent event) { + if (event.getRightClicked() instanceof ItemFrame + && items.isBoundAbilityItem(event.getPlayer().getInventory() + .getItem(event.getHand()))) { + event.setCancelled(true); + } + } + + @EventHandler + public void onDeath(PlayerDeathEvent event) { + event.getDrops().removeIf(items::isBoundAbilityItem); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/BukkitAbilityItemService.java b/src/main/java/games/dmg/spigottyrant/BukkitAbilityItemService.java new file mode 100644 index 0000000..02f5f0d --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/BukkitAbilityItemService.java @@ -0,0 +1,152 @@ +package games.dmg.spigottyrant; + +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.plugin.Plugin; + +public final class BukkitAbilityItemService implements AbilityItemService { + private final NamespacedKey abilityKey; + private final NamespacedKey ownerKey; + private final PluginSettings settings; + + public BukkitAbilityItemService(Plugin plugin, PluginSettings settings) { + abilityKey = new NamespacedKey(plugin, "ability"); + ownerKey = new NamespacedKey(plugin, "owner"); + this.settings = settings; + } + + @Override + public void recover(Player player, PlayerState state) { + for (Ability ability : state.readyAbilityItems()) { + if (!isItemAbilityForClass(ability, state.tyrantClass()) + || contains(player, state.playerId(), ability)) { + continue; + } + ItemStack item = create(state.playerId(), ability); + Map leftovers = player.getInventory().addItem(item); + if (!leftovers.isEmpty()) { + player.sendMessage(ChatColor.RED + settings.inventoryFullMessage()); + } + } + } + + @Override + public boolean isBoundAbilityItem(ItemStack item) { + return ability(item).isPresent() && owner(item).isPresent(); + } + + @Override + public Optional ability(ItemStack item) { + if (item == null || !item.hasItemMeta()) { + return Optional.empty(); + } + String value = item.getItemMeta().getPersistentDataContainer() + .get(abilityKey, PersistentDataType.STRING); + if (value == null) { + return Optional.empty(); + } + try { + return Optional.of(Ability.valueOf(value)); + } catch (IllegalArgumentException exception) { + return Optional.empty(); + } + } + + @Override + public Optional owner(ItemStack item) { + if (item == null || !item.hasItemMeta()) { + return Optional.empty(); + } + String value = item.getItemMeta().getPersistentDataContainer() + .get(ownerKey, PersistentDataType.STRING); + if (value == null) { + return Optional.empty(); + } + try { + return Optional.of(UUID.fromString(value)); + } catch (IllegalArgumentException exception) { + return Optional.empty(); + } + } + + @Override + public void removeInvalid(Player player, PlayerState state) { + ItemStack[] contents = player.getInventory().getContents(); + for (int index = 0; index < contents.length; index++) { + ItemStack item = contents[index]; + if (isBoundAbilityItem(item) + && (owner(item).filter(state.playerId()::equals).isEmpty() + || ability(item).filter(state.readyAbilityItems()::contains).isEmpty())) { + player.getInventory().setItem(index, null); + } + } + } + + @Override + public void removeAll(Player player) { + ItemStack[] contents = player.getInventory().getContents(); + for (int index = 0; index < contents.length; index++) { + if (isBoundAbilityItem(contents[index])) { + player.getInventory().setItem(index, null); + } + } + } + + private ItemStack create(UUID ownerId, Ability ability) { + PluginSettings.AbilityItemSettings configured = settingsFor(ability); + Material material = Material.matchMaterial(configured.material()); + if (material == null) { + throw new IllegalStateException("Configured item material is unavailable"); + } + ItemStack item = new ItemStack(material); + ItemMeta meta = item.getItemMeta(); + meta.setDisplayName(ChatColor.GOLD + configured.name()); + meta.getPersistentDataContainer().set( + abilityKey, PersistentDataType.STRING, ability.name() + ); + meta.getPersistentDataContainer().set( + ownerKey, PersistentDataType.STRING, ownerId.toString() + ); + item.setItemMeta(meta); + return item; + } + + private boolean contains(Player player, UUID ownerId, Ability ability) { + for (ItemStack item : player.getInventory().getContents()) { + if (owner(item).filter(ownerId::equals).isPresent() + && ability(item).filter(ability::equals).isPresent()) { + return true; + } + } + return false; + } + + private PluginSettings.AbilityItemSettings settingsFor(Ability ability) { + return switch (ability) { + case ASSASSIN_INVISIBILITY -> settings.assassinItem(); + case FIXER_BOOST -> settings.fixerItem(); + case TAMER_CAPTURE -> settings.tamerItem(); + default -> throw new IllegalArgumentException( + ability.name().toLowerCase(Locale.ROOT) + " has no class item" + ); + }; + } + + private static boolean isItemAbilityForClass(Ability ability, TyrantClass tyrantClass) { + return switch (tyrantClass) { + case ASSASSIN -> ability == Ability.ASSASSIN_INVISIBILITY; + case FIXER -> ability == Ability.FIXER_BOOST; + case TAMER -> ability == Ability.TAMER_CAPTURE; + case NONE -> false; + }; + } +} diff --git a/src/main/java/games/dmg/spigottyrant/ClassAssignmentService.java b/src/main/java/games/dmg/spigottyrant/ClassAssignmentService.java index 5fff88f..606c237 100644 --- a/src/main/java/games/dmg/spigottyrant/ClassAssignmentService.java +++ b/src/main/java/games/dmg/spigottyrant/ClassAssignmentService.java @@ -102,6 +102,7 @@ public final class ClassAssignmentService { cooldowns.remove(ability); readyItems.remove(ability); } + readyItemFor(tyrantClass).ifPresent(readyItems::add); return new PlayerState( player.playerId(), player.latestName(), @@ -115,6 +116,15 @@ public final class ClassAssignmentService { ); } + private static Optional readyItemFor(TyrantClass tyrantClass) { + return switch (tyrantClass) { + case ASSASSIN -> Optional.of(Ability.ASSASSIN_INVISIBILITY); + case FIXER -> Optional.of(Ability.FIXER_BOOST); + case TAMER -> Optional.of(Ability.TAMER_CAPTURE); + case NONE -> Optional.empty(); + }; + } + private static Set abilitiesFor(TyrantClass tyrantClass) { return switch (tyrantClass) { case ASSASSIN -> Set.of( diff --git a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java index 847ced8..88902bb 100644 --- a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java +++ b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java @@ -12,6 +12,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin { private TyrantStateManager stateManager; private TyrantPresentation tyrantPresentation; private VigilanteEffectController vigilanteEffects; + private AssassinEffectController assassinEffects; @Override public void onEnable() { @@ -48,6 +49,14 @@ public final class SpigotTyrantPlugin extends JavaPlugin { ); BukkitOnlinePlayerDirectory onlinePlayers = new BukkitOnlinePlayerDirectory(); FollowerService followers = new FollowerService(); + AbilityReadinessService readiness = new AbilityReadinessService(); + AbilityItemService abilityItems = new BukkitAbilityItemService(this, settings); + AssassinAbilityService assassinAbilities = new AssassinAbilityService( + readiness, settings + ); + assassinEffects = new AssassinEffectController( + stateManager, getServer(), settings, clock + ); VigilanteCombatTracker combatTracker = new VigilanteCombatTracker(); vigilanteEffects = new VigilanteEffectController( stateManager, getServer(), combatTracker, settings, clock @@ -66,7 +75,8 @@ public final class SpigotTyrantPlugin extends JavaPlugin { clock, new ParticipationService(settings.optOutDuration()), new RoleRelinquishmentService(succession, maintenance), - tyrantPresentation + tyrantPresentation, + abilityItems )); Objects.requireNonNull(getCommand("vigilante"), "Missing vigilante metadata") .setExecutor(new VigilanteCommand(stateManager, followers, onlinePlayers)); @@ -105,6 +115,20 @@ public final class SpigotTyrantPlugin extends JavaPlugin { ), this ); + getServer().getPluginManager().registerEvents( + new BoundItemListener(abilityItems), + this + ); + getServer().getPluginManager().registerEvents( + new AssassinItemListener( + stateManager, abilityItems, assassinAbilities, settings, clock + ), + this + ); + getServer().getPluginManager().registerEvents( + new AssassinJumpListener(stateManager, assassinAbilities, clock), + this + ); long maintenanceTicks = Math.max( 1L, Math.multiplyExact(settings.selectionRetryInterval().toSeconds(), 20L) ); @@ -117,12 +141,24 @@ public final class SpigotTyrantPlugin extends JavaPlugin { maintenanceTicks ); getServer().getScheduler().runTaskTimer(this, vigilanteEffects, 10L, 10L); + getServer().getScheduler().runTaskTimer(this, assassinEffects, 10L, 10L); + getServer().getScheduler().runTaskTimer( + this, + new AbilityItemRefreshTask( + stateManager, readiness, abilityItems, getServer(), clock + ), + 20L, + 20L + ); getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L); getLogger().info("Spigot Tyrant enabled."); } @Override public void onDisable() { + if (assassinEffects != null) { + assassinEffects.clearAll(); + } if (vigilanteEffects != null) { vigilanteEffects.clear(); } diff --git a/src/main/java/games/dmg/spigottyrant/TyrantCommand.java b/src/main/java/games/dmg/spigottyrant/TyrantCommand.java index 6eee591..a596731 100644 --- a/src/main/java/games/dmg/spigottyrant/TyrantCommand.java +++ b/src/main/java/games/dmg/spigottyrant/TyrantCommand.java @@ -18,6 +18,7 @@ public final class TyrantCommand implements CommandExecutor { private final ParticipationService participation; private final RoleRelinquishmentService relinquishment; private final TyrantPresentation presentation; + private final AbilityItemService abilityItems; public TyrantCommand( TyrantStateManager stateManager, @@ -41,7 +42,7 @@ public final class TyrantCommand implements CommandExecutor { ) { this( stateManager, progression, assignments, onlinePlayers, clock, - new ParticipationService(Duration.ofDays(7)), null, null + new ParticipationService(Duration.ofDays(7)), null, null, null ); } @@ -53,7 +54,8 @@ public final class TyrantCommand implements CommandExecutor { Clock clock, ParticipationService participation, RoleRelinquishmentService relinquishment, - TyrantPresentation presentation + TyrantPresentation presentation, + AbilityItemService abilityItems ) { this.stateManager = stateManager; this.progression = progression; @@ -63,6 +65,7 @@ public final class TyrantCommand implements CommandExecutor { this.participation = participation; this.relinquishment = relinquishment; this.presentation = presentation; + this.abilityItems = abilityItems; } @Override @@ -104,9 +107,13 @@ public final class TyrantCommand implements CommandExecutor { relinquish(player, arguments); return true; } + if (arguments.length == 1 && arguments[0].equalsIgnoreCase("item")) { + recoverItems(player); + return true; + } player.sendMessage( ChatColor.YELLOW - + "Usage: /tyrant " ); return true; @@ -185,10 +192,17 @@ public final class TyrantCommand implements CommandExecutor { } stateManager.replacePlayers(result.players()); stateManager.saveIfDirty(); - result.previousHolderId().map(onlinePlayers::findById).ifPresent(previous -> + result.previousHolderId().map(onlinePlayers::findById).ifPresent(previous -> { + if (abilityItems != null) { + abilityItems.removeAll(previous); + } previous.sendMessage(ChatColor.YELLOW + "You are no longer the " - + tyrantClass.name() + ".") - ); + + tyrantClass.name() + "."); + }); + if (abilityItems != null) { + abilityItems.removeInvalid(target, result.players().get(target.getUniqueId())); + abilityItems.recover(target, result.players().get(target.getUniqueId())); + } target.sendMessage(ChatColor.GREEN + "You are now the " + tyrantClass.name() + "."); tyrant.sendMessage( ChatColor.GREEN + "Assigned " + tyrantClass.name() + " to " + target.getName() + "." @@ -227,6 +241,17 @@ public final class TyrantCommand implements CommandExecutor { + readable(result[0].status()) + "."); } + private void recoverItems(Player player) { + if (abilityItems == null) { + player.sendMessage(ChatColor.RED + "Ability item recovery is unavailable."); + return; + } + PlayerState state = stateManager.player(player.getUniqueId(), player.getName()); + abilityItems.recover(player, state); + player.sendMessage(ChatColor.YELLOW + + "Recovered every currently ready missing class item that could fit."); + } + private void relinquish(Player player, String[] arguments) { if (arguments.length != 2 || !arguments[1].equalsIgnoreCase("confirm")) { player.sendMessage(ChatColor.RED diff --git a/src/test/java/games/dmg/spigottyrant/AbilityReadinessServiceTest.java b/src/test/java/games/dmg/spigottyrant/AbilityReadinessServiceTest.java new file mode 100644 index 0000000..6e1e74c --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/AbilityReadinessServiceTest.java @@ -0,0 +1,60 @@ +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 AbilityReadinessServiceTest { + private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z"); + private final AbilityReadinessService service = new AbilityReadinessService(); + + @Test + void successfulCooldownActivationConsumesItemAndRefreshRestoresIt() { + PlayerState ready = player( + TyrantClass.ASSASSIN, Map.of(), Set.of(Ability.ASSASSIN_INVISIBILITY) + ); + + PlayerState used = service.consume( + ready, Ability.ASSASSIN_INVISIBILITY, NOW, Duration.ofHours(1) + ); + PlayerState refreshed = service.refresh(used, NOW.plus(Duration.ofHours(1))); + + assertEquals(Set.of(), used.readyAbilityItems()); + assertEquals(NOW.plus(Duration.ofHours(1)), + used.cooldownEnds().get(Ability.ASSASSIN_INVISIBILITY)); + assertEquals(Set.of(Ability.ASSASSIN_INVISIBILITY), + refreshed.readyAbilityItems()); + assertEquals(Map.of(), refreshed.cooldownEnds()); + } + + @Test + void tamerItemRemainsReadyWithoutCooldown() { + PlayerState tamer = player( + TyrantClass.TAMER, Map.of(), Set.of(Ability.TAMER_CAPTURE) + ); + + PlayerState after = service.consume( + tamer, Ability.TAMER_CAPTURE, NOW, Duration.ZERO + ); + + assertEquals(tamer, after); + } + + private static PlayerState player( + TyrantClass tyrantClass, + Map cooldowns, + Set ready + ) { + return new PlayerState( + UUID.fromString("11111111-1111-1111-1111-111111111111"), "Player", + Optional.empty(), Optional.empty(), tyrantClass, Optional.empty(), + cooldowns, ready, java.util.List.of() + ); + } +} diff --git a/src/test/java/games/dmg/spigottyrant/AssassinAbilityServiceTest.java b/src/test/java/games/dmg/spigottyrant/AssassinAbilityServiceTest.java new file mode 100644 index 0000000..ba5daf5 --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/AssassinAbilityServiceTest.java @@ -0,0 +1,60 @@ +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 AssassinAbilityServiceTest { + private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z"); + private final PluginSettings settings = PluginSettings.from(Map.of()); + private final AssassinAbilityService service = new AssassinAbilityService( + new AbilityReadinessService(), settings + ); + + @Test + void invisibilityConsumesItemAndDoublesDurationNearTyrant() { + PlayerState ready = assassin(Set.of(Ability.ASSASSIN_INVISIBILITY), Map.of()); + + AbilityUseResult normal = service.activateInvisibility(ready, NOW, false); + AbilityUseResult near = service.activateInvisibility(ready, NOW, true); + + assertEquals(AbilityUseStatus.ACTIVATED, normal.status()); + assertEquals(NOW.plusSeconds(600), normal.player().cooldownEnds() + .get(Ability.ASSASSIN_INVISIBILITY_ACTIVE)); + assertEquals(NOW.plusSeconds(1200), near.player().cooldownEnds() + .get(Ability.ASSASSIN_INVISIBILITY_ACTIVE)); + assertEquals(NOW.plusSeconds(3600), normal.player().cooldownEnds() + .get(Ability.ASSASSIN_INVISIBILITY)); + assertEquals(Set.of(), normal.player().readyAbilityItems()); + } + + @Test + void doubleJumpAddsSpeedWeaknessAndSixtySecondCooldown() { + PlayerState assassin = assassin(Set.of(), Map.of()); + + AbilityUseResult result = service.doubleJump(assassin, NOW); + AbilityUseResult blocked = service.doubleJump(result.player(), NOW.plusSeconds(30)); + + assertEquals(AbilityUseStatus.ACTIVATED, result.status()); + assertEquals(NOW.plusSeconds(60), result.player().cooldownEnds() + .get(Ability.ASSASSIN_DOUBLE_JUMP)); + assertEquals(NOW.plusSeconds(15), result.player().cooldownEnds() + .get(Ability.ASSASSIN_SPEED_ACTIVE)); + assertEquals(NOW.plusSeconds(20), result.player().cooldownEnds() + .get(Ability.ASSASSIN_WEAKNESS_ACTIVE)); + assertEquals(AbilityUseStatus.COOLDOWN, blocked.status()); + } + + private static PlayerState assassin(Set ready, Map cooldowns) { + return new PlayerState( + UUID.fromString("11111111-1111-1111-1111-111111111111"), "Assassin", + Optional.empty(), Optional.empty(), TyrantClass.ASSASSIN, Optional.empty(), + cooldowns, ready, java.util.List.of() + ); + } +} diff --git a/src/test/java/games/dmg/spigottyrant/ClassAssignmentServiceTest.java b/src/test/java/games/dmg/spigottyrant/ClassAssignmentServiceTest.java index 0a69e40..5b95394 100644 --- a/src/test/java/games/dmg/spigottyrant/ClassAssignmentServiceTest.java +++ b/src/test/java/games/dmg/spigottyrant/ClassAssignmentServiceTest.java @@ -46,6 +46,8 @@ final class ClassAssignmentServiceTest { assertEquals(ClassAssignmentStatus.ASSIGNED, vigilanteResult.status()); assertEquals(TyrantClass.ASSASSIN, vigilanteResult.players().get(VIGILANTE).tyrantClass()); + assertEquals(Set.of(Ability.ASSASSIN_INVISIBILITY), + vigilanteResult.players().get(VIGILANTE).readyAbilityItems()); assertEquals(ClassAssignmentStatus.ASSIGNED, followerResult.status()); assertEquals(TyrantClass.NONE, followerResult.players().get(VIGILANTE).tyrantClass());