fix(assassin): preserve external potion effects
This commit is contained in:
@@ -6,6 +6,12 @@ description: Chronological record of material decisions affecting the Spigot Tyr
|
|||||||
|
|
||||||
# Spigot Tyrant Design Log
|
# 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
|
## 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.
|
- 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.
|
||||||
|
|||||||
@@ -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] 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] 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] 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
|
## Related
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package games.dmg.spigottyrant;
|
|||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.bukkit.GameMode;
|
import org.bukkit.GameMode;
|
||||||
@@ -17,6 +18,7 @@ public final class AssassinEffectController implements Runnable {
|
|||||||
private final Server server;
|
private final Server server;
|
||||||
private final PluginSettings settings;
|
private final PluginSettings settings;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
|
private final EffectApplier effectApplier;
|
||||||
private final Set<UUID> grantedFlight = new HashSet<>();
|
private final Set<UUID> grantedFlight = new HashSet<>();
|
||||||
|
|
||||||
public AssassinEffectController(
|
public AssassinEffectController(
|
||||||
@@ -25,10 +27,21 @@ public final class AssassinEffectController implements Runnable {
|
|||||||
PluginSettings settings,
|
PluginSettings settings,
|
||||||
Clock clock
|
Clock clock
|
||||||
) {
|
) {
|
||||||
this.stateManager = stateManager;
|
this(stateManager, server, settings, clock, new BukkitEffectApplier());
|
||||||
this.server = server;
|
}
|
||||||
this.settings = settings;
|
|
||||||
this.clock = clock;
|
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
|
@Override
|
||||||
@@ -43,15 +56,15 @@ public final class AssassinEffectController implements Runnable {
|
|||||||
}
|
}
|
||||||
applyWhileActive(
|
applyWhileActive(
|
||||||
player, state, Ability.ASSASSIN_INVISIBILITY_ACTIVE,
|
player, state, Ability.ASSASSIN_INVISIBILITY_ACTIVE,
|
||||||
PotionEffectType.INVISIBILITY, 0, false
|
EffectKind.INVISIBILITY, 0, false
|
||||||
);
|
);
|
||||||
applyWhileActive(
|
applyWhileActive(
|
||||||
player, state, Ability.ASSASSIN_SPEED_ACTIVE,
|
player, state, Ability.ASSASSIN_SPEED_ACTIVE,
|
||||||
PotionEffectType.SPEED, 0, true
|
EffectKind.SPEED, 0, true
|
||||||
);
|
);
|
||||||
applyWhileActive(
|
applyWhileActive(
|
||||||
player, state, Ability.ASSASSIN_WEAKNESS_ACTIVE,
|
player, state, Ability.ASSASSIN_WEAKNESS_ACTIVE,
|
||||||
PotionEffectType.WEAKNESS, settings.assassinWeaknessLevel() - 1, true
|
EffectKind.WEAKNESS, settings.assassinWeaknessLevel() - 1, true
|
||||||
);
|
);
|
||||||
boolean jumpReady = !state.cooldownEnds().getOrDefault(
|
boolean jumpReady = !state.cooldownEnds().getOrDefault(
|
||||||
Ability.ASSASSIN_DOUBLE_JUMP, Instant.MIN
|
Ability.ASSASSIN_DOUBLE_JUMP, Instant.MIN
|
||||||
@@ -71,29 +84,52 @@ public final class AssassinEffectController implements Runnable {
|
|||||||
Player player,
|
Player player,
|
||||||
PlayerState state,
|
PlayerState state,
|
||||||
Ability ability,
|
Ability ability,
|
||||||
PotionEffectType type,
|
EffectKind effect,
|
||||||
int amplifier,
|
int amplifier,
|
||||||
boolean particles
|
boolean particles
|
||||||
) {
|
) {
|
||||||
if (state.cooldownEnds().getOrDefault(ability, Instant.MIN).isAfter(clock.instant())) {
|
if (state.cooldownEnds().getOrDefault(ability, Instant.MIN).isAfter(clock.instant())) {
|
||||||
player.addPotionEffect(new PotionEffect(
|
effectApplier.apply(player, effect, amplifier, particles);
|
||||||
type, EFFECT_TICKS, amplifier, false, particles, true
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
player.removePotionEffect(type);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void clear(Player player) {
|
private void clear(Player player) {
|
||||||
player.removePotionEffect(PotionEffectType.INVISIBILITY);
|
|
||||||
player.removePotionEffect(PotionEffectType.SPEED);
|
|
||||||
player.removePotionEffect(PotionEffectType.WEAKNESS);
|
|
||||||
if (grantedFlight.remove(player.getUniqueId()) && isSurvivalLike(player)) {
|
if (grantedFlight.remove(player.getUniqueId()) && isSurvivalLike(player)) {
|
||||||
player.setAllowFlight(false);
|
player.setAllowFlight(false);
|
||||||
player.setFlying(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) {
|
private static boolean isGrounded(Player player) {
|
||||||
return !player.getLocation().clone().subtract(0.0, 0.1, 0.0)
|
return !player.getLocation().clone().subtract(0.0, 0.1, 0.0)
|
||||||
.getBlock().isPassable();
|
.getBlock().isPassable();
|
||||||
|
|||||||
@@ -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<Ability, Instant> 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user