fix(assassin): preserve external potion effects
Release / release (push) Successful in 2m46s
CI / build (push) Successful in 1m8s

This commit is contained in:
dmg
2026-08-18 23:50:15 -04:00
parent 323d177573
commit 3d65576010
4 changed files with 210 additions and 16 deletions
@@ -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<UUID> 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();
@@ -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()
);
}
}