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