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);