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 b030ec8..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; @@ -117,14 +119,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; @@ -889,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; @@ -1187,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; } @@ -1207,11 +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.DEATH) { - settleDeath(session.ref().ownerId(), session.ref().characterId().toString()); - } else { - recordDeparture(session.ref().ownerId(), session.ref().characterId().toString()); - save(); + // 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; + } + 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 f721e56..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()); @@ -111,7 +115,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,7 +143,22 @@ void sharedEndRecordsTheInvalidatedOutgoingCharacterAndPreservesPriorDebt(Reason assertEquals(deadline, current.getLogoutGraceUntil().get(owner)); } } - @Test void sharedDeathSettlesOnlyTheOutgoingCharactersCommitmentsAfterInvalidation() { + @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 sharedDeathSettlesOnlyOnceSavedAndOnlyTheDeadCharactersCommitments() { Player player = MockBukkit.getMock().addPlayer(); try (CharacterProvider provider = new CharacterProvider(); var database = mockStatic(InfestationDatabase.class); @@ -157,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()); @@ -169,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 555d40b..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,31 +1082,61 @@ 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 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() {