From 1097f9389686ad500ae6a2e1e2a1610d67b94483 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 06:39:43 +0000 Subject: [PATCH 1/2] fix: a server restart no longer starts lure logout grace Shutdown recorded every committed player as departing, so anyone not back within logout-grace-seconds of the stop owed a death. A restart is now ignored again: the empty lure fails without penalty, as before 0.3.0. Co-Authored-By: Claude Opus 5.5 --- .../infestation/InfestationManager.java | 12 ++++-------- .../InfestationsServicesTest.java | 17 ++++++++++++++++- .../infestation/InfestationManagerTest.java | 19 ++++++++++--------- 3 files changed, 30 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 b030ec8..8098326 100644 --- a/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java +++ b/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java @@ -117,14 +117,6 @@ public void start() { } public void shutdown() { - if (usesCharacters()) { - for (Player player : Bukkit.getOnlinePlayers()) { - CharacterSession session = Characters.session(player); - if (session != null) { - recordDeparture(player.getUniqueId(), session.ref().characterId().toString()); - } - } - } if (task != null) { task.cancel(); task = null; @@ -1207,6 +1199,10 @@ private void settleDeath(UUID id, String character) { public void onCharacterEnd(CharacterSessionEndEvent event) { characterTracking = true; CharacterSession session = event.getSession(); + if (event.getReason() == CharacterSessionEndEvent.Reason.SHUTDOWN) { + // A restart is not a departure: with nobody back, the lure fails without penalty. + return; + } if (event.getReason() == CharacterSessionEndEvent.Reason.DEATH) { settleDeath(session.ref().ownerId(), session.ref().characterId().toString()); } else { diff --git a/src/test/java/net/tfminecraft/infestations/InfestationsServicesTest.java b/src/test/java/net/tfminecraft/infestations/InfestationsServicesTest.java index f721e56..0b075e8 100644 --- a/src/test/java/net/tfminecraft/infestations/InfestationsServicesTest.java +++ b/src/test/java/net/tfminecraft/infestations/InfestationsServicesTest.java @@ -111,7 +111,7 @@ class InfestationsServicesTest { } } @ParameterizedTest - @EnumSource(value = Reason.class, names = {"SWITCH", "QUIT", "SHUTDOWN"}) + @EnumSource(value = Reason.class, names = {"SWITCH", "QUIT"}) void sharedEndRecordsTheInvalidatedOutgoingCharacterAndPreservesPriorDebt(Reason reason) { Player player = MockBukkit.getMock().addPlayer(); try (CharacterProvider provider = new CharacterProvider(); @@ -139,6 +139,21 @@ void sharedEndRecordsTheInvalidatedOutgoingCharacterAndPreservesPriorDebt(Reason assertEquals(deadline, current.getLogoutGraceUntil().get(owner)); } } + @Test void sharedShutdownEndRecordsNoDeparture() { + Player player = MockBukkit.getMock().addPlayer(); + try (CharacterProvider provider = new CharacterProvider(); + var database = mockStatic(InfestationDatabase.class); + var export = mockStatic(InfestationMapExport.class)) { + InfestationManager manager = registeredManager(provider); + Infestation lure = commitment(manager, 1, player); + CharacterSession outgoing = provider.activate(player, UUID.randomUUID()); + provider.invalidate(player); + Bukkit.getPluginManager().callEvent(new CharacterSessionEndEvent(player, outgoing, Reason.SHUTDOWN)); + assertTrue(lure.isCommitted(player.getUniqueId())); + assertTrue(lure.getLogoutCharacter().isEmpty()); + assertTrue(lure.getLogoutGraceUntil().isEmpty()); + } + } @Test void sharedDeathSettlesOnlyTheOutgoingCharactersCommitmentsAfterInvalidation() { Player player = MockBukkit.getMock().addPlayer(); try (CharacterProvider provider = new CharacterProvider(); diff --git a/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java b/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java index 555d40b..a54e9aa 100644 --- a/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java +++ b/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java @@ -1094,18 +1094,19 @@ void waterAndSeaSpreadLogTheirRoutes(boolean sea) throws Exception { assertEquals(LurePhase.NONE, lure.getPhase()); } - @Test void shutdownRecordsReadyCommitmentsBeforeProviderDisablesWithoutExtendingGrace() { - Player p = player(1); UUID id = p.getUniqueId(); player(2); + @Test void restartIsNotADepartureSoTheEmptyLureFailsWithoutPenalty() { + Player p = player(1); UUID id = p.getUniqueId(); Infestation lure = lure(1, LurePhase.ACTIVE); lure.getCommitted().add(id); characters.when(Characters::enabled).thenReturn(true); current(p, ALICE); manager.shutdown(); - assertEquals(ALICE, lure.getLogoutCharacter().get(id)); - long deadline = lure.getLogoutGraceUntil().get(id); - manager.shutdown(); - assertEquals(deadline, lure.getLogoutGraceUntil().get(id)); - characters.when(Characters::enabled).thenReturn(false); - manager.characterReturned(p); - assertEquals(ALICE, lure.getLogoutCharacter().get(id)); + end(p, CharacterSessionEndEvent.Reason.SHUTDOWN); + assertTrue(lure.getLogoutGraceUntil().isEmpty()); + assertTrue(lure.getLogoutCharacter().isEmpty()); + assertTrue(lure.getDeathOnLogin().isEmpty()); + online.remove(id); when(p.isOnline()).thenReturn(false); + call("checkWipe", lure); + assertFalse(lure.isCommitted(id)); + assertEquals(LurePhase.NONE, lure.getPhase()); } @Test void loadedCharacterTagsEnableStickyTrackingWithoutAProvider() { From 6086602fcc58efaf1dce8aadc207efb34746979e Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 06:50:22 +0000 Subject: [PATCH 2/2] fix: settle a lure on the saved character death, not the session end The DEATH session end fires before RPCharacters saves the death, so a failed save left a living character released from its lure. Lures now settle on the TLibs CharacterDeathEvent, only for the character the commitment belongs to and only while the provider still records that character as dead, so a repeat or a replay after a revival changes nothing. The DEATH end still starts no logout grace, and an ordinary death settles as before. character-required now uses the same hex red as the other errors, and its built-in fallback for older messages.yml files carries the prefix. Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/infestations/Messages.java | 5 +++ .../infestation/InfestationManager.java | 34 +++++++++++------ .../infestations/utils/Characters.java | 10 ++++- src/main/resources/messages.yml | 3 +- .../InfestationsServicesTest.java | 24 +++++++++++- .../infestation/InfestationManagerTest.java | 38 +++++++++++++++++-- 6 files changed, 93 insertions(+), 21 deletions(-) diff --git a/src/main/java/net/tfminecraft/infestations/Messages.java b/src/main/java/net/tfminecraft/infestations/Messages.java index 1b39ad4..e7dcc29 100644 --- a/src/main/java/net/tfminecraft/infestations/Messages.java +++ b/src/main/java/net/tfminecraft/infestations/Messages.java @@ -42,6 +42,11 @@ public static String get(String path, String... keyValues) { return format(raw); } + /** For keys that a messages.yml from an older release may lack. */ + public static String getOrDefault(String path, String fallback) { + return format(applyPrefix(config != null ? config.getString(path, fallback) : fallback)); + } + public static String getRaw(String path) { if (config == null) { return path; diff --git a/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java b/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java index 8098326..da25521 100644 --- a/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java +++ b/src/main/java/net/tfminecraft/infestations/infestation/InfestationManager.java @@ -55,6 +55,8 @@ import net.tfminecraft.simplefactions.events.PlayerProvinceLeaveEvent; import net.tfminecraft.tlibs.TLibs; import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.CharacterDeathEvent; +import net.tfminecraft.tlibs.character.CharacterRef; import net.tfminecraft.tlibs.character.CharacterSessionEndEvent; import net.tfminecraft.tlibs.character.CharacterSessionStartEvent; import net.tfminecraft.infestations.Infestations; @@ -881,9 +883,8 @@ private int highlightLureMobs(Infestation infestation) { private boolean readyToJoin(Player player) { if (usesCharacters() && Characters.session(player) == null) { - String message = Messages.get("character-required"); - player.sendMessage("character-required".equals(message) - ? "Your character must be ready before joining a lure." : message); + player.sendMessage(Messages.getOrDefault("character-required", + "{prefix}#c45c5cYour character must be ready before joining a lure.")); return false; } return true; @@ -1179,8 +1180,11 @@ private void onPlayerDeath(Player player) { } 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())) { - String owner = infestation.getLogoutCharacter().get(id); + // 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))) { continue; } @@ -1199,15 +1203,23 @@ private void settleDeath(UUID id, String character) { public void onCharacterEnd(CharacterSessionEndEvent event) { characterTracking = true; CharacterSession session = event.getSession(); - if (event.getReason() == CharacterSessionEndEvent.Reason.SHUTDOWN) { - // A restart is not a departure: with nobody back, the lure fails without penalty. + // A restart is not a departure: with nobody back, the lure fails without penalty. + // A death settles through onCharacterDeath once RPCharacters has saved it. + if (event.getReason() == CharacterSessionEndEvent.Reason.SHUTDOWN + || event.getReason() == CharacterSessionEndEvent.Reason.DEATH) { return; } - if (event.getReason() == CharacterSessionEndEvent.Reason.DEATH) { - settleDeath(session.ref().ownerId(), session.ref().characterId().toString()); - } else { - recordDeparture(session.ref().ownerId(), session.ref().characterId().toString()); - save(); + recordDeparture(session.ref().ownerId(), session.ref().characterId().toString()); + save(); + } + + /** Settles the dead character's commitments. A repeat, or a replay after a revival, changes nothing. */ + @EventHandler + public void onCharacterDeath(CharacterDeathEvent event) { + characterTracking = true; + CharacterRef character = event.getCharacter(); + if (Characters.isDead(character)) { + settleDeath(character.ownerId(), character.characterId().toString()); } } diff --git a/src/main/java/net/tfminecraft/infestations/utils/Characters.java b/src/main/java/net/tfminecraft/infestations/utils/Characters.java index 78ba44e..c2d365d 100644 --- a/src/main/java/net/tfminecraft/infestations/utils/Characters.java +++ b/src/main/java/net/tfminecraft/infestations/utils/Characters.java @@ -4,6 +4,7 @@ import org.bukkit.entity.Player; +import net.tfminecraft.tlibs.character.CharacterRef; import net.tfminecraft.tlibs.character.CharacterSession; /** Shared identity lookups; unavailable and not ready are distinct from standalone play. */ @@ -39,8 +40,13 @@ public static boolean isDead(Player player, String characterId) { if (!id.toString().equalsIgnoreCase(characterId)) { return false; } - return net.tfminecraft.tlibs.character.Characters.info(id) - .filter(info -> info.ref().ownerId().equals(player.getUniqueId())) + return isDead(new CharacterRef(player.getUniqueId(), id)); + } + + /** Whether the provider still records this character of this account as not alive. */ + public static boolean isDead(CharacterRef character) { + return net.tfminecraft.tlibs.character.Characters.info(character.characterId()) + .filter(info -> info.ref().ownerId().equals(character.ownerId())) .map(info -> !info.alive()).orElse(false); } } diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index c3a31f4..426e501 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -36,5 +36,4 @@ lure-victory: "{prefix}#7dba7aThe infestation is cleared." lure-fail: "{prefix}#c45c5cThe lure failed. The infestation remains." hologram-joining: "#c9a44aLure - join {seconds}s" hologram-remaining: "#c45c5cRemaining: #f0e6d0{remaining}" - -character-required: "{prefix}&cYour character must be ready before joining a lure." +character-required: "{prefix}#c45c5cYour character must be ready before joining a lure." diff --git a/src/test/java/net/tfminecraft/infestations/InfestationsServicesTest.java b/src/test/java/net/tfminecraft/infestations/InfestationsServicesTest.java index 0b075e8..8bcfba3 100644 --- a/src/test/java/net/tfminecraft/infestations/InfestationsServicesTest.java +++ b/src/test/java/net/tfminecraft/infestations/InfestationsServicesTest.java @@ -9,6 +9,7 @@ import net.tfminecraft.infestations.spawn.SpawnLog; import net.tfminecraft.infestations.utils.Characters; import net.tfminecraft.infestations.utils.Provinces; +import net.tfminecraft.tlibs.character.CharacterDeathEvent; import net.tfminecraft.tlibs.character.CharacterIdentity; import net.tfminecraft.tlibs.character.CharacterInfo; import net.tfminecraft.tlibs.character.CharacterRef; @@ -60,10 +61,13 @@ class InfestationsServicesTest { assertEquals("missing", Messages.getRaw("missing")); assertEquals("hello", Messages.get("hello")); assertEquals("", Messages.get(null)); + assertEquals("§cfallback", Messages.getOrDefault("missing", "{prefix}&cfallback")); Path file = Files.writeString(directory.resolve("messages.yml"), "prefix: '&a[Infestations] '\ngreet: '{prefix}Hello {who}{blank}'\n"); Messages.load(file.toFile()); assertEquals("§a[Infestations] Hello Ryan", Messages.get("greet", "who", "Ryan", "blank", null, "unpaired")); assertEquals("missing", Messages.getRaw("missing")); + assertEquals("§a[Infestations] fallback", Messages.getOrDefault("missing", "{prefix}fallback")); + assertEquals("§a[Infestations] Hello {who}{blank}", Messages.getOrDefault("greet", "fallback")); Files.writeString(file, "invalid: [bad"); Messages.load(file.toFile()); assertEquals("missing", Messages.get("missing")); Messages.load(directory.resolve("absent").toFile()); @@ -154,7 +158,7 @@ void sharedEndRecordsTheInvalidatedOutgoingCharacterAndPreservesPriorDebt(Reason assertTrue(lure.getLogoutGraceUntil().isEmpty()); } } - @Test void sharedDeathSettlesOnlyTheOutgoingCharactersCommitmentsAfterInvalidation() { + @Test void sharedDeathSettlesOnlyOnceSavedAndOnlyTheDeadCharactersCommitments() { Player player = MockBukkit.getMock().addPlayer(); try (CharacterProvider provider = new CharacterProvider(); var database = mockStatic(InfestationDatabase.class); @@ -172,8 +176,17 @@ void sharedEndRecordsTheInvalidatedOutgoingCharacterAndPreservesPriorDebt(Reason other.getLogoutCharacter().put(owner, bob.toString()); CharacterSession outgoing = provider.activate(player, alice); provider.invalidate(player); - assertFalse(Characters.isCurrent(outgoing)); + // The session ends before RPCharacters saves the death; a failed save leaves the character alive. Bukkit.getPluginManager().callEvent(new CharacterSessionEndEvent(player, outgoing, Reason.DEATH)); + for (Infestation infestation : List.of(matching, ongoing, other)) { + assertTrue(infestation.isCommitted(owner)); + } + assertTrue(ongoing.getLogoutGraceUntil().isEmpty()); + + provider.describe(owner, alice, CharacterStatus.DEAD); + CharacterDeathEvent death = new CharacterDeathEvent(new CharacterRef(owner, alice), UUID.randomUUID(), + java.time.Instant.now(), null, null, "LURE"); + Bukkit.getPluginManager().callEvent(death); for (Infestation infestation : List.of(matching, ongoing)) { assertFalse(infestation.isCommitted(owner)); assertTrue(infestation.getLogoutCharacter().isEmpty()); @@ -184,6 +197,13 @@ void sharedEndRecordsTheInvalidatedOutgoingCharacterAndPreservesPriorDebt(Reason assertEquals(Map.of(owner, bob.toString()), other.getLogoutCharacter()); assertEquals(Map.of(owner, 1234L), other.getLogoutGraceUntil()); assertEquals(Set.of(owner), other.getDeathOnLogin()); + + // A replay after the character is revived and joins again settles nothing. + provider.describe(owner, alice, CharacterStatus.ALIVE); + provider.activate(player, alice); + matching.getCommitted().add(owner); + Bukkit.getPluginManager().callEvent(death); + assertTrue(matching.isCommitted(owner)); } } @Test void sharedStartAppliesOnlyTheCurrentCharactersGraceAndDeath() { diff --git a/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java b/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java index a54e9aa..5fe6ce7 100644 --- a/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java +++ b/src/test/java/net/tfminecraft/infestations/infestation/InfestationManagerTest.java @@ -54,6 +54,7 @@ import net.tfminecraft.simplefactions.events.PlayerProvinceEnterEvent; import net.tfminecraft.simplefactions.events.PlayerProvinceLeaveEvent; import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.character.CharacterDeathEvent; import net.tfminecraft.tlibs.character.CharacterRef; import net.tfminecraft.tlibs.character.CharacterSession; import net.tfminecraft.tlibs.character.CharacterSessionEndEvent; @@ -1059,7 +1060,7 @@ void waterAndSeaSpreadLogTheirRoutes(boolean sea) throws Exception { characters.when(Characters::enabled).thenReturn(true); call("tryJoin", p, lure); assertFalse(lure.isCommitted(id)); - verify(p).sendMessage("Your character must be ready before joining a lure."); + verify(p).sendMessage("character-required"); start(p, ALICE); call("tryJoin", p, lure); assertTrue(lure.isCommitted(id)); CharacterSession old = sessions.get(id); @@ -1081,17 +1082,46 @@ void waterAndSeaSpreadLogTheirRoutes(boolean sea) throws Exception { assertTrue(manager.leaveLure(p)); } - @Test void deathEndUsesInvalidatedOutgoingTokenAndDoesNotSettleAnotherCharacter() { + @Test void onlyTheSavedDeathOfTheDepartedCharacterSettlesItsLure() { Player p = player(1); UUID id = p.getUniqueId(); Infestation lure = lure(1, LurePhase.ACTIVE); lure.getCommitted().add(id); lure.getLogoutCharacter().put(id, ALICE.toUpperCase(Locale.ROOT)); lure.getDeathOnLogin().add(id); characters.when(Characters::enabled).thenReturn(true); - current(p, BOB); end(p, CharacterSessionEndEvent.Reason.DEATH); - assertTrue(lure.isCommitted(id)); current(p, ALICE); end(p, CharacterSessionEndEvent.Reason.DEATH); + assertTrue(lure.isCommitted(id)); assertTrue(lure.getDeathOnLogin().contains(id)); + + characters.when(() -> Characters.isDead(any(CharacterRef.class))).thenReturn(true); + manager.onCharacterDeath(died(id, BOB)); + assertTrue(lure.isCommitted(id)); + characters.when(() -> Characters.isDead(any(CharacterRef.class))).thenReturn(false); + manager.onCharacterDeath(died(id, ALICE)); + assertTrue(lure.isCommitted(id)); + + characters.when(() -> Characters.isDead(any(CharacterRef.class))).thenReturn(true); + manager.onCharacterDeath(died(id, ALICE)); assertFalse(lure.isCommitted(id)); assertTrue(lure.getDeathOnLogin().isEmpty()); assertEquals(LurePhase.NONE, lure.getPhase()); + assertDoesNotThrow(() -> manager.onCharacterDeath(died(id, ALICE))); + } + + @Test void withoutADepartureOnlyTheCurrentOrJustEndedCharactersDeathSettles() { + Player p = player(1); UUID id = p.getUniqueId(); + Infestation lure = lure(1, LurePhase.ACTIVE); lure.getCommitted().add(id); + characters.when(Characters::enabled).thenReturn(true); + characters.when(() -> Characters.isDead(any(CharacterRef.class))).thenReturn(true); + current(p, BOB); + manager.onCharacterDeath(died(id, ALICE)); + assertTrue(lure.isCommitted(id)); + end(p, CharacterSessionEndEvent.Reason.DEATH); + assertTrue(lure.isCommitted(id)); assertTrue(lure.getLogoutGraceUntil().isEmpty()); + manager.onCharacterDeath(died(id, BOB)); + assertFalse(lure.isCommitted(id)); + } + + private static CharacterDeathEvent died(UUID owner, String character) { + return new CharacterDeathEvent(new CharacterRef(owner, UUID.fromString(character)), UUID.randomUUID(), + java.time.Instant.now(), null, null, "LURE"); } @Test void restartIsNotADepartureSoTheEmptyLureFailsWithoutPenalty() {