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..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); @@ -710,7 +712,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 +767,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 +795,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 +810,25 @@ 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()) { + 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) { Location loc = infestation.lureLocation(); int provinceId = infestation.getProvinceId(); @@ -1183,6 +1205,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 +1222,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; @@ -1269,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. */ @@ -1281,9 +1317,9 @@ 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 (!player.isOnline() || Bukkit.getPlayer(id) != player - || (session != null ? !Characters.isCurrent(session) : usesCharacters())) { + if (!stillReturning(player, session)) { break; } String target = infestation.getLogoutCharacter().get(id); @@ -1303,17 +1339,31 @@ 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()) { - player.damage(Math.max(40.0, player.getHealth() + 10.0)); + } 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; + } + } + String owing = active == null ? Infestation.ACCOUNT : active; + for (Infestation infestation : List.copyOf(byProvince.values())) { + if (!stillReturning(player, session)) { + break; + } + changed |= dropDeadCharacterDebt(infestation, player, active); + Set owed = infestation.getOwedDeaths().get(id); + if (!killed && owed != null && owed.stream().anyMatch(owing::equalsIgnoreCase) && !player.isDead()) { + kill(player); + killed = true; } } if (changed) { @@ -1321,6 +1371,31 @@ public void characterReturned(Player player) { } } + private boolean stillReturning(Player player, CharacterSession session) { + return player.isOnline() && Bukkit.getPlayer(player.getUniqueId()) == player + && (session != null ? Characters.isCurrent(session) : !usesCharacters()); + } + + /** 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(); + Set owed = infestation.getOwedDeaths().get(id); + if (owed == null || active == null) { + return false; + } + for (String character : List.copyOf(owed)) { + if (!character.equalsIgnoreCase(active) && 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/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/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..867f7e8 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); @@ -872,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); @@ -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() { @@ -1039,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 { @@ -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() { @@ -1166,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() { @@ -1373,4 +1375,214 @@ 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); + 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); + 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()); + 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()); + + 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()); + } + + @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); + } }