From 7bd3f10c6c6df0b3fadba81fee6f05db3e53fc1d Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:19:07 +0200 Subject: [PATCH 1/3] fix: end a lure once every absent player's logout grace has run out A committed player whose logout grace expired was marked to die on next login, and that pending death kept the lure alive. If the player never came back on that character, the lure never ended: the province got no ambient spawns, nobody could place a new lure, and visitors kept taking deserter damage. Only presence or a running grace now holds a lure. When the grace runs out the player has left the fight, so the lure fails as usual once nobody else is present. Because they abandoned a running fight, the death they owe moves to the infestation when the lure fails and still kills that character on its next login, or is dropped once that character has died. Unidentified (held) debts still end with the lure. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- .../database/InfestationData.java | 1 + .../database/InfestationDatabase.java | 18 +++ .../infestations/infestation/Infestation.java | 12 ++ .../infestation/InfestationManager.java | 88 +++++++++-- .../database/InfestationDataTest.java | 17 +- .../infestation/InfestationManagerTest.java | 148 ++++++++++++++++-- 7 files changed, 262 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 56d9471..4bc6b9d 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ commits to the fight, and defeats every monster the lure draws in. - **Committed parties** — a committed player who leaves the province takes damage until they opt out of the fight. Losing the whole party fails the lure, bystanders who stay are warned and damaged, and logged-out - players get a grace period. + players get a grace period. A player still away when it ends no longer holds + the lure, and that character dies when it next logs in. - **Optional spread** — idle infestations can worsen and spread to neighbouring land, including across a single water or sea province. - **Map and persistence** — infestation severity is exported to the diff --git a/src/main/java/net/tfminecraft/infestations/database/InfestationData.java b/src/main/java/net/tfminecraft/infestations/database/InfestationData.java index fcf3bbd..1b07c23 100644 --- a/src/main/java/net/tfminecraft/infestations/database/InfestationData.java +++ b/src/main/java/net/tfminecraft/infestations/database/InfestationData.java @@ -26,4 +26,5 @@ public class InfestationData { public Map logoutGraceUntil = new HashMap<>(); public List deathOnLogin = new ArrayList<>(); public Map logoutCharacter = new HashMap<>(); + public Map> owedDeaths = 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 4bc1bca..9ab3e80 100644 --- a/src/main/java/net/tfminecraft/infestations/database/InfestationDatabase.java +++ b/src/main/java/net/tfminecraft/infestations/database/InfestationDatabase.java @@ -7,8 +7,10 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collection; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; import com.google.gson.Gson; @@ -137,6 +139,19 @@ private static Infestation fromData(InfestationData row) { } } } + if (row.owedDeaths != null) { + for (Map.Entry> e : row.owedDeaths.entrySet()) { + if (e.getKey() == null || e.getValue() == null) continue; + try { + UUID id = UUID.fromString(e.getKey()); + for (String character : e.getValue()) { + if (character == null) continue; + infestation.getOwedDeaths().computeIfAbsent(id, k -> new HashSet<>()).add(character); + } + } catch (IllegalArgumentException ignored) { + } + } + } return infestation; } @@ -169,6 +184,9 @@ private static InfestationData toData(Infestation infestation) { for (Map.Entry e : infestation.getLogoutCharacter().entrySet()) { row.logoutCharacter.put(e.getKey().toString(), e.getValue()); } + for (Map.Entry> e : infestation.getOwedDeaths().entrySet()) { + row.owedDeaths.put(e.getKey().toString(), new ArrayList<>(e.getValue())); + } return row; } } diff --git a/src/main/java/net/tfminecraft/infestations/infestation/Infestation.java b/src/main/java/net/tfminecraft/infestations/infestation/Infestation.java index 1cc7333..3d1edc1 100644 --- a/src/main/java/net/tfminecraft/infestations/infestation/Infestation.java +++ b/src/main/java/net/tfminecraft/infestations/infestation/Infestation.java @@ -13,6 +13,9 @@ public final class Infestation { + /** The owed-death entry of an account on a server without characters. */ + public static final String ACCOUNT = ""; + private final int provinceId; private String groupId; private Severity severity; @@ -41,6 +44,11 @@ public long getLureGeneration() { 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<>(); + /** + * Deaths still owed after their lure ended, by account: the characters that abandoned a running fight, + * or {@link #ACCOUNT} on servers without characters. They outlive lures until settled; clearing the infestation forgives them. + */ + private final Map> owedDeaths = new HashMap<>(); public Infestation(int provinceId, String groupId, Severity severity) { this.provinceId = provinceId; @@ -300,6 +308,10 @@ public Map getLogoutCharacter() { return logoutCharacter; } + public Map> getOwedDeaths() { + return owedDeaths; + } + 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 da25521..51a5a33 100644 --- a/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java +++ b/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java @@ -710,7 +710,9 @@ private void expireGrace(Infestation infestation, long now) { private boolean usesCharacters() { if (!characterTracking) { characterTracking = Characters.enabled() || byProvince.values().stream() - .anyMatch(infestation -> !infestation.getLogoutCharacter().isEmpty()); + .anyMatch(infestation -> !infestation.getLogoutCharacter().isEmpty() + || infestation.getOwedDeaths().values().stream() + .anyMatch(owed -> owed.stream().anyMatch(c -> !Infestation.ACCOUNT.equals(c)))); } return characterTracking; } @@ -763,18 +765,16 @@ private void checkWipe(Infestation infestation) { failLure(infestation); } - /** Held debts and their commitments are left out, since no character can return to settle them. */ + /** + * A lure holds while a committed player is present or still inside their logout grace. Once that grace + * runs out the player has left the fight: the death they owe waits for their return, not the lure. + */ private boolean hasLivingOrGrace(Infestation infestation) { if (!infestation.getLogoutGraceUntil().isEmpty()) { return true; } - for (UUID id : infestation.getDeathOnLogin()) { - if (!isUnidentified(infestation, id)) { - return true; - } - } for (UUID id : infestation.getCommitted()) { - if (isHeldDebt(infestation, id)) { + if (infestation.getDeathOnLogin().contains(id)) { continue; } Player player = Bukkit.getPlayer(id); @@ -793,6 +793,7 @@ private void failLure(Infestation infestation) { Set committed = new HashSet<>(infestation.getCommitted()); int provinceId = infestation.getProvinceId(); Location loc = infestation.lureLocation(); + keepOwedDeaths(infestation); destroyLure(infestation, false); infestation.clearLure(); ambient.reconcileLoaded(this); @@ -807,6 +808,20 @@ private void failLure(Infestation infestation) { } } + /** + * A failed lure still owes the deaths of those who abandoned it while it ran, so they move to the + * infestation and outlive the lure. Held debts have no character to reach and end with it. + */ + private void keepOwedDeaths(Infestation infestation) { + for (UUID id : infestation.getDeathOnLogin()) { + if (!infestation.isCommitted(id) || isUnidentified(infestation, id)) { + continue; + } + String character = infestation.getLogoutCharacter().getOrDefault(id, Infestation.ACCOUNT); + infestation.getOwedDeaths().computeIfAbsent(id, k -> new HashSet<>()).add(character); + } + } + private void victory(Infestation infestation) { Location loc = infestation.lureLocation(); int provinceId = infestation.getProvinceId(); @@ -1183,6 +1198,7 @@ private void settleDeath(UUID id, String character) { Player online = Bukkit.getPlayer(id); String current = online == null ? null : Characters.activeId(online); for (Infestation infestation : List.copyOf(byProvince.values())) { + dropOwedDeath(infestation, id, character == null ? Infestation.ACCOUNT : character); // Without a recorded departure, the commitment belongs to the current character, if any. String owner = infestation.getLogoutCharacter().getOrDefault(id, current); if (!infestation.isCommitted(id) || (owner != null && !owner.equalsIgnoreCase(character))) { @@ -1199,6 +1215,17 @@ private void settleDeath(UUID id, String character) { save(); } + private static boolean dropOwedDeath(Infestation infestation, UUID id, String character) { + Set owed = infestation.getOwedDeaths().get(id); + if (owed == null || !owed.removeIf(character::equalsIgnoreCase)) { + return false; + } + if (owed.isEmpty()) { + infestation.getOwedDeaths().remove(id); + } + return true; + } + @EventHandler(priority = EventPriority.LOWEST) public void onCharacterEnd(CharacterSessionEndEvent event) { characterTracking = true; @@ -1282,8 +1309,7 @@ public void characterReturned(Player player) { String active = session == null ? null : session.ref().characterId().toString(); boolean changed = false; for (Infestation infestation : List.copyOf(byProvince.values())) { - if (!player.isOnline() || Bukkit.getPlayer(id) != player - || (session != null ? !Characters.isCurrent(session) : usesCharacters())) { + if (!stillReturning(player, session)) { break; } String target = infestation.getLogoutCharacter().get(id); @@ -1313,14 +1339,54 @@ public void characterReturned(Player player) { checkWipe(infestation); } } else if (infestation.isCommitted(id) && !player.isDead()) { - player.damage(Math.max(40.0, player.getHealth() + 10.0)); + kill(player); + } + } + for (Infestation infestation : List.copyOf(byProvince.values())) { + if (!stillReturning(player, session)) { + break; } + changed |= settleOwedDeath(infestation, player, active); } if (changed) { save(); } } + private boolean stillReturning(Player player, CharacterSession session) { + return player.isOnline() && Bukkit.getPlayer(player.getUniqueId()) == player + && (session != null ? Characters.isCurrent(session) : !usesCharacters()); + } + + /** + * Kills the returning character for a death its failed lure still owes, or drops the debt once that + * character has died. Another living character's debt waits. + */ + private static boolean settleOwedDeath(Infestation infestation, Player player, String active) { + UUID id = player.getUniqueId(); + if (dropOwedDeath(infestation, id, active == null ? Infestation.ACCOUNT : active)) { + if (!player.isDead()) { + kill(player); + } + return true; + } + Set owed = infestation.getOwedDeaths().get(id); + if (owed == null || active == null) { + return false; + } + for (String character : List.copyOf(owed)) { + if (Characters.isDead(player, character)) { + dropOwedDeath(infestation, id, character); + return true; + } + } + return false; + } + + private static void kill(Player player) { + player.damage(Math.max(40.0, player.getHealth() + 10.0)); + } + @EventHandler public void onLeaveProvince(PlayerProvinceLeaveEvent event) { Infestation infestation = get(event.getProvinceId()); diff --git a/src/test/java/net/tfminecraft/infestations/database/InfestationDataTest.java b/src/test/java/net/tfminecraft/infestations/database/InfestationDataTest.java index d70127f..bf9f2a5 100644 --- a/src/test/java/net/tfminecraft/infestations/database/InfestationDataTest.java +++ b/src/test/java/net/tfminecraft/infestations/database/InfestationDataTest.java @@ -142,6 +142,7 @@ void lureLifecycleLocatesTheBlockAndResetsOnlyLureState() { infestation.getLogoutGraceUntil().put(PLAYER, 100L); infestation.getDeathOnLogin().add(PLAYER); infestation.getLogoutCharacter().put(PLAYER, "alice"); + infestation.getOwedDeaths().put(PLAYER, Set.of("bob")); infestation.setWaveRetryAtTick(5); infestation.setVictoryAtTick(6); infestation.setPendingSpawns(3); @@ -164,6 +165,7 @@ void lureLifecycleLocatesTheBlockAndResetsOnlyLureState() { assertTrue(infestation.getLogoutGraceUntil().isEmpty()); assertTrue(infestation.getDeathOnLogin().isEmpty()); assertTrue(infestation.getLogoutCharacter().isEmpty()); + assertEquals(Map.of(PLAYER, Set.of("bob")), infestation.getOwedDeaths(), "Owed deaths outlive lures"); assertEquals(0, infestation.getPendingSpawns()); assertEquals(0, infestation.getEnemiesAlive()); assertEquals(0, infestation.getLureReleased()); @@ -189,6 +191,7 @@ void lureLifecycleLocatesTheBlockAndResetsOnlyLureState() { assertTrue(infestation.getLogoutGraceUntil().isEmpty()); assertTrue(infestation.getDeathOnLogin().isEmpty()); assertTrue(infestation.getLogoutCharacter().isEmpty()); + assertEquals(Map.of(PLAYER, Set.of("bob")), infestation.getOwedDeaths()); } @Test @@ -251,6 +254,7 @@ void persistenceRoundTripKeepsRuntimeStateButRequeuesScheduledSpawns() throws Ex original.getLogoutGraceUntil().put(PLAYER, 9876L); original.getDeathOnLogin().add(PLAYER); original.getLogoutCharacter().put(PLAYER, "alice"); + original.getOwedDeaths().put(PLAYER, Set.of("bob", "carol")); InfestationDatabase.save(List.of(original)); assertTrue(Files.readString(InfestationDatabase.file().toPath()).contains("test-group")); Infestation loaded = InfestationDatabase.load().getFirst(); @@ -270,6 +274,7 @@ void persistenceRoundTripKeepsRuntimeStateButRequeuesScheduledSpawns() throws Ex assertEquals(9876L, loaded.getLogoutGraceUntil().get(PLAYER)); assertEquals(Set.of(PLAYER), loaded.getDeathOnLogin()); assertEquals(Map.of(PLAYER, "alice"), loaded.getLogoutCharacter()); + assertEquals(Map.of(PLAYER, Set.of("bob", "carol")), loaded.getOwedDeaths()); } @Test @@ -281,13 +286,19 @@ void malformedRowsAndLegacyFieldsDoNotDiscardValidNeighbors() throws Exception { {"provinceId":3,"groupId":"valid","severity":"mild","phase":"old-phase", "committed":["bad","%s"],"logoutGraceUntil":{"bad":5,"%s":6}, "deathOnLogin":["bad","%s"],"logoutCharacter":{"bad":"x","%s":"alice"}, + "owedDeaths":{"bad":["x"],"%s":[null,"bob"]}, "pendingSpawns":20,"lureReleased":3}, {"provinceId":4,"groupId":"legacy","severity":"worrying","committed":null, - "logoutGraceUntil":null,"deathOnLogin":null,"logoutCharacter":{"%s":null}}] - """.formatted(PLAYER, PLAYER, PLAYER, PLAYER, PLAYER)); + "logoutGraceUntil":null,"deathOnLogin":null,"logoutCharacter":{"%s":null}, + "owedDeaths":{"%s":null}}, + {"provinceId":5,"groupId":"older","severity":"mild","owedDeaths":null}] + """.formatted(PLAYER, PLAYER, PLAYER, PLAYER, PLAYER, PLAYER, PLAYER)); List loaded = InfestationDatabase.load(); - assertEquals(2, loaded.size()); + assertEquals(3, loaded.size()); assertSame(LurePhase.NONE, loaded.getFirst().getPhase()); + assertEquals(Map.of(PLAYER, Set.of("bob")), loaded.getFirst().getOwedDeaths()); + assertTrue(loaded.get(1).getOwedDeaths().isEmpty()); + assertTrue(loaded.get(2).getOwedDeaths().isEmpty()); assertEquals(0, loaded.getFirst().getLureReleased()); assertEquals(Set.of(PLAYER), loaded.getFirst().getCommitted()); assertEquals(6L, loaded.getFirst().getLogoutGraceUntil().get(PLAYER)); diff --git a/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java b/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java index 5fe6ce7..690da84 100644 --- a/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java +++ b/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java @@ -608,13 +608,12 @@ void playerApproachingStrictSpotUsesAnchorOrRefundsQuota(boolean anchorAvailable assertEquals(Set.of(expired), active.getDeathOnLogin()); assertEquals(Map.of(grace, 30L), active.getLogoutGraceUntil()); call("checkWipe", active); assertEquals(LurePhase.ACTIVE, active.getPhase()); - active.getLogoutGraceUntil().clear(); call("checkWipe", active); - assertEquals(LurePhase.ACTIVE, active.getPhase()); - active.getDeathOnLogin().clear(); Player dead = player(1), offline = player(1), witness = player(1), elsewhere = player(2); when(dead.isDead()).thenReturn(true); when(offline.isOnline()).thenReturn(false); - active.getCommitted().addAll(List.of(dead.getUniqueId(), offline.getUniqueId(), UUID.randomUUID())); - call("checkWipe", active); assertEquals(LurePhase.NONE, active.getPhase()); + active.getCommitted().addAll(List.of(expired, dead.getUniqueId(), offline.getUniqueId(), UUID.randomUUID())); + // An expired grace no longer holds the lure: only presence or a running grace does. + active.getLogoutGraceUntil().clear(); call("checkWipe", active); + assertEquals(LurePhase.NONE, active.getPhase()); verify(witness).sendMessage("lure-fail"); verify(elsewhere, never()).sendMessage("lure-fail"); call("checkWipe", active); call("failLure", active); verify(ambient).reconcileLoaded(manager); @@ -946,7 +945,7 @@ void waterAndSeaSpreadLogTheirRoutes(boolean sea) throws Exception { call("tickOutsideCommitted", legacy); verify(p, never()).damage(anyDouble()); } - @Test void unidentifiedDebtsDoNotKeepTheLureAliveAcrossSaveAndLoad(@TempDir java.nio.file.Path dir) { + @Test void debtsDoNotKeepTheLureAliveAndOnlyTaggedOnesOutliveItAcrossSaveAndLoad(@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)); @@ -958,14 +957,13 @@ void waterAndSeaSpreadLogTheirRoutes(boolean sea) throws Exception { 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()); + assertEquals(Map.of(tagged, Set.of(CAROL)), loaded.getOwedDeaths()); verify(p).sendMessage("lure-fail"); verify(plugin.getLogger(), atLeastOnce()).warning(contains(id.toString())); + manager.save(); manager.start(); + assertEquals(Map.of(tagged, Set.of(CAROL)), manager.get(1).getOwedDeaths()); } @Test void unidentifiedDeathsAreHeldWithAWarningWhileRPCharactersRuns() { @@ -1137,6 +1135,10 @@ private static CharacterDeathEvent died(UUID owner, String character) { call("checkWipe", lure); assertFalse(lure.isCommitted(id)); assertEquals(LurePhase.NONE, lure.getPhase()); + assertTrue(lure.getOwedDeaths().isEmpty()); + online.put(id, p); when(p.isOnline()).thenReturn(true); + start(p, ALICE); + verify(p, never()).damage(anyDouble()); } @Test void loadedCharacterTagsEnableStickyTrackingWithoutAProvider() { @@ -1373,4 +1375,130 @@ void reloadRemovesLuresWhenWorldVerticalCoordinateOrPhaseChanges(String change) furniture.verify(() -> LureFurniture.remove(f)); assertSame(active, manager.get(1)); } + + private void logOff(Player player) { + end(player, CharacterSessionEndEvent.Reason.QUIT); + online.remove(player.getUniqueId()); players.remove(player); + when(player.isOnline()).thenReturn(false); + } + private void logOn(Player player, String character) { + online.put(player.getUniqueId(), player); players.add(player); + when(player.isOnline()).thenReturn(true); + start(player, character); + } + + @Test void soleCommittedPlayerPastGraceFailsTheLureButStillDiesOnReturningAsThatCharacter() throws Exception { + characters.when(Characters::enabled).thenReturn(true); + Infestation lure = lure(1, LurePhase.ACTIVE); + Player p = player(1), visitor = player(1); UUID id = p.getUniqueId(); + current(visitor, CAROL); + start(p, ALICE); call("tryJoin", p, lure); + logOff(p); + long deadline = lure.getLogoutGraceUntil().get(id); + atTick(19); tick.run(); + assertEquals(LurePhase.ACTIVE, lure.getPhase(), "A running grace holds the lure"); + verify(visitor).damage(2.0); + + lure.getLogoutGraceUntil().put(id, System.currentTimeMillis() - 1); + assertTrue(deadline > System.currentTimeMillis()); + atTick(40); tick.run(); + assertEquals(LurePhase.NONE, lure.getPhase()); + assertFalse(lure.hasLure()); assertSame(lure, manager.get(1)); + assertTrue(lure.getCommitted().isEmpty()); assertTrue(lure.getDeathOnLogin().isEmpty()); + assertEquals(Map.of(id, Set.of(ALICE)), lure.getOwedDeaths()); + furniture.verify(() -> LureFurniture.removeFor(lure)); + hologram.verify(() -> LureHologram.remove(lure)); + verify(ambient).reconcileLoaded(manager); + verify(visitor).sendMessage("lure-fail"); + atTick(59); tick.run(); + verify(visitor, times(1)).damage(anyDouble()); + + logOn(p, BOB); + verify(p, never()).damage(anyDouble()); + assertEquals(Map.of(id, Set.of(ALICE)), lure.getOwedDeaths()); + logOff(p); + logOn(p, ALICE); + verify(p).damage(40.0); + assertTrue(lure.getOwedDeaths().isEmpty()); + logOff(p); logOn(p, ALICE); + verify(p, times(1)).damage(anyDouble()); + } + + @Test void oneCommittedPlayerPastGraceLeavesTheFightWhileAnotherKeepsItAlive() throws Exception { + characters.when(Characters::enabled).thenReturn(true); + Infestation lure = lure(1, LurePhase.ACTIVE); + Player gone = player(1), fighter = player(1); UUID id = gone.getUniqueId(); + start(gone, ALICE); call("tryJoin", gone, lure); + start(fighter, BOB); call("tryJoin", fighter, lure); + logOff(gone); + lure.getLogoutGraceUntil().put(id, System.currentTimeMillis() - 1); + atTick(19); tick.run(); + assertEquals(LurePhase.ACTIVE, lure.getPhase()); + assertEquals(Set.of(id), lure.getDeathOnLogin()); + assertTrue(lure.getOwedDeaths().isEmpty()); + + // Back on another character, the departed player is still not in the fight. + logOn(gone, CAROL); + verify(gone, never()).damage(anyDouble()); + when(fighter.isDead()).thenReturn(true); + call("checkWipe", lure); + assertEquals(LurePhase.NONE, lure.getPhase()); + verify(fighter).sendMessage("lure-fail"); verify(gone).sendMessage("lure-fail"); + assertEquals(Map.of(id, Set.of(ALICE)), lure.getOwedDeaths(), "They abandoned a running fight"); + + characters.when(() -> Characters.isDead(gone, ALICE)).thenReturn(true); + manager.characterReturned(gone); + verify(gone, never()).damage(anyDouble()); + assertTrue(lure.getOwedDeaths().isEmpty(), "A character that already died owes nothing more"); + } + + @Test void returningWithinGraceKeepsTheLureWithoutPenalty() throws Exception { + characters.when(Characters::enabled).thenReturn(true); + Infestation lure = lure(1, LurePhase.ACTIVE); + Player p = player(1); UUID id = p.getUniqueId(); + start(p, ALICE); call("tryJoin", p, lure); + logOff(p); + atTick(19); tick.run(); + assertEquals(LurePhase.ACTIVE, lure.getPhase()); + logOn(p, ALICE); + assertTrue(lure.getLogoutGraceUntil().isEmpty()); + atTick(39); tick.run(); + assertEquals(LurePhase.ACTIVE, lure.getPhase()); + assertTrue(lure.isCommitted(id)); + assertTrue(lure.getDeathOnLogin().isEmpty()); assertTrue(lure.getOwedDeaths().isEmpty()); + verify(p, never()).damage(anyDouble()); + } + + @Test void owedDeathsSettleByAccountWithoutCharactersAndByTheSavedDeathWithThem() throws Exception { + Infestation lure = lure(1, LurePhase.ACTIVE); + Player p = player(1); UUID id = p.getUniqueId(); + call("tryJoin", p, lure); + PlayerQuitEvent quit = mock(PlayerQuitEvent.class); when(quit.getPlayer()).thenReturn(p); + manager.onQuit(quit); online.remove(id); when(p.isOnline()).thenReturn(false); + lure.getLogoutGraceUntil().put(id, 1L); + atTick(19); tick.run(); + assertEquals(LurePhase.NONE, lure.getPhase()); + assertEquals(Map.of(id, Set.of(Infestation.ACCOUNT)), lure.getOwedDeaths()); + online.put(id, p); when(p.isOnline()).thenReturn(true); + PlayerJoinEvent join = mock(PlayerJoinEvent.class); when(join.getPlayer()).thenReturn(p); + when(p.isDead()).thenReturn(true); + manager.onJoin(join); runLater(); + verify(p, never()).damage(anyDouble()); + assertTrue(lure.getOwedDeaths().isEmpty(), "Already dead on return settles the debt"); + lure.getOwedDeaths().put(id, new HashSet<>(Set.of(Infestation.ACCOUNT))); + call("onPlayerDeath", p); + assertTrue(lure.getOwedDeaths().isEmpty()); + + lure.getOwedDeaths().put(id, new HashSet<>(Set.of(ALICE, BOB))); + database.when(InfestationDatabase::load).thenReturn(List.of(lure)); + manager = new InfestationManager(plugin); manager.start(); + manager.characterReturned(p); + assertEquals(Set.of(ALICE, BOB), lure.getOwedDeaths().get(id), "Owed character deaths keep tracking on"); + characters.when(Characters::enabled).thenReturn(true); + characters.when(() -> Characters.isDead(any(CharacterRef.class))).thenReturn(true); + manager.onCharacterDeath(died(id, ALICE.toUpperCase(Locale.ROOT))); + assertEquals(Map.of(id, Set.of(BOB)), lure.getOwedDeaths()); + manager.onCharacterDeath(died(id, BOB)); + assertTrue(lure.getOwedDeaths().isEmpty()); + } } From 56d7be71f3c3ed68a7f501d1bb2384737f0c1822 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:27:14 +0200 Subject: [PATCH 2/3] fix: keep an owed lure death until the death is settled Returning as the owing character removed the saved debt before the kill, so damage that was cancelled, reduced or survived (totem, invulnerability) forgave the penalty for good. The kill now leaves the debt in place; only a settled death clears it, so a kill that does not land is owed again on the next return. One return kills at most once, whatever number of debts it owes. Co-Authored-By: Claude Opus 5.5 --- .../infestation/InfestationManager.java | 26 ++++++------ .../infestation/InfestationManagerTest.java | 41 ++++++++++++++++++- 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java b/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java index 51a5a33..2ba4245 100644 --- a/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java +++ b/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java @@ -1308,6 +1308,7 @@ public void characterReturned(Player player) { } String active = session == null ? null : session.ref().characterId().toString(); boolean changed = false; + boolean killed = false; for (Infestation infestation : List.copyOf(byProvince.values())) { if (!stillReturning(player, session)) { break; @@ -1340,13 +1341,21 @@ public void characterReturned(Player player) { } } else if (infestation.isCommitted(id) && !player.isDead()) { kill(player); + killed = true; } } + String owing = active == null ? Infestation.ACCOUNT : active; for (Infestation infestation : List.copyOf(byProvince.values())) { if (!stillReturning(player, session)) { break; } - changed |= settleOwedDeath(infestation, player, active); + changed |= dropDeadCharacterDebt(infestation, player, active); + // The debt stays until the death is settled, so a kill that does not land is owed again next time. + Set owed = infestation.getOwedDeaths().get(id); + if (!killed && owed != null && owed.stream().anyMatch(owing::equalsIgnoreCase) && !player.isDead()) { + kill(player); + killed = true; + } } if (changed) { save(); @@ -1358,24 +1367,15 @@ private boolean stillReturning(Player player, CharacterSession session) { && (session != null ? Characters.isCurrent(session) : !usesCharacters()); } - /** - * Kills the returning character for a death its failed lure still owes, or drops the debt once that - * character has died. Another living character's debt waits. - */ - private static boolean settleOwedDeath(Infestation infestation, Player player, String active) { + /** Drops a death owed by another character of this account once that character has died. */ + private static boolean dropDeadCharacterDebt(Infestation infestation, Player player, String active) { UUID id = player.getUniqueId(); - if (dropOwedDeath(infestation, id, active == null ? Infestation.ACCOUNT : active)) { - if (!player.isDead()) { - kill(player); - } - return true; - } Set owed = infestation.getOwedDeaths().get(id); if (owed == null || active == null) { return false; } for (String character : List.copyOf(owed)) { - if (Characters.isDead(player, character)) { + if (!character.equalsIgnoreCase(active) && Characters.isDead(player, character)) { dropOwedDeath(infestation, id, character); return true; } diff --git a/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java b/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java index 690da84..9365b94 100644 --- a/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java +++ b/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java @@ -1419,11 +1419,49 @@ private void logOn(Player player, String character) { logOff(p); logOn(p, ALICE); verify(p).damage(40.0); + assertEquals(Map.of(id, Set.of(ALICE)), lure.getOwedDeaths(), "Only a settled death pays the debt"); + manager.onDeath(deathOf(p)); assertTrue(lure.getOwedDeaths().isEmpty()); logOff(p); logOn(p, ALICE); verify(p, times(1)).damage(anyDouble()); } + private EntityDeathEvent deathOf(Player player) { + EntityDeathEvent death = mock(EntityDeathEvent.class); + when(death.getEntity()).thenReturn(player); + return death; + } + + @Test void aKillThatDoesNotLandKeepsTheOwedDeathAcrossSaveAndLoadUntilADeathSettlesIt( + @TempDir java.nio.file.Path dir) { + characters.when(Characters::enabled).thenReturn(true); + Player p = player(1); UUID id = p.getUniqueId(); + Infestation first = infestation(1), second = infestation(2); + first.getOwedDeaths().put(id, new HashSet<>(Set.of(ALICE))); + second.getOwedDeaths().put(id, new HashSet<>(Set.of(ALICE))); + mocks.remove(database); database.close(); + when(plugin.getDataFolder()).thenReturn(dir.toFile()); + + // Cancelled or reduced damage, a totem or invulnerability: the character survives the kill. + start(p, ALICE); + verify(p, times(1)).damage(40.0); + assertEquals(Map.of(id, Set.of(ALICE)), first.getOwedDeaths()); + assertEquals(Map.of(id, Set.of(ALICE)), second.getOwedDeaths()); + + manager.save(); manager.start(); + Infestation loaded = manager.get(1); + assertNotSame(first, loaded); + assertEquals(Map.of(id, Set.of(ALICE)), loaded.getOwedDeaths()); + assertEquals(Map.of(id, Set.of(ALICE)), manager.get(2).getOwedDeaths()); + + doAnswer(i -> { manager.onDeath(deathOf(p)); return null; }).when(p).damage(anyDouble()); + logOff(p); logOn(p, ALICE); + verify(p, times(2)).damage(40.0); + assertTrue(loaded.getOwedDeaths().isEmpty()); assertTrue(manager.get(2).getOwedDeaths().isEmpty()); + logOff(p); logOn(p, ALICE); + verify(p, times(2)).damage(anyDouble()); + } + @Test void oneCommittedPlayerPastGraceLeavesTheFightWhileAnotherKeepsItAlive() throws Exception { characters.when(Characters::enabled).thenReturn(true); Infestation lure = lure(1, LurePhase.ACTIVE); @@ -1484,7 +1522,8 @@ private void logOn(Player player, String character) { when(p.isDead()).thenReturn(true); manager.onJoin(join); runLater(); verify(p, never()).damage(anyDouble()); - assertTrue(lure.getOwedDeaths().isEmpty(), "Already dead on return settles the debt"); + assertEquals(Map.of(id, Set.of(Infestation.ACCOUNT)), lure.getOwedDeaths(), + "A player already dead is not killed again, and the debt waits for a settled death"); lure.getOwedDeaths().put(id, new HashSet<>(Set.of(Infestation.ACCOUNT))); call("onPlayerDeath", p); assertTrue(lure.getOwedDeaths().isEmpty()); From 83b1a253c2b562f1ef756316e52c8d0222d3d289 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:36:21 +0200 Subject: [PATCH 3/3] fix: keep a running lure's login death until the death is settled Returning as the character that owed a running lure's death removed deathOnLogin before the kill, so damage that was cancelled, reduced or survived forgave the death. The kill now leaves the debt, commitment and character in place; settleDeath clears them when the death happens, so a kill that does not land is owed again on the next return. A return kills at most once across running and failed lures, and leaving the lure after surviving moves the debt to the infestation instead of erasing it. Co-Authored-By: Claude Opus 5.5 --- .../infestation/InfestationManager.java | 33 +++++++----- .../InfestationsServicesTest.java | 5 +- .../infestation/InfestationManagerTest.java | 53 +++++++++++++++++-- 3 files changed, 73 insertions(+), 18 deletions(-) diff --git a/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java b/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java index 2ba4245..b15aad3 100644 --- a/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java +++ b/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java @@ -675,6 +675,8 @@ public boolean leaveLure(Player player) { } found = true; boolean inProvince = Provinces.at(player) == infestation.getProvinceId(); + // Leaving cannot forgive a death owed for abandoning the fight before. + keepOwedDeath(infestation, id); infestation.getCommitted().remove(id); infestation.getLogoutGraceUntil().remove(id); infestation.getDeathOnLogin().remove(id); @@ -814,12 +816,17 @@ private void failLure(Infestation infestation) { */ private void keepOwedDeaths(Infestation infestation) { for (UUID id : infestation.getDeathOnLogin()) { - if (!infestation.isCommitted(id) || isUnidentified(infestation, id)) { - continue; - } - String character = infestation.getLogoutCharacter().getOrDefault(id, Infestation.ACCOUNT); - infestation.getOwedDeaths().computeIfAbsent(id, k -> new HashSet<>()).add(character); + keepOwedDeath(infestation, id); + } + } + + private void keepOwedDeath(Infestation infestation, UUID id) { + if (!infestation.getDeathOnLogin().contains(id) || !infestation.isCommitted(id) + || isUnidentified(infestation, id)) { + return; } + String character = infestation.getLogoutCharacter().getOrDefault(id, Infestation.ACCOUNT); + infestation.getOwedDeaths().computeIfAbsent(id, k -> new HashSet<>()).add(character); } private void victory(Infestation infestation) { @@ -1296,6 +1303,8 @@ public void onJoin(PlayerJoinEvent event) { /** * Ends the logout grace of the character now active and kills it if its grace ran out. + * The kill clears nothing: only a settled death does, so a kill that does not land is owed again + * on the next return. One return kills at most once. * Another character's grace or death waits, and the death is dropped once that character has died. * Standalone servers use the account. Once identity is known, provider loss holds its debts. */ @@ -1330,16 +1339,17 @@ public void characterReturned(Player player) { 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. + if (otherCharacter || !infestation.isCommitted(id)) { + infestation.getDeathOnLogin().remove(id); + infestation.getLogoutCharacter().remove(id); + changed = true; + // That character already died, or nothing is committed, so nothing is left to pay. infestation.getCommitted().remove(id); if (infestation.getPhase() != LurePhase.NONE) { checkWipe(infestation); } - } else if (infestation.isCommitted(id) && !player.isDead()) { + } else if (!killed && !player.isDead()) { + // The debt stays until the death is settled, so a kill that does not land is owed again next time. kill(player); killed = true; } @@ -1350,7 +1360,6 @@ public void characterReturned(Player player) { break; } changed |= dropDeadCharacterDebt(infestation, player, active); - // The debt stays until the death is settled, so a kill that does not land is owed again next time. Set owed = infestation.getOwedDeaths().get(id); if (!killed && owed != null && owed.stream().anyMatch(owing::equalsIgnoreCase) && !player.isDead()) { kill(player); diff --git a/src/test/java/net/tfminecraft/infestations/InfestationsServicesTest.java b/src/test/java/net/tfminecraft/infestations/InfestationsServicesTest.java index 8bcfba3..6ff000a 100644 --- a/src/test/java/net/tfminecraft/infestations/InfestationsServicesTest.java +++ b/src/test/java/net/tfminecraft/infestations/InfestationsServicesTest.java @@ -231,9 +231,10 @@ void sharedEndRecordsTheInvalidatedOutgoingCharacterAndPreservesPriorDebt(Reason Bukkit.getPluginManager().callEvent(new CharacterSessionStartEvent(player, returned)); assertTrue(grace.getLogoutGraceUntil().isEmpty()); assertTrue(grace.getLogoutCharacter().isEmpty()); - assertTrue(debt.getDeathOnLogin().isEmpty()); - assertTrue(debt.getLogoutCharacter().isEmpty()); verify(player).damage(40.0); + // The kill did not land here, so the debt waits for a settled death. + assertEquals(Set.of(owner), debt.getDeathOnLogin()); + assertEquals(Map.of(owner, alice.toString()), debt.getLogoutCharacter()); } } @Test void unregisteringTheProviderHoldsTaggedPenaltiesInsteadOfUsingTheAccount() { diff --git a/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java b/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java index 9365b94..867f7e8 100644 --- a/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java +++ b/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java @@ -871,7 +871,7 @@ void waterAndSeaSpreadLogTheirRoutes(boolean sea) throws Exception { current(p, ALICE); manager.characterReturned(p); verify(p).damage(40.0); - assertTrue(state.getDeathOnLogin().isEmpty()); assertTrue(state.getLogoutCharacter().isEmpty()); + assertEquals(Set.of(id), state.getDeathOnLogin()); assertEquals(ALICE, state.getLogoutCharacter().get(id)); state.getDeathOnLogin().add(id); state.getLogoutCharacter().put(id, ALICE); current(p, BOB); @@ -1037,7 +1037,7 @@ void waterAndSeaSpreadLogTheirRoutes(boolean sea) throws Exception { }).when(p).damage(anyDouble()); manager.characterReturned(p); verify(p, times(1)).damage(40.0); - assertEquals(1, first.getDeathOnLogin().size() + second.getDeathOnLogin().size()); + assertEquals(2, first.getDeathOnLogin().size() + second.getDeathOnLogin().size()); } @Test void pendingCharactersCannotPlaceLuresOrReceiveNonjoinerDamage() throws Exception { @@ -1168,8 +1168,8 @@ private static CharacterDeathEvent died(UUID owner, String character) { doAnswer(i -> { current(p, BOB); current(p, ALICE); return null; }).when(p).damage(anyDouble()); manager.characterReturned(p); verify(p, times(1)).damage(40.0); - assertEquals(1, first.getDeathOnLogin().size() + second.getDeathOnLogin().size()); - assertEquals(1, first.getLogoutCharacter().size() + second.getLogoutCharacter().size()); + assertEquals(2, first.getDeathOnLogin().size() + second.getDeathOnLogin().size()); + assertEquals(2, first.getLogoutCharacter().size() + second.getLogoutCharacter().size()); } @Test void standaloneJoinCallbackCannotFollowReconnectOrProviderArrival() { @@ -1540,4 +1540,49 @@ private EntityDeathEvent deathOf(Player player) { manager.onCharacterDeath(died(id, BOB)); assertTrue(lure.getOwedDeaths().isEmpty()); } + + @Test void aRunningLureKillThatDoesNotLandIsOwedAgainUntilADeathSettlesIt() { + characters.when(Characters::enabled).thenReturn(true); + Player p = player(1), fighter = player(1); UUID id = p.getUniqueId(); + start(fighter, BOB); + Infestation first = lure(1, LurePhase.ACTIVE), second = lure(2, LurePhase.ACTIVE); + for (Infestation lure : List.of(first, second)) { + lure.getCommitted().addAll(List.of(id, fighter.getUniqueId())); + lure.getLogoutCharacter().put(id, ALICE); lure.getDeathOnLogin().add(id); + } + + // Cancelled or reduced damage, a totem or invulnerability: the character survives the kill. + start(p, ALICE); + verify(p, times(1)).damage(40.0); + for (Infestation lure : List.of(first, second)) { + assertEquals(Set.of(id), lure.getDeathOnLogin()); assertEquals(Map.of(id, ALICE), lure.getLogoutCharacter()); + assertTrue(lure.isCommitted(id)); assertEquals(LurePhase.ACTIVE, lure.getPhase()); + } + logOff(p); logOn(p, ALICE); + verify(p, times(2)).damage(40.0); + + doAnswer(i -> { manager.onDeath(deathOf(p)); return null; }).when(p).damage(anyDouble()); + logOff(p); logOn(p, ALICE); + verify(p, times(3)).damage(40.0); + for (Infestation lure : List.of(first, second)) { + assertTrue(lure.getDeathOnLogin().isEmpty()); assertTrue(lure.getLogoutCharacter().isEmpty()); + assertFalse(lure.isCommitted(id)); assertEquals(LurePhase.ACTIVE, lure.getPhase()); + } + logOff(p); logOn(p, ALICE); + verify(p, times(3)).damage(anyDouble()); + } + + @Test void leavingTheLureAfterSurvivingTheKillStillOwesTheDeath() { + characters.when(Characters::enabled).thenReturn(true); + Player p = player(1); UUID id = p.getUniqueId(); + Infestation lure = lure(1, LurePhase.ACTIVE); + lure.getCommitted().add(id); lure.getLogoutCharacter().put(id, ALICE); lure.getDeathOnLogin().add(id); + start(p, ALICE); + verify(p).damage(40.0); + assertTrue(manager.leaveLure(p)); + assertEquals(LurePhase.NONE, lure.getPhase()); + assertEquals(Map.of(id, Set.of(ALICE)), lure.getOwedDeaths()); + logOff(p); logOn(p, ALICE); + verify(p, times(2)).damage(40.0); + } }