From 2020dcba2c652ce9b66fa6123f9ba2e10ba21141 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:52:00 +0000 Subject: [PATCH 1/3] fix: tie death on login to the character who logged off A committed player's character is recorded when they log off. Once the grace period has run out, the death waits until that character is active again, through RPCharacters' activation event, and is dropped if that character has died in the meantime. RPCharacters is a soft dependency. Co-Authored-By: Claude Opus 5.5 --- pom.xml | 13 +++++ .../infestations/Infestations.java | 5 ++ .../database/InfestationData.java | 1 + .../database/InfestationDatabase.java | 12 +++++ .../CharacterActivatedListener.java | 23 +++++++++ .../infestations/infestation/Infestation.java | 8 +++ .../infestation/InfestationManager.java | 49 +++++++++++++++++-- .../infestations/utils/Characters.java | 46 +++++++++++++++++ src/main/resources/plugin.yml | 1 + .../InfestationsLifecycleTest.java | 11 +++++ .../InfestationsServicesTest.java | 38 ++++++++++++++ .../database/InfestationDataTest.java | 16 ++++-- .../infestation/InfestationManagerTest.java | 30 ++++++++++++ 13 files changed, 246 insertions(+), 7 deletions(-) create mode 100644 src/main/java/net/tfminecraft/infestations/infestation/CharacterActivatedListener.java create mode 100644 src/main/java/net/tfminecraft/infestations/utils/Characters.java diff --git a/pom.xml b/pom.xml index acd6e77..d334ae7 100644 --- a/pom.xml +++ b/pom.xml @@ -17,6 +17,7 @@ full 0.2.1 + 2.4.0 3.0.2 2.0.1 @@ -52,6 +53,18 @@ + + net.tfminecraft + rpcharacters + ${rpcharacters.version} + provided + + + * + * + + + net.tfminecraft simplefactions diff --git a/src/main/java/net/tfminecraft/infestations/Infestations.java b/src/main/java/net/tfminecraft/infestations/Infestations.java index 2e1c7ad..0b56473 100644 --- a/src/main/java/net/tfminecraft/infestations/Infestations.java +++ b/src/main/java/net/tfminecraft/infestations/Infestations.java @@ -10,9 +10,11 @@ import net.tfminecraft.infestations.cache.Cache; import net.tfminecraft.infestations.command.CommandManager; +import net.tfminecraft.infestations.infestation.CharacterActivatedListener; import net.tfminecraft.infestations.infestation.InfestationManager; import net.tfminecraft.infestations.loader.ConfigLoader; import net.tfminecraft.infestations.loader.GroupLoader; +import net.tfminecraft.infestations.utils.Characters; /** * Province infestations. See the Docs project guide for behaviour and configuration. @@ -37,6 +39,9 @@ public void onEnable() { infestationManager = new InfestationManager(this); getServer().getPluginManager().registerEvents(infestationManager, this); + if (Characters.enabled()) { + getServer().getPluginManager().registerEvents(new CharacterActivatedListener(infestationManager), this); + } infestationManager.start(); registerCommand("infestation"); diff --git a/src/main/java/net/tfminecraft/infestations/database/InfestationData.java b/src/main/java/net/tfminecraft/infestations/database/InfestationData.java index b178448..fcf3bbd 100644 --- a/src/main/java/net/tfminecraft/infestations/database/InfestationData.java +++ b/src/main/java/net/tfminecraft/infestations/database/InfestationData.java @@ -25,4 +25,5 @@ public class InfestationData { public List committed = new ArrayList<>(); public Map logoutGraceUntil = new HashMap<>(); public List deathOnLogin = new ArrayList<>(); + public Map logoutCharacter = new HashMap<>(); } diff --git a/src/main/java/net/tfminecraft/infestations/database/InfestationDatabase.java b/src/main/java/net/tfminecraft/infestations/database/InfestationDatabase.java index c33f645..4bc1bca 100644 --- a/src/main/java/net/tfminecraft/infestations/database/InfestationDatabase.java +++ b/src/main/java/net/tfminecraft/infestations/database/InfestationDatabase.java @@ -128,6 +128,15 @@ private static Infestation fromData(InfestationData row) { } } } + if (row.logoutCharacter != null) { + for (Map.Entry e : row.logoutCharacter.entrySet()) { + if (e.getKey() == null || e.getValue() == null) continue; + try { + infestation.getLogoutCharacter().put(UUID.fromString(e.getKey()), e.getValue()); + } catch (IllegalArgumentException ignored) { + } + } + } return infestation; } @@ -157,6 +166,9 @@ private static InfestationData toData(Infestation infestation) { for (UUID id : infestation.getDeathOnLogin()) { row.deathOnLogin.add(id.toString()); } + for (Map.Entry e : infestation.getLogoutCharacter().entrySet()) { + row.logoutCharacter.put(e.getKey().toString(), e.getValue()); + } return row; } } diff --git a/src/main/java/net/tfminecraft/infestations/infestation/CharacterActivatedListener.java b/src/main/java/net/tfminecraft/infestations/infestation/CharacterActivatedListener.java new file mode 100644 index 0000000..cb68636 --- /dev/null +++ b/src/main/java/net/tfminecraft/infestations/infestation/CharacterActivatedListener.java @@ -0,0 +1,23 @@ +package net.tfminecraft.infestations.infestation; + +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; + +import net.tfminecraft.rpcharacters.lifecycle.CharacterActivatedEvent; + +/** Registered only with RPCharacters: a pending death applies once its character is active again. */ +public final class CharacterActivatedListener implements Listener { + + private final InfestationManager manager; + + public CharacterActivatedListener(InfestationManager manager) { + this.manager = manager; + } + + @EventHandler + public void onCharacterActivated(CharacterActivatedEvent event) { + if (event.getOwner() != null) { + manager.applyDeathOnLogin(event.getOwner()); + } + } +} diff --git a/src/main/java/net/tfminecraft/infestations/infestation/Infestation.java b/src/main/java/net/tfminecraft/infestations/infestation/Infestation.java index b836fec..1cc7333 100644 --- a/src/main/java/net/tfminecraft/infestations/infestation/Infestation.java +++ b/src/main/java/net/tfminecraft/infestations/infestation/Infestation.java @@ -39,6 +39,8 @@ public long getLureGeneration() { private final Set committed = new HashSet<>(); private final Map logoutGraceUntil = new HashMap<>(); private final Set deathOnLogin = new HashSet<>(); + /** The character each committed player logged off as, so a death on login reaches only that character. */ + private final Map logoutCharacter = new HashMap<>(); public Infestation(int provinceId, String groupId, Severity severity) { this.provinceId = provinceId; @@ -124,6 +126,7 @@ public void placeLure(Block block, long joinEndsAt, int budget) { this.committed.clear(); this.logoutGraceUntil.clear(); this.deathOnLogin.clear(); + this.logoutCharacter.clear(); this.waveRetryAtTick = 0; this.lureActivatedAt = 0; this.lureReleased = 0; @@ -144,6 +147,7 @@ public void clearLure() { committed.clear(); logoutGraceUntil.clear(); deathOnLogin.clear(); + logoutCharacter.clear(); waveRetryAtTick = 0; lureActivatedAt = 0; lureReleased = 0; @@ -292,6 +296,10 @@ public Set getDeathOnLogin() { return deathOnLogin; } + public Map getLogoutCharacter() { + return logoutCharacter; + } + public boolean isCommitted(UUID id) { return committed.contains(id); } diff --git a/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java b/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java index 3bfa5cb..fba2071 100644 --- a/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java +++ b/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java @@ -73,6 +73,7 @@ import net.tfminecraft.infestations.spawn.MythicSpawner; import net.tfminecraft.infestations.spawn.SpawnLog; import net.tfminecraft.infestations.spawn.SpawnPlanner; +import net.tfminecraft.infestations.utils.Characters; import net.tfminecraft.infestations.utils.Provinces; public final class InfestationManager implements Listener, AmbientSpawnService.CollectionInfestations { @@ -1106,11 +1107,17 @@ private void onPlayerDeath(Player player) { public void onQuit(PlayerQuitEvent event) { Player player = event.getPlayer(); long until = System.currentTimeMillis() + Cache.logoutGraceSeconds * 1000L; + String character = Characters.activeId(player); for (Infestation infestation : byProvince.values()) { if (!infestation.isCommitted(player.getUniqueId())) { continue; } infestation.getLogoutGraceUntil().put(player.getUniqueId(), until); + if (character == null) { + infestation.getLogoutCharacter().remove(player.getUniqueId()); + } else { + infestation.getLogoutCharacter().put(player.getUniqueId(), character); + } } save(); } @@ -1124,15 +1131,49 @@ public void onJoin(PlayerJoinEvent event) { } for (Infestation infestation : byProvince.values()) { infestation.getLogoutGraceUntil().remove(player.getUniqueId()); - if (infestation.getDeathOnLogin().remove(player.getUniqueId()) - && infestation.isCommitted(player.getUniqueId()) - && !player.isDead()) { - player.damage(Math.max(40.0, player.getHealth() + 10.0)); + if (!infestation.getDeathOnLogin().contains(player.getUniqueId())) { + infestation.getLogoutCharacter().remove(player.getUniqueId()); } } + applyDeathOnLogin(player); }, 5L); } + /** + * Kills a player whose lure grace ran out, once the character who logged off is active. + * The death waits while another character is active and is dropped if that character has died. + */ + public void applyDeathOnLogin(Player player) { + UUID id = player.getUniqueId(); + String active = Characters.activeId(player); + boolean changed = false; + for (Infestation infestation : List.copyOf(byProvince.values())) { + if (!infestation.getDeathOnLogin().contains(id)) { + continue; + } + String target = infestation.getLogoutCharacter().get(id); + boolean otherCharacter = target != null && !target.equals(active); + if (otherCharacter && !Characters.isDead(player, target)) { + continue; + } + infestation.getDeathOnLogin().remove(id); + infestation.getLogoutCharacter().remove(id); + changed = true; + if (otherCharacter) { + // That character already died, which settles what the lure owed. + infestation.getCommitted().remove(id); + if (infestation.getPhase() != LurePhase.NONE) { + checkWipe(infestation); + } + } else if (infestation.isCommitted(id) && !player.isDead()) { + player.damage(Math.max(40.0, player.getHealth() + 10.0)); + } + } + if (changed) { + save(); + } + } + @EventHandler public void onLeaveProvince(PlayerProvinceLeaveEvent event) { Infestation infestation = get(event.getProvinceId()); diff --git a/src/main/java/net/tfminecraft/infestations/utils/Characters.java b/src/main/java/net/tfminecraft/infestations/utils/Characters.java new file mode 100644 index 0000000..5678d04 --- /dev/null +++ b/src/main/java/net/tfminecraft/infestations/utils/Characters.java @@ -0,0 +1,46 @@ +package net.tfminecraft.infestations.utils; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +import net.tfminecraft.rpcharacters.enums.Status; +import net.tfminecraft.rpcharacters.managers.PlayerManager; +import net.tfminecraft.rpcharacters.objects.PlayerData; +import net.tfminecraft.rpcharacters.objects.RPCharacter; + +/** RPCharacters lookups; without RPCharacters every player has no character ID. */ +public final class Characters { + + private Characters() {} + + public static boolean enabled() { + return Bukkit.getPluginManager().isPluginEnabled("RPCharacters"); + } + + /** The player's active character ID, or null. */ + public static String activeId(Player player) { + if (!enabled()) { + return null; + } + PlayerData data = PlayerManager.get(player); + RPCharacter character = data == null ? null : data.getActiveCharacter(); + return character == null ? null : character.getId(); + } + + /** True when the player's character with this ID exists and is no longer alive. */ + public static boolean isDead(Player player, String characterId) { + if (!enabled()) { + return false; + } + PlayerData data = PlayerManager.get(player); + if (data == null) { + return false; + } + for (RPCharacter character : data.getCharacters()) { + if (characterId.equals(character.getId())) { + return character.getStatus() != Status.ALIVE; + } + } + return false; + } +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index b708e2e..03c4ded 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -4,6 +4,7 @@ name: Infestations version: '${project.version}' author: Drefvelin depend: [TLibs, SimpleFactions, MythicMobs, ItemsAdder, InteractibleFurniture] +softdepend: [RPCharacters] commands: infestation: diff --git a/src/test/java/net/tfminecraft/infestations/InfestationsLifecycleTest.java b/src/test/java/net/tfminecraft/infestations/InfestationsLifecycleTest.java index 7f18f10..6b54f9f 100644 --- a/src/test/java/net/tfminecraft/infestations/InfestationsLifecycleTest.java +++ b/src/test/java/net/tfminecraft/infestations/InfestationsLifecycleTest.java @@ -13,6 +13,7 @@ import java.util.List; import java.util.logging.Logger; +import org.bukkit.event.HandlerList; import org.bukkit.plugin.PluginDescriptionFile; import org.junit.jupiter.api.*; import org.junit.jupiter.api.io.TempDir; @@ -21,6 +22,7 @@ import org.mockbukkit.mockbukkit.ServerMock; import net.tfminecraft.infestations.cache.Cache; +import net.tfminecraft.infestations.infestation.CharacterActivatedListener; import net.tfminecraft.infestations.infestation.InfestationManager; class InfestationsLifecycleTest { @@ -81,6 +83,15 @@ private Infestations controlled(Path data, Logger logger) { plugin.onDisable(); verify(plugin.getInfestationManager()).shutdown(); } + @Test void characterListenerIsRegisteredOnlyWithRPCharacters() { + assertFalse(HandlerList.getRegisteredListeners(plugin).stream() + .anyMatch(l -> l.getListener() instanceof CharacterActivatedListener)); + MockBukkit.createMockPlugin("RPCharacters"); + plugin.onEnable(); + assertTrue(HandlerList.getRegisteredListeners(plugin).stream() + .anyMatch(l -> l.getListener() instanceof CharacterActivatedListener)); + } + @Test void reloadAppliesCurrentConfigAndReportsDebugSettingsWithoutReplacingFiles() throws Exception { Path folder = plugin.getDataFolder().toPath(); Path config = folder.resolve("config.yml"); String custom = "debug: true\nlogging: false\njoin-seconds: 42\nlure-item: ia.test:lure\n"; diff --git a/src/test/java/net/tfminecraft/infestations/InfestationsServicesTest.java b/src/test/java/net/tfminecraft/infestations/InfestationsServicesTest.java index 408dd08..2eda347 100644 --- a/src/test/java/net/tfminecraft/infestations/InfestationsServicesTest.java +++ b/src/test/java/net/tfminecraft/infestations/InfestationsServicesTest.java @@ -6,7 +6,13 @@ import net.tfminecraft.infestations.loader.GroupLoader; import net.tfminecraft.infestations.map.InfestationMapExport; import net.tfminecraft.infestations.spawn.SpawnLog; +import net.tfminecraft.infestations.utils.Characters; import net.tfminecraft.infestations.utils.Provinces; +import net.tfminecraft.rpcharacters.enums.Status; +import net.tfminecraft.rpcharacters.lifecycle.CharacterActivatedEvent; +import net.tfminecraft.rpcharacters.managers.PlayerManager; +import net.tfminecraft.rpcharacters.objects.PlayerData; +import net.tfminecraft.rpcharacters.objects.RPCharacter; import net.tfminecraft.simplefactions.SimpleFactions; import net.tfminecraft.simplefactions.enums.Terrain; import net.tfminecraft.simplefactions.map.ProvinceGrid; @@ -51,6 +57,38 @@ class InfestationsServicesTest { Messages.load(directory.resolve("absent").toFile()); verify(logger, times(2)).severe(contains("Failed to load messages")); } + @Test @SuppressWarnings("unchecked") void characterLookupsNeedRPCharactersAndReadTheOwnersCharacters() throws Exception { + Player player = MockBukkit.getMock().addPlayer(); + assertNull(Characters.activeId(player)); assertFalse(Characters.isDead(player, "alice")); + MockBukkit.createMockPlugin("RPCharacters"); + assertNull(Characters.activeId(player)); assertFalse(Characters.isDead(player, "alice")); + RPCharacter alice = mock(RPCharacter.class), bob = mock(RPCharacter.class); + when(alice.getId()).thenReturn("alice"); when(alice.getStatus()).thenReturn(Status.DEAD); + when(bob.getId()).thenReturn("bob"); when(bob.getStatus()).thenReturn(Status.ALIVE); + PlayerData data = mock(PlayerData.class); + when(data.getPlayer()).thenReturn(player); when(data.getCharacters()).thenReturn(List.of(alice, bob)); + var field = PlayerManager.class.getDeclaredField("data"); field.setAccessible(true); + List loaded = (List) field.get(null); + loaded.add(data); + try { + assertNull(Characters.activeId(player)); + when(data.getActiveCharacter()).thenReturn(bob); + assertEquals("bob", Characters.activeId(player)); + assertTrue(Characters.isDead(player, "alice")); assertFalse(Characters.isDead(player, "bob")); + assertFalse(Characters.isDead(player, "carol")); + } finally { + loaded.remove(data); + } + } + @Test void characterActivationAppliesPendingDeathsForOnlineOwners() { + InfestationManager manager = mock(InfestationManager.class); + CharacterActivatedListener listener = new CharacterActivatedListener(manager); + RPCharacter character = mock(RPCharacter.class); Player owner = mock(Player.class); + listener.onCharacterActivated(new CharacterActivatedEvent(null, UUID.randomUUID(), character, null)); + verifyNoInteractions(manager); + listener.onCharacterActivated(new CharacterActivatedEvent(owner, UUID.randomUUID(), character, null)); + verify(manager).applyDeathOnLogin(owner); + } @Test void provinceAdaptersHandleUnavailableDependenciesAndKnownTerrain() { try (var sf = mockStatic(SimpleFactions.class); var rest = mockStatic(RestServer.class)) { assertEquals(-2, Provinces.at((Player)null)); assertEquals(-2, Provinces.at((Location)null)); diff --git a/src/test/java/net/tfminecraft/infestations/database/InfestationDataTest.java b/src/test/java/net/tfminecraft/infestations/database/InfestationDataTest.java index 30d13b7..d70127f 100644 --- a/src/test/java/net/tfminecraft/infestations/database/InfestationDataTest.java +++ b/src/test/java/net/tfminecraft/infestations/database/InfestationDataTest.java @@ -16,6 +16,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.logging.Logger; @@ -140,6 +141,7 @@ void lureLifecycleLocatesTheBlockAndResetsOnlyLureState() { infestation.getCommitted().add(PLAYER); infestation.getLogoutGraceUntil().put(PLAYER, 100L); infestation.getDeathOnLogin().add(PLAYER); + infestation.getLogoutCharacter().put(PLAYER, "alice"); infestation.setWaveRetryAtTick(5); infestation.setVictoryAtTick(6); infestation.setPendingSpawns(3); @@ -161,6 +163,7 @@ void lureLifecycleLocatesTheBlockAndResetsOnlyLureState() { assertTrue(infestation.getCommitted().isEmpty()); assertTrue(infestation.getLogoutGraceUntil().isEmpty()); assertTrue(infestation.getDeathOnLogin().isEmpty()); + assertTrue(infestation.getLogoutCharacter().isEmpty()); assertEquals(0, infestation.getPendingSpawns()); assertEquals(0, infestation.getEnemiesAlive()); assertEquals(0, infestation.getLureReleased()); @@ -171,6 +174,7 @@ void lureLifecycleLocatesTheBlockAndResetsOnlyLureState() { assertTrue(infestation.isCommitted(PLAYER)); infestation.getLogoutGraceUntil().put(PLAYER, 100L); infestation.getDeathOnLogin().add(PLAYER); + infestation.getLogoutCharacter().put(PLAYER, "alice"); infestation.clearLure(); assertSame(LurePhase.NONE, infestation.getPhase()); assertFalse(infestation.hasLure()); @@ -184,6 +188,7 @@ void lureLifecycleLocatesTheBlockAndResetsOnlyLureState() { assertFalse(infestation.isCommitted(PLAYER)); assertTrue(infestation.getLogoutGraceUntil().isEmpty()); assertTrue(infestation.getDeathOnLogin().isEmpty()); + assertTrue(infestation.getLogoutCharacter().isEmpty()); } @Test @@ -245,6 +250,7 @@ void persistenceRoundTripKeepsRuntimeStateButRequeuesScheduledSpawns() throws Ex original.getCommitted().add(PLAYER); original.getLogoutGraceUntil().put(PLAYER, 9876L); original.getDeathOnLogin().add(PLAYER); + original.getLogoutCharacter().put(PLAYER, "alice"); InfestationDatabase.save(List.of(original)); assertTrue(Files.readString(InfestationDatabase.file().toPath()).contains("test-group")); Infestation loaded = InfestationDatabase.load().getFirst(); @@ -263,6 +269,7 @@ void persistenceRoundTripKeepsRuntimeStateButRequeuesScheduledSpawns() throws Ex assertEquals(Set.of(PLAYER), loaded.getCommitted()); assertEquals(9876L, loaded.getLogoutGraceUntil().get(PLAYER)); assertEquals(Set.of(PLAYER), loaded.getDeathOnLogin()); + assertEquals(Map.of(PLAYER, "alice"), loaded.getLogoutCharacter()); } @Test @@ -273,10 +280,11 @@ void malformedRowsAndLegacyFieldsDoNotDiscardValidNeighbors() throws Exception { {"provinceId":2,"severity":"mild"}, {"provinceId":3,"groupId":"valid","severity":"mild","phase":"old-phase", "committed":["bad","%s"],"logoutGraceUntil":{"bad":5,"%s":6}, - "deathOnLogin":["bad","%s"],"pendingSpawns":20,"lureReleased":3}, + "deathOnLogin":["bad","%s"],"logoutCharacter":{"bad":"x","%s":"alice"}, + "pendingSpawns":20,"lureReleased":3}, {"provinceId":4,"groupId":"legacy","severity":"worrying","committed":null, - "logoutGraceUntil":null,"deathOnLogin":null}] - """.formatted(PLAYER, PLAYER, PLAYER)); + "logoutGraceUntil":null,"deathOnLogin":null,"logoutCharacter":{"%s":null}}] + """.formatted(PLAYER, PLAYER, PLAYER, PLAYER, PLAYER)); List loaded = InfestationDatabase.load(); assertEquals(2, loaded.size()); assertSame(LurePhase.NONE, loaded.getFirst().getPhase()); @@ -286,6 +294,8 @@ void malformedRowsAndLegacyFieldsDoNotDiscardValidNeighbors() throws Exception { assertEquals(Set.of(PLAYER), loaded.getFirst().getDeathOnLogin()); assertTrue(loaded.get(1).getCommitted().isEmpty()); assertTrue(loaded.get(1).getDeathOnLogin().isEmpty()); + assertEquals(Map.of(PLAYER, "alice"), loaded.getFirst().getLogoutCharacter()); + assertTrue(loaded.get(1).getLogoutCharacter().isEmpty()); } @Test diff --git a/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java b/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java index 44f6f02..ef6079f 100644 --- a/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java +++ b/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java @@ -46,6 +46,7 @@ import net.tfminecraft.infestations.lure.LureHologram; import net.tfminecraft.infestations.map.InfestationMapExport; import net.tfminecraft.infestations.spawn.*; +import net.tfminecraft.infestations.utils.Characters; import net.tfminecraft.infestations.utils.Provinces; import net.tfminecraft.interactiblefurniture.events.*; import net.tfminecraft.interactiblefurniture.furniture.Furniture; @@ -79,6 +80,7 @@ class InfestationManagerTest { private MockedStatic export; private MockedStatic groups; private MockedStatic provinceApi; + private MockedStatic characters; private MockedStatic planner; private MockedStatic spawner; private MockedStatic furniture; @@ -106,6 +108,7 @@ class InfestationManagerTest { export = scoped(mockStatic(InfestationMapExport.class)); groups = scoped(mockStatic(GroupLoader.class)); provinceApi = scoped(mockStatic(Provinces.class)); + characters = scoped(mockStatic(Characters.class)); planner = scoped(mockStatic(SpawnPlanner.class)); spawner = scoped(mockStatic(MythicSpawner.class)); furniture = scoped(mockStatic(LureFurniture.class)); @@ -814,6 +817,33 @@ void waterAndSeaSpreadLogTheirRoutes(boolean sea) throws Exception { when(p.isOnline()).thenReturn(false); manager.onJoin(join); runLater(); verify(p, times(1)).damage(anyDouble()); } + @Test void deathOnLoginWaitsForTheCharacterWhoLoggedOffAndIsDroppedOnceThatCharacterDied() { + Infestation state = lure(1, LurePhase.ACTIVE); + Player p = player(1); UUID id = p.getUniqueId(); state.getCommitted().add(id); + characters.when(() -> Characters.activeId(p)).thenReturn("alice"); + PlayerQuitEvent quit = mock(PlayerQuitEvent.class); when(quit.getPlayer()).thenReturn(p); + manager.onQuit(quit); assertEquals("alice", state.getLogoutCharacter().get(id)); + state.getLogoutGraceUntil().clear(); state.getDeathOnLogin().add(id); + + characters.when(() -> Characters.activeId(p)).thenReturn("bob"); + PlayerJoinEvent join = mock(PlayerJoinEvent.class); when(join.getPlayer()).thenReturn(p); + manager.onJoin(join); runLater(); + verify(p, never()).damage(anyDouble()); + assertTrue(state.getDeathOnLogin().contains(id)); assertEquals("alice", state.getLogoutCharacter().get(id)); + + characters.when(() -> Characters.activeId(p)).thenReturn("alice"); + manager.applyDeathOnLogin(p); + verify(p).damage(40.0); + assertTrue(state.getDeathOnLogin().isEmpty()); assertTrue(state.getLogoutCharacter().isEmpty()); + + state.getDeathOnLogin().add(id); state.getLogoutCharacter().put(id, "alice"); + characters.when(() -> Characters.activeId(p)).thenReturn("bob"); + characters.when(() -> Characters.isDead(p, "alice")).thenReturn(true); + manager.applyDeathOnLogin(p); + verify(p, times(1)).damage(anyDouble()); + assertFalse(state.isCommitted(id)); assertEquals(LurePhase.NONE, state.getPhase()); + } + @Test void provinceEventsWarnOnlyRelevantParticipants() { Player p = player(1); PlayerProvinceLeaveEvent leave = mock(PlayerProvinceLeaveEvent.class); when(leave.getPlayer()).thenReturn(p); when(leave.getProvinceId()).thenReturn(1); From 568131ace738b94120662f973fddd3949ee04f98 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:32:17 +0000 Subject: [PATCH 2/3] fix: keep a pending lure death and commitment with their character Record the character at LOWEST quit priority, before RPCharacters drops its data. A grace or death already owed keeps its character and deadline when another character logs off, and that other character takes no deserter damage and cannot settle the debt by dying or /lure leave. Deaths without a character are held with a console warning while RPCharacters runs; without RPCharacters, tagged deaths fall back to the account. Co-Authored-By: Claude Opus 5.5 --- .../CharacterActivatedListener.java | 4 +- .../infestation/InfestationManager.java | 97 +++++++++++++----- .../InfestationsServicesTest.java | 30 +++++- .../infestation/InfestationManagerTest.java | 98 ++++++++++++++++++- 4 files changed, 199 insertions(+), 30 deletions(-) diff --git a/src/main/java/net/tfminecraft/infestations/infestation/CharacterActivatedListener.java b/src/main/java/net/tfminecraft/infestations/infestation/CharacterActivatedListener.java index cb68636..be0c1fd 100644 --- a/src/main/java/net/tfminecraft/infestations/infestation/CharacterActivatedListener.java +++ b/src/main/java/net/tfminecraft/infestations/infestation/CharacterActivatedListener.java @@ -5,7 +5,7 @@ import net.tfminecraft.rpcharacters.lifecycle.CharacterActivatedEvent; -/** Registered only with RPCharacters: a pending death applies once its character is active again. */ +/** Registered only with RPCharacters: a character's lure grace ends, or its pending death applies, once active. */ public final class CharacterActivatedListener implements Listener { private final InfestationManager manager; @@ -17,7 +17,7 @@ public CharacterActivatedListener(InfestationManager manager) { @EventHandler public void onCharacterActivated(CharacterActivatedEvent event) { if (event.getOwner() != null) { - manager.applyDeathOnLogin(event.getOwner()); + manager.characterReturned(event.getOwner()); } } } diff --git a/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java b/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java index fba2071..00a4cb0 100644 --- a/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java +++ b/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java @@ -103,6 +103,7 @@ public void start() { for (Infestation infestation : InfestationDatabase.load()) { byProvince.put(infestation.getProvinceId(), infestation); } + warnUnidentifiedDeaths(); InfestationMapExport.exportAsync(byProvince.values()); task = Bukkit.getScheduler().runTaskTimer(plugin, this::tick, 20L, 1L); Bukkit.getScheduler().runTaskLater(plugin, () -> { @@ -155,6 +156,7 @@ public void reloadFromDisk() { for (Infestation infestation : InfestationDatabase.load()) { byProvince.put(infestation.getProvinceId(), infestation); } + warnUnidentifiedDeaths(); pluginFurnitureRemove = true; try { for (Infestation old : previous.values()) { @@ -638,7 +640,7 @@ private void tickOutsideCommitted(Infestation infestation) { if (player.getGameMode() == GameMode.CREATIVE || player.getGameMode() == GameMode.SPECTATOR) { continue; } - if (Provinces.at(player) == infestation.getProvinceId()) { + if (Provinces.at(player) == infestation.getProvinceId() || heldByOtherCharacter(infestation, player)) { continue; } actionBar(player, bar); @@ -658,7 +660,7 @@ public boolean leaveLure(Player player) { UUID id = player.getUniqueId(); boolean found = false; for (Infestation infestation : List.copyOf(byProvince.values())) { - if (!infestation.isCommitted(id)) { + if (!infestation.isCommitted(id) || heldByOtherCharacter(infestation, player)) { continue; } if (!found) { @@ -669,6 +671,7 @@ public boolean leaveLure(Player player) { infestation.getCommitted().remove(id); infestation.getLogoutGraceUntil().remove(id); infestation.getDeathOnLogin().remove(id); + infestation.getLogoutCharacter().remove(id); if (infestation.getPhase() != LurePhase.NONE && !hasLivingOrGrace(infestation)) { failLure(infestation); if (!inProvince) { @@ -689,10 +692,46 @@ private void expireGrace(Infestation infestation, long now) { if (now >= e.getValue()) { infestation.getDeathOnLogin().add(e.getKey()); it.remove(); + if (isUnidentified(infestation, e.getKey())) { + warnUnidentified(infestation, e.getKey()); + } + } + } + } + + /** + * True when the account's commitment here belongs to a logged off character other than the active one. + * That character's grace or pending death stays with it, so the active character is not treated as committed. + */ + private static boolean heldByOtherCharacter(Infestation infestation, Player player) { + if (!Characters.enabled()) { + return false; + } + String owner = infestation.getLogoutCharacter().get(player.getUniqueId()); + return owner != null && !owner.equals(Characters.activeId(player)); + } + + /** A death owed with RPCharacters enabled but no recorded character is held, never applied. */ + private static boolean isUnidentified(Infestation infestation, UUID id) { + return Characters.enabled() && !infestation.getLogoutCharacter().containsKey(id); + } + + private void warnUnidentifiedDeaths() { + for (Infestation infestation : byProvince.values()) { + for (UUID id : infestation.getDeathOnLogin()) { + if (isUnidentified(infestation, id)) { + warnUnidentified(infestation, id); + } } } } + private void warnUnidentified(Infestation infestation, UUID id) { + plugin.getLogger().warning("[Infestations] Holding the death on login owed by " + id + " in province " + + infestation.getProvinceId() + ": it has no character, so it will not be applied." + + " It clears when the lure ends or that account dies or leaves the lure."); + } + private void checkWipe(Infestation infestation) { if (infestation.getPhase() != LurePhase.ACTIVE) { return; @@ -825,6 +864,7 @@ private void tryJoin(Player player, Infestation infestation) { infestation.getCommitted().add(player.getUniqueId()); infestation.getLogoutGraceUntil().remove(player.getUniqueId()); infestation.getDeathOnLogin().remove(player.getUniqueId()); + infestation.getLogoutCharacter().remove(player.getUniqueId()); player.sendMessage(Messages.get("lure-join")); save(); } @@ -1090,12 +1130,13 @@ private record RecentDeath(int provinceId, String kind, int tick) {} private void onPlayerDeath(Player player) { for (Infestation infestation : List.copyOf(byProvince.values())) { - if (!infestation.isCommitted(player.getUniqueId())) { + if (!infestation.isCommitted(player.getUniqueId()) || heldByOtherCharacter(infestation, player)) { continue; } infestation.getCommitted().remove(player.getUniqueId()); infestation.getLogoutGraceUntil().remove(player.getUniqueId()); infestation.getDeathOnLogin().remove(player.getUniqueId()); + infestation.getLogoutCharacter().remove(player.getUniqueId()); if (infestation.getPhase() != LurePhase.NONE) { checkWipe(infestation); } @@ -1103,20 +1144,22 @@ private void onPlayerDeath(Player player) { save(); } - @EventHandler + /** Runs before RPCharacters' quit listener drops the player's character data. */ + @EventHandler(priority = EventPriority.LOWEST) public void onQuit(PlayerQuitEvent event) { Player player = event.getPlayer(); + UUID id = player.getUniqueId(); long until = System.currentTimeMillis() + Cache.logoutGraceSeconds * 1000L; String character = Characters.activeId(player); for (Infestation infestation : byProvince.values()) { - if (!infestation.isCommitted(player.getUniqueId())) { + // A grace or death already owed keeps its character and deadline. + if (!infestation.isCommitted(id) || infestation.getLogoutCharacter().containsKey(id) + || infestation.getDeathOnLogin().contains(id)) { continue; } - infestation.getLogoutGraceUntil().put(player.getUniqueId(), until); - if (character == null) { - infestation.getLogoutCharacter().remove(player.getUniqueId()); - } else { - infestation.getLogoutCharacter().put(player.getUniqueId(), character); + infestation.getLogoutGraceUntil().put(id, until); + if (character != null) { + infestation.getLogoutCharacter().put(id, character); } } save(); @@ -1126,33 +1169,37 @@ public void onQuit(PlayerQuitEvent event) { public void onJoin(PlayerJoinEvent event) { Player player = event.getPlayer(); Bukkit.getScheduler().runTaskLater(plugin, () -> { - if (!player.isOnline()) { - return; - } - for (Infestation infestation : byProvince.values()) { - infestation.getLogoutGraceUntil().remove(player.getUniqueId()); - if (!infestation.getDeathOnLogin().contains(player.getUniqueId())) { - infestation.getLogoutCharacter().remove(player.getUniqueId()); - } + if (player.isOnline()) { + characterReturned(player); } - applyDeathOnLogin(player); }, 5L); } /** - * Kills a player whose lure grace ran out, once the character who logged off is active. - * The death waits while another character is active and is dropped if that character has died. + * Ends the logout grace of the character now active and kills it if its grace ran out. + * Another character's grace or death waits, and the death is dropped once that character has died. + * Without RPCharacters the account is the character, so any return counts. */ - public void applyDeathOnLogin(Player player) { + public void characterReturned(Player player) { UUID id = player.getUniqueId(); + boolean tracked = Characters.enabled(); String active = Characters.activeId(player); boolean changed = false; for (Infestation infestation : List.copyOf(byProvince.values())) { + String target = tracked ? infestation.getLogoutCharacter().get(id) : null; + boolean otherCharacter = target != null && !target.equals(active); + if (!otherCharacter && infestation.getLogoutGraceUntil().remove(id) != null) { + changed = true; + } if (!infestation.getDeathOnLogin().contains(id)) { + if (!otherCharacter && infestation.getLogoutCharacter().remove(id) != null) { + changed = true; + } + continue; + } + if (isUnidentified(infestation, id)) { continue; } - String target = infestation.getLogoutCharacter().get(id); - boolean otherCharacter = target != null && !target.equals(active); if (otherCharacter && !Characters.isDead(player, target)) { continue; } @@ -1184,7 +1231,7 @@ public void onLeaveProvince(PlayerProvinceLeaveEvent event) { return; } Player player = event.getPlayer(); - if (!infestation.isCommitted(player.getUniqueId())) { + if (!infestation.isCommitted(player.getUniqueId()) || heldByOtherCharacter(infestation, player)) { return; } if (player.getGameMode() == GameMode.CREATIVE || player.getGameMode() == GameMode.SPECTATOR) { diff --git a/src/test/java/net/tfminecraft/infestations/InfestationsServicesTest.java b/src/test/java/net/tfminecraft/infestations/InfestationsServicesTest.java index 2eda347..e768f47 100644 --- a/src/test/java/net/tfminecraft/infestations/InfestationsServicesTest.java +++ b/src/test/java/net/tfminecraft/infestations/InfestationsServicesTest.java @@ -2,6 +2,7 @@ import com.google.gson.JsonParser; import net.tfminecraft.infestations.cache.Cache; +import net.tfminecraft.infestations.database.InfestationDatabase; import net.tfminecraft.infestations.infestation.*; import net.tfminecraft.infestations.loader.GroupLoader; import net.tfminecraft.infestations.map.InfestationMapExport; @@ -21,6 +22,9 @@ import net.tfminecraft.simplefactions.rest.RestServer; import org.bukkit.*; import org.bukkit.entity.Player; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.scheduler.BukkitScheduler; import org.junit.jupiter.api.*; import org.junit.jupiter.api.io.TempDir; @@ -80,6 +84,30 @@ class InfestationsServicesTest { loaded.remove(data); } } + @Test @SuppressWarnings("unchecked") void quitRecordsTheCharacterBeforeRPCharactersDropsItsData() throws Exception { + var server = MockBukkit.getMock(); Player player = server.addPlayer(); + var rpcharacters = MockBukkit.createMockPlugin("RPCharacters"); var infestations = MockBukkit.createMockPlugin("Infestations"); + var field = PlayerManager.class.getDeclaredField("data"); field.setAccessible(true); + List loaded = (List) field.get(null); + PlayerData data = mock(PlayerData.class); RPCharacter alice = mock(RPCharacter.class); + when(alice.getId()).thenReturn("alice"); when(data.getPlayer()).thenReturn(player); + when(data.getActiveCharacter()).thenReturn(alice); loaded.add(data); + // RPCharacters loads first and drops the data in PlayerManager.onLeave, a NORMAL priority listener. + server.getPluginManager().registerEvent(PlayerQuitEvent.class, new Listener() {}, EventPriority.NORMAL, + (listener, event) -> loaded.remove(data), rpcharacters); + try (var database = mockStatic(InfestationDatabase.class); + var export = mockStatic(InfestationMapExport.class)) { + InfestationManager manager = new InfestationManager(Infestations.plugin); + server.getPluginManager().registerEvents(manager, infestations); + manager.set(1, "zombies", Severity.MILD); + manager.get(1).getCommitted().add(player.getUniqueId()); + server.getPluginManager().callEvent(new PlayerQuitEvent(player, net.kyori.adventure.text.Component.empty())); + assertFalse(loaded.contains(data)); + assertEquals(Map.of(player.getUniqueId(), "alice"), manager.get(1).getLogoutCharacter()); + } finally { + loaded.remove(data); + } + } @Test void characterActivationAppliesPendingDeathsForOnlineOwners() { InfestationManager manager = mock(InfestationManager.class); CharacterActivatedListener listener = new CharacterActivatedListener(manager); @@ -87,7 +115,7 @@ class InfestationsServicesTest { listener.onCharacterActivated(new CharacterActivatedEvent(null, UUID.randomUUID(), character, null)); verifyNoInteractions(manager); listener.onCharacterActivated(new CharacterActivatedEvent(owner, UUID.randomUUID(), character, null)); - verify(manager).applyDeathOnLogin(owner); + verify(manager).characterReturned(owner); } @Test void provinceAdaptersHandleUnavailableDependenciesAndKnownTerrain() { try (var sf = mockStatic(SimpleFactions.class); var rest = mockStatic(RestServer.class)) { diff --git a/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java b/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java index ef6079f..e633e0e 100644 --- a/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java +++ b/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java @@ -820,6 +820,7 @@ void waterAndSeaSpreadLogTheirRoutes(boolean sea) throws Exception { @Test void deathOnLoginWaitsForTheCharacterWhoLoggedOffAndIsDroppedOnceThatCharacterDied() { Infestation state = lure(1, LurePhase.ACTIVE); Player p = player(1); UUID id = p.getUniqueId(); state.getCommitted().add(id); + characters.when(Characters::enabled).thenReturn(true); characters.when(() -> Characters.activeId(p)).thenReturn("alice"); PlayerQuitEvent quit = mock(PlayerQuitEvent.class); when(quit.getPlayer()).thenReturn(p); manager.onQuit(quit); assertEquals("alice", state.getLogoutCharacter().get(id)); @@ -832,18 +833,111 @@ void waterAndSeaSpreadLogTheirRoutes(boolean sea) throws Exception { assertTrue(state.getDeathOnLogin().contains(id)); assertEquals("alice", state.getLogoutCharacter().get(id)); characters.when(() -> Characters.activeId(p)).thenReturn("alice"); - manager.applyDeathOnLogin(p); + manager.characterReturned(p); verify(p).damage(40.0); assertTrue(state.getDeathOnLogin().isEmpty()); assertTrue(state.getLogoutCharacter().isEmpty()); state.getDeathOnLogin().add(id); state.getLogoutCharacter().put(id, "alice"); characters.when(() -> Characters.activeId(p)).thenReturn("bob"); characters.when(() -> Characters.isDead(p, "alice")).thenReturn(true); - manager.applyDeathOnLogin(p); + manager.characterReturned(p); verify(p, times(1)).damage(anyDouble()); assertFalse(state.isCommitted(id)); assertEquals(LurePhase.NONE, state.getPhase()); } + @Test void anotherCharacterLoggingOffKeepsThePendingCharacterAndDeadline() { + Infestation state = lure(1, LurePhase.ACTIVE); Infestation waiting = lure(2, LurePhase.ACTIVE); + Player p = player(1); UUID id = p.getUniqueId(); + state.getCommitted().add(id); waiting.getCommitted().add(id); + characters.when(Characters::enabled).thenReturn(true); + state.getDeathOnLogin().add(id); state.getLogoutCharacter().put(id, "alice"); + waiting.getLogoutGraceUntil().put(id, 1234L); waiting.getLogoutCharacter().put(id, "alice"); + characters.when(() -> Characters.activeId(p)).thenReturn("bob"); + PlayerJoinEvent join = mock(PlayerJoinEvent.class); when(join.getPlayer()).thenReturn(p); + manager.onJoin(join); runLater(); + assertEquals(1234L, waiting.getLogoutGraceUntil().get(id)); + PlayerQuitEvent quit = mock(PlayerQuitEvent.class); when(quit.getPlayer()).thenReturn(p); + manager.onQuit(quit); + characters.when(() -> Characters.activeId(p)).thenReturn(null); + manager.onQuit(quit); + assertEquals(Map.of(id, "alice"), state.getLogoutCharacter()); assertTrue(state.getLogoutGraceUntil().isEmpty()); + assertEquals(Set.of(id), state.getDeathOnLogin()); + assertEquals(Map.of(id, "alice"), waiting.getLogoutCharacter()); + assertEquals(Map.of(id, 1234L), waiting.getLogoutGraceUntil()); + } + + @Test void anotherCharacterIsNotPunishedForAndCannotSettleTheCommitment() throws Exception { + Infestation owed = lure(1, LurePhase.ACTIVE); Infestation grace = lure(3, LurePhase.ACTIVE); + Player p = player(2); UUID id = p.getUniqueId(); + owed.getCommitted().add(id); grace.getCommitted().add(id); + characters.when(Characters::enabled).thenReturn(true); + owed.getDeathOnLogin().add(id); owed.getLogoutCharacter().put(id, "alice"); + grace.getLogoutGraceUntil().put(id, Long.MAX_VALUE); grace.getLogoutCharacter().put(id, "alice"); + characters.when(() -> Characters.activeId(p)).thenReturn("bob"); + atTick(100); call("tickOutsideCommitted", owed); call("tickOutsideCommitted", grace); + PlayerProvinceLeaveEvent leave = mock(PlayerProvinceLeaveEvent.class); + when(leave.getPlayer()).thenReturn(p); when(leave.getProvinceId()).thenReturn(1); + manager.onLeaveProvince(leave); + verify(p, never()).damage(anyDouble()); verify(p, never()).sendMessage("lure-outside"); + assertFalse(manager.leaveLure(p)); + EntityDeathEvent death = mock(EntityDeathEvent.class); when(death.getEntity()).thenReturn(p); + manager.onDeath(death); + for (Infestation infestation : List.of(owed, grace)) { + assertTrue(infestation.isCommitted(id)); assertEquals("alice", infestation.getLogoutCharacter().get(id)); + assertEquals(LurePhase.ACTIVE, infestation.getPhase()); + } + assertEquals(Set.of(id), owed.getDeathOnLogin()); assertTrue(grace.getLogoutGraceUntil().containsKey(id)); + + characters.when(() -> Characters.activeId(p)).thenReturn("alice"); + owed.getDeathOnLogin().clear(); owed.getCommitted().remove(id); owed.getLogoutCharacter().remove(id); + manager.characterReturned(p); + assertTrue(grace.getLogoutGraceUntil().isEmpty()); assertTrue(grace.getLogoutCharacter().isEmpty()); + call("tickOutsideCommitted", grace); verify(p).damage(2.0); + assertTrue(manager.leaveLure(p)); assertFalse(grace.isCommitted(id)); + } + + @Test void unidentifiedDeathsAreHeldWithAWarningWhileRPCharactersRuns() { + Infestation legacy = lure(1, LurePhase.ACTIVE); + Player p = player(1); UUID id = p.getUniqueId(); legacy.getCommitted().add(id); legacy.getDeathOnLogin().add(id); + database.when(InfestationDatabase::load).thenReturn(List.of(legacy)); + manager.start(); + verify(plugin.getLogger(), never()).warning(anyString()); + characters.when(Characters::enabled).thenReturn(true); + characters.when(() -> Characters.activeId(p)).thenReturn("bob"); + manager.start(); + verify(plugin.getLogger()).warning(contains(id + " in province 1")); + PlayerJoinEvent join = mock(PlayerJoinEvent.class); when(join.getPlayer()).thenReturn(p); + manager.onJoin(join); runLater(); + verify(p, never()).damage(anyDouble()); + assertEquals(Set.of(id), legacy.getDeathOnLogin()); assertTrue(legacy.isCommitted(id)); + PlayerQuitEvent quit = mock(PlayerQuitEvent.class); when(quit.getPlayer()).thenReturn(p); + manager.onQuit(quit); + assertTrue(legacy.getLogoutGraceUntil().isEmpty()); assertTrue(legacy.getLogoutCharacter().isEmpty()); + UUID untagged = UUID.randomUUID(), tagged = UUID.randomUUID(); + legacy.getLogoutGraceUntil().put(untagged, 1L); legacy.getLogoutGraceUntil().put(tagged, 1L); + legacy.getLogoutCharacter().put(tagged, "carol"); + call("expireGrace", legacy, 2L); + verify(plugin.getLogger()).warning(contains(untagged.toString())); + verify(plugin.getLogger(), never()).warning(contains(tagged.toString())); + } + + @Test void taggedDeathsUseTheAccountWithoutRPCharactersAndWaitForMissingCharacterData() { + Infestation state = lure(1, LurePhase.ACTIVE); + Player p = player(1); UUID id = p.getUniqueId(); state.getCommitted().add(id); + state.getDeathOnLogin().add(id); state.getLogoutCharacter().put(id, "alice"); + characters.when(Characters::enabled).thenReturn(true); + manager.characterReturned(p); + verify(p, never()).damage(anyDouble()); assertEquals(Set.of(id), state.getDeathOnLogin()); + characters.when(Characters::enabled).thenReturn(false); + PlayerJoinEvent join = mock(PlayerJoinEvent.class); when(join.getPlayer()).thenReturn(p); + manager.onJoin(join); runLater(); + verify(p).damage(40.0); + assertTrue(state.getDeathOnLogin().isEmpty()); assertTrue(state.getLogoutCharacter().isEmpty()); + state.getLogoutGraceUntil().put(id, Long.MAX_VALUE); state.getLogoutCharacter().put(id, "alice"); + manager.onJoin(join); runLater(); + assertTrue(state.getLogoutGraceUntil().isEmpty()); assertTrue(state.getLogoutCharacter().isEmpty()); + } + @Test void provinceEventsWarnOnlyRelevantParticipants() { Player p = player(1); PlayerProvinceLeaveEvent leave = mock(PlayerProvinceLeaveEvent.class); when(leave.getPlayer()).thenReturn(p); when(leave.getProvinceId()).thenReturn(1); From aceeafd82d432a9b6e4fa749d41534c13d116fe0 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:40:18 +0000 Subject: [PATCH 3/3] fix: stop held legacy lure deaths punishing players or keeping lures alive A pending lure death saved without a character is held while RPCharacters runs, but the account still took deserter damage and the death kept the lure from failing forever. Such debts now bring no deserter damage or outside warnings, and neither the debt nor its commitment counts towards keeping the lure alive. They still clear when the lure ends. Co-Authored-By: Claude Opus 5.5 --- .../infestation/InfestationManager.java | 24 ++++++++++-- .../infestation/InfestationManagerTest.java | 37 +++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java b/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java index 00a4cb0..0810a33 100644 --- a/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java +++ b/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java @@ -640,7 +640,7 @@ private void tickOutsideCommitted(Infestation infestation) { if (player.getGameMode() == GameMode.CREATIVE || player.getGameMode() == GameMode.SPECTATOR) { continue; } - if (Provinces.at(player) == infestation.getProvinceId() || heldByOtherCharacter(infestation, player)) { + if (Provinces.at(player) == infestation.getProvinceId() || notAnswerable(infestation, player)) { continue; } actionBar(player, bar); @@ -716,6 +716,16 @@ private static boolean isUnidentified(Infestation infestation, UUID id) { return Characters.enabled() && !infestation.getLogoutCharacter().containsKey(id); } + /** An unidentified death already owed: it is held, brings no deserter penalties and keeps no lure alive. */ + private static boolean isHeldDebt(Infestation infestation, UUID id) { + return infestation.getDeathOnLogin().contains(id) && isUnidentified(infestation, id); + } + + /** True when the outside warnings and deserter damage skip this committed player. */ + private static boolean notAnswerable(Infestation infestation, Player player) { + return heldByOtherCharacter(infestation, player) || isHeldDebt(infestation, player.getUniqueId()); + } + private void warnUnidentifiedDeaths() { for (Infestation infestation : byProvince.values()) { for (UUID id : infestation.getDeathOnLogin()) { @@ -742,14 +752,20 @@ private void checkWipe(Infestation infestation) { failLure(infestation); } + /** Held debts and their commitments are left out, since no character can return to settle them. */ private boolean hasLivingOrGrace(Infestation infestation) { if (!infestation.getLogoutGraceUntil().isEmpty()) { return true; } - if (!infestation.getDeathOnLogin().isEmpty()) { - return true; + for (UUID id : infestation.getDeathOnLogin()) { + if (!isUnidentified(infestation, id)) { + return true; + } } for (UUID id : infestation.getCommitted()) { + if (isHeldDebt(infestation, id)) { + continue; + } Player player = Bukkit.getPlayer(id); if (player != null && player.isOnline() && !player.isDead()) { return true; @@ -1231,7 +1247,7 @@ public void onLeaveProvince(PlayerProvinceLeaveEvent event) { return; } Player player = event.getPlayer(); - if (!infestation.isCommitted(player.getUniqueId()) || heldByOtherCharacter(infestation, player)) { + if (!infestation.isCommitted(player.getUniqueId()) || notAnswerable(infestation, player)) { return; } if (player.getGameMode() == GameMode.CREATIVE || player.getGameMode() == GameMode.SPECTATOR) { diff --git a/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java b/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java index e633e0e..18717e9 100644 --- a/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java +++ b/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java @@ -27,6 +27,7 @@ import org.bukkit.scheduler.BukkitScheduler; import org.bukkit.scheduler.BukkitTask; import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.mockito.MockedConstruction; @@ -896,6 +897,42 @@ void waterAndSeaSpreadLogTheirRoutes(boolean sea) throws Exception { assertTrue(manager.leaveLure(p)); assertFalse(grace.isCommitted(id)); } + @Test void unidentifiedDebtsBringNoDeserterPenaltiesWhileRPCharactersRuns() throws Exception { + Infestation legacy = lure(1, LurePhase.ACTIVE); + Player p = player(2); UUID id = p.getUniqueId(); legacy.getCommitted().add(id); legacy.getDeathOnLogin().add(id); + characters.when(Characters::enabled).thenReturn(true); + characters.when(() -> Characters.activeId(p)).thenReturn("bob"); + atTick(100); call("tickOutsideCommitted", legacy); + PlayerProvinceLeaveEvent leave = mock(PlayerProvinceLeaveEvent.class); + when(leave.getPlayer()).thenReturn(p); when(leave.getProvinceId()).thenReturn(1); + manager.onLeaveProvince(leave); + verify(p, never()).damage(anyDouble()); verify(p, never()).sendMessage("lure-outside"); + characters.when(Characters::enabled).thenReturn(false); + call("tickOutsideCommitted", legacy); verify(p).damage(2.0); + } + + @Test void unidentifiedDebtsDoNotKeepTheLureAliveAcrossSaveAndLoad(@TempDir java.nio.file.Path dir) { + Infestation legacy = lure(1, LurePhase.ACTIVE); + Player p = player(1); UUID id = p.getUniqueId(), offline = UUID.randomUUID(), tagged = UUID.randomUUID(); + legacy.getCommitted().addAll(List.of(id, offline, tagged)); legacy.getDeathOnLogin().addAll(List.of(id, offline, tagged)); + legacy.getLogoutCharacter().put(tagged, "carol"); + characters.when(Characters::enabled).thenReturn(true); + characters.when(() -> Characters.activeId(p)).thenReturn("bob"); + mocks.remove(database); database.close(); + when(plugin.getDataFolder()).thenReturn(dir.toFile()); + manager.save(); manager.start(); + Infestation loaded = manager.get(1); + assertNotSame(legacy, loaded); assertEquals(Set.of(id, offline, tagged), loaded.getDeathOnLogin()); + call("checkWipe", loaded); assertEquals(LurePhase.ACTIVE, loaded.getPhase()); + loaded.getDeathOnLogin().remove(tagged); loaded.getCommitted().remove(tagged); loaded.getLogoutCharacter().clear(); + manager.save(); manager.start(); loaded = manager.get(1); + assertEquals(Set.of(id, offline), loaded.getDeathOnLogin()); assertEquals(LurePhase.ACTIVE, loaded.getPhase()); + call("checkWipe", loaded); + assertEquals(LurePhase.NONE, loaded.getPhase()); + assertTrue(loaded.getCommitted().isEmpty()); assertTrue(loaded.getDeathOnLogin().isEmpty()); + verify(p).sendMessage("lure-fail"); verify(plugin.getLogger(), atLeastOnce()).warning(contains(id.toString())); + } + @Test void unidentifiedDeathsAreHeldWithAWarningWhileRPCharactersRuns() { Infestation legacy = lure(1, LurePhase.ACTIVE); Player p = player(1); UUID id = p.getUniqueId(); legacy.getCommitted().add(id); legacy.getDeathOnLogin().add(id);