diff --git a/design/log.md b/design/log.md index 3d7afc7..dad6cee 100644 --- a/design/log.md +++ b/design/log.md @@ -6,6 +6,12 @@ description: Chronological record of material decisions affecting the Spigot Tyr # Spigot Tyrant Design Log +## 2026-08-18 — Preserve ordinary potion effects + +- Corrected Assassin effect maintenance so it refreshes active Tyrant abilities without removing invisibility, Speed, or Weakness supplied by potions, commands, or other plugins. +- Expired or inapplicable Tyrant-managed effects stop refreshing and expire naturally within their short 30-tick maintenance duration, while double-jump flight cleanup remains immediate. +- Reopened and completed US-005 with regression coverage; verified `./gradlew clean check jar`. + ## 2026-08-14 — Opt-out proximity glow completed - Extended US-011 with a globally visible green outline for opted-out players in the Tyrant's world and configured range while the event is running. diff --git a/design/user-stories/us-005-use-assassin-abilities.md b/design/user-stories/us-005-use-assassin-abilities.md index 4212fd6..8d8c128 100644 --- a/design/user-stories/us-005-use-assassin-abilities.md +++ b/design/user-stories/us-005-use-assassin-abilities.md @@ -20,6 +20,13 @@ As the **Assassin**, I want stealth and burst mobility so that I can ambush oppo - [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. +- [x] Tyrant removes only Assassin potion effects that Tyrant itself applied. +- [x] Invisibility from potions, commands, or other plugins remains active for Assassins and non-Assassins. +- [x] Tyrant-managed Assassin effects are still removed when abilities expire, roles change, games stop, or the plugin disables. + +## Validation + +Automated controller tests verify that inactive Tyrant state does not alter ordinary potion effects, active Assassin invisibility is refreshed, and refresh stops at expiry so the short managed effect expires naturally. The complete `./gradlew clean check jar` lifecycle passes. ## Related diff --git a/src/main/java/games/dmg/spigottyrant/AssassinEffectController.java b/src/main/java/games/dmg/spigottyrant/AssassinEffectController.java index 588e95a..e6a0de0 100644 --- a/src/main/java/games/dmg/spigottyrant/AssassinEffectController.java +++ b/src/main/java/games/dmg/spigottyrant/AssassinEffectController.java @@ -3,6 +3,7 @@ package games.dmg.spigottyrant; import java.time.Clock; import java.time.Instant; import java.util.HashSet; +import java.util.Objects; import java.util.Set; import java.util.UUID; import org.bukkit.GameMode; @@ -17,6 +18,7 @@ public final class AssassinEffectController implements Runnable { private final Server server; private final PluginSettings settings; private final Clock clock; + private final EffectApplier effectApplier; private final Set grantedFlight = new HashSet<>(); public AssassinEffectController( @@ -25,10 +27,21 @@ public final class AssassinEffectController implements Runnable { PluginSettings settings, Clock clock ) { - this.stateManager = stateManager; - this.server = server; - this.settings = settings; - this.clock = clock; + this(stateManager, server, settings, clock, new BukkitEffectApplier()); + } + + AssassinEffectController( + TyrantStateManager stateManager, + Server server, + PluginSettings settings, + Clock clock, + EffectApplier effectApplier + ) { + this.stateManager = Objects.requireNonNull(stateManager, "stateManager"); + this.server = Objects.requireNonNull(server, "server"); + this.settings = Objects.requireNonNull(settings, "settings"); + this.clock = Objects.requireNonNull(clock, "clock"); + this.effectApplier = Objects.requireNonNull(effectApplier, "effectApplier"); } @Override @@ -43,15 +56,15 @@ public final class AssassinEffectController implements Runnable { } applyWhileActive( player, state, Ability.ASSASSIN_INVISIBILITY_ACTIVE, - PotionEffectType.INVISIBILITY, 0, false + EffectKind.INVISIBILITY, 0, false ); applyWhileActive( player, state, Ability.ASSASSIN_SPEED_ACTIVE, - PotionEffectType.SPEED, 0, true + EffectKind.SPEED, 0, true ); applyWhileActive( player, state, Ability.ASSASSIN_WEAKNESS_ACTIVE, - PotionEffectType.WEAKNESS, settings.assassinWeaknessLevel() - 1, true + EffectKind.WEAKNESS, settings.assassinWeaknessLevel() - 1, true ); boolean jumpReady = !state.cooldownEnds().getOrDefault( Ability.ASSASSIN_DOUBLE_JUMP, Instant.MIN @@ -71,29 +84,52 @@ public final class AssassinEffectController implements Runnable { Player player, PlayerState state, Ability ability, - PotionEffectType type, + EffectKind effect, 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); + effectApplier.apply(player, effect, amplifier, particles); } } 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); } } + enum EffectKind { + INVISIBILITY, + SPEED, + WEAKNESS + } + + @FunctionalInterface + interface EffectApplier { + void apply(Player player, EffectKind effect, int amplifier, boolean particles); + } + + private static final class BukkitEffectApplier implements EffectApplier { + @Override + public void apply( + Player player, + EffectKind effect, + int amplifier, + boolean particles + ) { + PotionEffectType type = switch (effect) { + case INVISIBILITY -> PotionEffectType.INVISIBILITY; + case SPEED -> PotionEffectType.SPEED; + case WEAKNESS -> PotionEffectType.WEAKNESS; + }; + player.addPotionEffect(new PotionEffect( + type, EFFECT_TICKS, amplifier, false, particles, true + )); + } + } + private static boolean isGrounded(Player player) { return !player.getLocation().clone().subtract(0.0, 0.1, 0.0) .getBlock().isPassable(); diff --git a/src/test/java/games/dmg/spigottyrant/AssassinEffectControllerTest.java b/src/test/java/games/dmg/spigottyrant/AssassinEffectControllerTest.java new file mode 100644 index 0000000..0eb7d5a --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/AssassinEffectControllerTest.java @@ -0,0 +1,145 @@ +package games.dmg.spigottyrant; + +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.nio.file.Path; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.logging.Logger; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class AssassinEffectControllerTest { + private static final Instant NOW = Instant.parse("2026-08-18T12:00:00Z"); + private static final UUID PLAYER_ID = UUID.fromString( + "11111111-1111-1111-1111-111111111111" + ); + + @TempDir + Path temporaryDirectory; + + @Test + void doesNotRemovePotionEffectsItDidNotApply() throws Exception { + TyrantStateManager stateManager = manager(); + Player player = player(); + Server server = server(player); + AssassinEffectController.EffectApplier effects = mock( + AssassinEffectController.EffectApplier.class + ); + AssassinEffectController controller = controller(stateManager, server, effects); + + controller.run(); + controller.clearAll(); + + verifyNoInteractions(effects); + } + + @Test + void stopsRefreshingManagedInvisibilityAfterItsDeadline() throws Exception { + TyrantStateManager stateManager = manager(); + stateManager.updateGame(ignored -> runningGame()); + stateManager.updatePlayer(PLAYER_ID, "Assassin", ignored -> assassin(Map.of( + Ability.ASSASSIN_INVISIBILITY_ACTIVE, NOW.plusSeconds(60), + Ability.ASSASSIN_DOUBLE_JUMP, NOW.plusSeconds(60) + ))); + Player player = player(); + Server server = server(player); + AssassinEffectController.EffectApplier effects = mock( + AssassinEffectController.EffectApplier.class + ); + AssassinEffectController controller = controller(stateManager, server, effects); + + controller.run(); + + verify(effects).apply( + player, + AssassinEffectController.EffectKind.INVISIBILITY, + 0, + false + ); + + stateManager.updatePlayer(PLAYER_ID, "Assassin", ignored -> assassin(Map.of( + Ability.ASSASSIN_DOUBLE_JUMP, NOW.plusSeconds(60) + ))); + clearInvocations(player, effects); + controller.run(); + + verifyNoInteractions(effects); + } + + private TyrantStateManager manager() throws Exception { + return new TyrantStateManager( + new YamlTyrantStateRepository(temporaryDirectory.resolve("state.yml")), + Logger.getLogger("test") + ); + } + + private static AssassinEffectController controller( + TyrantStateManager stateManager, + Server server, + AssassinEffectController.EffectApplier effects + ) { + return new AssassinEffectController( + stateManager, + server, + PluginSettings.from(Map.of()), + Clock.fixed(NOW, ZoneOffset.UTC), + effects + ); + } + + private static Server server(Player player) { + Server server = mock(Server.class); + doReturn(List.of(player)).when(server).getOnlinePlayers(); + return server; + } + + private static Player player() { + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(PLAYER_ID); + when(player.getName()).thenReturn("Assassin"); + return player; + } + + private static PlayerState assassin(Map cooldowns) { + return new PlayerState( + PLAYER_ID, + "Assassin", + Optional.empty(), + Optional.empty(), + TyrantClass.ASSASSIN, + Optional.empty(), + cooldowns, + Set.of(), + List.of() + ); + } + + private static GameState runningGame() { + return new GameState( + GameLifecycle.RUNNING, + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + java.time.Duration.ZERO, + 0, + 0, + Set.of() + ); + } +}