Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ commits to the fight, and defeats every monster the lure draws in.
- **Committed parties** — a committed player who leaves the province takes
damage until they opt out of the fight. Losing the whole party
fails the lure, bystanders who stay are warned and damaged, and logged-out
players get a grace period.
players get a grace period. A player still away when it ends no longer holds
the lure, and that character dies when it next logs in.
- **Optional spread** — idle infestations can worsen and spread to neighbouring
land, including across a single water or sea province.
- **Map and persistence** — infestation severity is exported to the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@ public class InfestationData {
public Map<String, Long> logoutGraceUntil = new HashMap<>();
public List<String> deathOnLogin = new ArrayList<>();
public Map<String, String> logoutCharacter = new HashMap<>();
public Map<String, List<String>> owedDeaths = new HashMap<>();
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;

import com.google.gson.Gson;
Expand Down Expand Up @@ -137,6 +139,19 @@ private static Infestation fromData(InfestationData row) {
}
}
}
if (row.owedDeaths != null) {
for (Map.Entry<String, List<String>> e : row.owedDeaths.entrySet()) {
if (e.getKey() == null || e.getValue() == null) continue;
try {
UUID id = UUID.fromString(e.getKey());
for (String character : e.getValue()) {
if (character == null) continue;
infestation.getOwedDeaths().computeIfAbsent(id, k -> new HashSet<>()).add(character);
}
} catch (IllegalArgumentException ignored) {
}
}
}
return infestation;
}

Expand Down Expand Up @@ -169,6 +184,9 @@ private static InfestationData toData(Infestation infestation) {
for (Map.Entry<UUID, String> e : infestation.getLogoutCharacter().entrySet()) {
row.logoutCharacter.put(e.getKey().toString(), e.getValue());
}
for (Map.Entry<UUID, Set<String>> e : infestation.getOwedDeaths().entrySet()) {
row.owedDeaths.put(e.getKey().toString(), new ArrayList<>(e.getValue()));
}
return row;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@

public final class Infestation {

/** The owed-death entry of an account on a server without characters. */
public static final String ACCOUNT = "";

private final int provinceId;
private String groupId;
private Severity severity;
Expand Down Expand Up @@ -41,6 +44,11 @@ public long getLureGeneration() {
private final Set<UUID> deathOnLogin = new HashSet<>();
/** The character each committed player logged off as, so a death on login reaches only that character. */
private final Map<UUID, String> logoutCharacter = new HashMap<>();
/**
* Deaths still owed after their lure ended, by account: the characters that abandoned a running fight,
* or {@link #ACCOUNT} on servers without characters. They outlive lures until settled; clearing the infestation forgives them.
*/
private final Map<UUID, Set<String>> owedDeaths = new HashMap<>();

public Infestation(int provinceId, String groupId, Severity severity) {
this.provinceId = provinceId;
Expand Down Expand Up @@ -300,6 +308,10 @@ public Map<UUID, String> getLogoutCharacter() {
return logoutCharacter;
}

public Map<UUID, Set<String>> getOwedDeaths() {
return owedDeaths;
}

public boolean isCommitted(UUID id) {
return committed.contains(id);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,8 @@ public boolean leaveLure(Player player) {
}
found = true;
boolean inProvince = Provinces.at(player) == infestation.getProvinceId();
// Leaving cannot forgive a death owed for abandoning the fight before.
keepOwedDeath(infestation, id);
infestation.getCommitted().remove(id);
infestation.getLogoutGraceUntil().remove(id);
infestation.getDeathOnLogin().remove(id);
Expand Down Expand Up @@ -710,7 +712,9 @@ private void expireGrace(Infestation infestation, long now) {
private boolean usesCharacters() {
if (!characterTracking) {
characterTracking = Characters.enabled() || byProvince.values().stream()
.anyMatch(infestation -> !infestation.getLogoutCharacter().isEmpty());
.anyMatch(infestation -> !infestation.getLogoutCharacter().isEmpty()
|| infestation.getOwedDeaths().values().stream()
.anyMatch(owed -> owed.stream().anyMatch(c -> !Infestation.ACCOUNT.equals(c))));
}
return characterTracking;
}
Expand Down Expand Up @@ -763,18 +767,16 @@ private void checkWipe(Infestation infestation) {
failLure(infestation);
}

/** Held debts and their commitments are left out, since no character can return to settle them. */
/**
* A lure holds while a committed player is present or still inside their logout grace. Once that grace
* runs out the player has left the fight: the death they owe waits for their return, not the lure.
*/
private boolean hasLivingOrGrace(Infestation infestation) {
if (!infestation.getLogoutGraceUntil().isEmpty()) {
return true;
}
for (UUID id : infestation.getDeathOnLogin()) {
if (!isUnidentified(infestation, id)) {
return true;
}
}
for (UUID id : infestation.getCommitted()) {
if (isHeldDebt(infestation, id)) {
if (infestation.getDeathOnLogin().contains(id)) {
continue;
}
Player player = Bukkit.getPlayer(id);
Expand All @@ -793,6 +795,7 @@ private void failLure(Infestation infestation) {
Set<UUID> committed = new HashSet<>(infestation.getCommitted());
int provinceId = infestation.getProvinceId();
Location loc = infestation.lureLocation();
keepOwedDeaths(infestation);
destroyLure(infestation, false);
infestation.clearLure();
ambient.reconcileLoaded(this);
Expand All @@ -807,6 +810,25 @@ private void failLure(Infestation infestation) {
}
}

/**
* A failed lure still owes the deaths of those who abandoned it while it ran, so they move to the
* infestation and outlive the lure. Held debts have no character to reach and end with it.
*/
private void keepOwedDeaths(Infestation infestation) {
for (UUID id : infestation.getDeathOnLogin()) {
keepOwedDeath(infestation, id);
}
}

private void keepOwedDeath(Infestation infestation, UUID id) {
if (!infestation.getDeathOnLogin().contains(id) || !infestation.isCommitted(id)
|| isUnidentified(infestation, id)) {
return;
}
String character = infestation.getLogoutCharacter().getOrDefault(id, Infestation.ACCOUNT);
infestation.getOwedDeaths().computeIfAbsent(id, k -> new HashSet<>()).add(character);
}

private void victory(Infestation infestation) {
Location loc = infestation.lureLocation();
int provinceId = infestation.getProvinceId();
Expand Down Expand Up @@ -1183,6 +1205,7 @@ private void settleDeath(UUID id, String character) {
Player online = Bukkit.getPlayer(id);
String current = online == null ? null : Characters.activeId(online);
for (Infestation infestation : List.copyOf(byProvince.values())) {
dropOwedDeath(infestation, id, character == null ? Infestation.ACCOUNT : character);
// Without a recorded departure, the commitment belongs to the current character, if any.
String owner = infestation.getLogoutCharacter().getOrDefault(id, current);
if (!infestation.isCommitted(id) || (owner != null && !owner.equalsIgnoreCase(character))) {
Expand All @@ -1199,6 +1222,17 @@ private void settleDeath(UUID id, String character) {
save();
}

private static boolean dropOwedDeath(Infestation infestation, UUID id, String character) {
Set<String> owed = infestation.getOwedDeaths().get(id);
if (owed == null || !owed.removeIf(character::equalsIgnoreCase)) {
return false;
}
if (owed.isEmpty()) {
infestation.getOwedDeaths().remove(id);
}
return true;
}

@EventHandler(priority = EventPriority.LOWEST)
public void onCharacterEnd(CharacterSessionEndEvent event) {
characterTracking = true;
Expand Down Expand Up @@ -1269,6 +1303,8 @@ public void onJoin(PlayerJoinEvent event) {

/**
* Ends the logout grace of the character now active and kills it if its grace ran out.
* The kill clears nothing: only a settled death does, so a kill that does not land is owed again
* on the next return. One return kills at most once.
* Another character's grace or death waits, and the death is dropped once that character has died.
* Standalone servers use the account. Once identity is known, provider loss holds its debts.
*/
Expand All @@ -1281,9 +1317,9 @@ public void characterReturned(Player player) {
}
String active = session == null ? null : session.ref().characterId().toString();
boolean changed = false;
boolean killed = false;
for (Infestation infestation : List.copyOf(byProvince.values())) {
if (!player.isOnline() || Bukkit.getPlayer(id) != player
|| (session != null ? !Characters.isCurrent(session) : usesCharacters())) {
if (!stillReturning(player, session)) {
break;
}
String target = infestation.getLogoutCharacter().get(id);
Expand All @@ -1303,24 +1339,63 @@ public void characterReturned(Player player) {
if (otherCharacter && !Characters.isDead(player, target)) {
continue;
}
infestation.getDeathOnLogin().remove(id);
infestation.getLogoutCharacter().remove(id);
changed = true;
if (otherCharacter) {
// That character already died, which settles what the lure owed.
if (otherCharacter || !infestation.isCommitted(id)) {
infestation.getDeathOnLogin().remove(id);
infestation.getLogoutCharacter().remove(id);
changed = true;
// That character already died, or nothing is committed, so nothing is left to pay.
infestation.getCommitted().remove(id);
if (infestation.getPhase() != LurePhase.NONE) {
checkWipe(infestation);
}
} else if (infestation.isCommitted(id) && !player.isDead()) {
player.damage(Math.max(40.0, player.getHealth() + 10.0));
} else if (!killed && !player.isDead()) {
// The debt stays until the death is settled, so a kill that does not land is owed again next time.
kill(player);
killed = true;
}
}
String owing = active == null ? Infestation.ACCOUNT : active;
for (Infestation infestation : List.copyOf(byProvince.values())) {
if (!stillReturning(player, session)) {
break;
}
changed |= dropDeadCharacterDebt(infestation, player, active);
Set<String> owed = infestation.getOwedDeaths().get(id);
if (!killed && owed != null && owed.stream().anyMatch(owing::equalsIgnoreCase) && !player.isDead()) {
kill(player);
killed = true;
}
}
if (changed) {
save();
}
}

private boolean stillReturning(Player player, CharacterSession session) {
return player.isOnline() && Bukkit.getPlayer(player.getUniqueId()) == player
&& (session != null ? Characters.isCurrent(session) : !usesCharacters());
}

/** Drops a death owed by another character of this account once that character has died. */
private static boolean dropDeadCharacterDebt(Infestation infestation, Player player, String active) {
UUID id = player.getUniqueId();
Set<String> owed = infestation.getOwedDeaths().get(id);
if (owed == null || active == null) {
return false;
}
for (String character : List.copyOf(owed)) {
if (!character.equalsIgnoreCase(active) && Characters.isDead(player, character)) {
dropOwedDeath(infestation, id, character);
return true;
}
}
return false;
}

private static void kill(Player player) {
player.damage(Math.max(40.0, player.getHealth() + 10.0));
}

@EventHandler
public void onLeaveProvince(PlayerProvinceLeaveEvent event) {
Infestation infestation = get(event.getProvinceId());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@
assertTrue(deadline >= before + Cache.logoutGraceSeconds * 1000L);
assertEquals(Map.of(owner, bob.toString()), previous.getLogoutCharacter());
assertEquals(Map.of(owner, 1234L), previous.getLogoutGraceUntil());
Bukkit.getPluginManager().callEvent(new PlayerQuitEvent(player, net.kyori.adventure.text.Component.empty()));

Check warning on line 142 in src/test/java/net/tfminecraft/infestations/InfestationsServicesTest.java

View workflow job for this annotation

GitHub Actions / build

PlayerQuitEvent(org.bukkit.entity.Player,net.kyori.adventure.text.Component) in org.bukkit.event.player.PlayerQuitEvent has been deprecated and marked for removal
assertEquals(deadline, current.getLogoutGraceUntil().get(owner));
}
}
Expand Down Expand Up @@ -231,9 +231,10 @@
Bukkit.getPluginManager().callEvent(new CharacterSessionStartEvent(player, returned));
assertTrue(grace.getLogoutGraceUntil().isEmpty());
assertTrue(grace.getLogoutCharacter().isEmpty());
assertTrue(debt.getDeathOnLogin().isEmpty());
assertTrue(debt.getLogoutCharacter().isEmpty());
verify(player).damage(40.0);
// The kill did not land here, so the debt waits for a settled death.
assertEquals(Set.of(owner), debt.getDeathOnLogin());
assertEquals(Map.of(owner, alice.toString()), debt.getLogoutCharacter());
}
}
@Test void unregisteringTheProviderHoldsTaggedPenaltiesInsteadOfUsingTheAccount() {
Expand All @@ -256,7 +257,7 @@
manager.characterReturned(player);
Bukkit.getPluginManager().callEvent(new CharacterSessionStartEvent(player, session));
Bukkit.getPluginManager().callEvent(new PlayerJoinEvent(player, net.kyori.adventure.text.Component.empty()));
Bukkit.getPluginManager().callEvent(new PlayerQuitEvent(player, net.kyori.adventure.text.Component.empty()));

Check warning on line 260 in src/test/java/net/tfminecraft/infestations/InfestationsServicesTest.java

View workflow job for this annotation

GitHub Actions / build

PlayerQuitEvent(org.bukkit.entity.Player,net.kyori.adventure.text.Component) in org.bukkit.event.player.PlayerQuitEvent has been deprecated and marked for removal
assertFalse(manager.leaveLure(player));
verify(player, never()).damage(anyDouble());
assertEquals(Map.of(owner, Long.MAX_VALUE), grace.getLogoutGraceUntil());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ void lureLifecycleLocatesTheBlockAndResetsOnlyLureState() {
infestation.getLogoutGraceUntil().put(PLAYER, 100L);
infestation.getDeathOnLogin().add(PLAYER);
infestation.getLogoutCharacter().put(PLAYER, "alice");
infestation.getOwedDeaths().put(PLAYER, Set.of("bob"));
infestation.setWaveRetryAtTick(5);
infestation.setVictoryAtTick(6);
infestation.setPendingSpawns(3);
Expand All @@ -164,6 +165,7 @@ void lureLifecycleLocatesTheBlockAndResetsOnlyLureState() {
assertTrue(infestation.getLogoutGraceUntil().isEmpty());
assertTrue(infestation.getDeathOnLogin().isEmpty());
assertTrue(infestation.getLogoutCharacter().isEmpty());
assertEquals(Map.of(PLAYER, Set.of("bob")), infestation.getOwedDeaths(), "Owed deaths outlive lures");
assertEquals(0, infestation.getPendingSpawns());
assertEquals(0, infestation.getEnemiesAlive());
assertEquals(0, infestation.getLureReleased());
Expand All @@ -189,6 +191,7 @@ void lureLifecycleLocatesTheBlockAndResetsOnlyLureState() {
assertTrue(infestation.getLogoutGraceUntil().isEmpty());
assertTrue(infestation.getDeathOnLogin().isEmpty());
assertTrue(infestation.getLogoutCharacter().isEmpty());
assertEquals(Map.of(PLAYER, Set.of("bob")), infestation.getOwedDeaths());
}

@Test
Expand Down Expand Up @@ -251,6 +254,7 @@ void persistenceRoundTripKeepsRuntimeStateButRequeuesScheduledSpawns() throws Ex
original.getLogoutGraceUntil().put(PLAYER, 9876L);
original.getDeathOnLogin().add(PLAYER);
original.getLogoutCharacter().put(PLAYER, "alice");
original.getOwedDeaths().put(PLAYER, Set.of("bob", "carol"));
InfestationDatabase.save(List.of(original));
assertTrue(Files.readString(InfestationDatabase.file().toPath()).contains("test-group"));
Infestation loaded = InfestationDatabase.load().getFirst();
Expand All @@ -270,6 +274,7 @@ void persistenceRoundTripKeepsRuntimeStateButRequeuesScheduledSpawns() throws Ex
assertEquals(9876L, loaded.getLogoutGraceUntil().get(PLAYER));
assertEquals(Set.of(PLAYER), loaded.getDeathOnLogin());
assertEquals(Map.of(PLAYER, "alice"), loaded.getLogoutCharacter());
assertEquals(Map.of(PLAYER, Set.of("bob", "carol")), loaded.getOwedDeaths());
}

@Test
Expand All @@ -281,13 +286,19 @@ void malformedRowsAndLegacyFieldsDoNotDiscardValidNeighbors() throws Exception {
{"provinceId":3,"groupId":"valid","severity":"mild","phase":"old-phase",
"committed":["bad","%s"],"logoutGraceUntil":{"bad":5,"%s":6},
"deathOnLogin":["bad","%s"],"logoutCharacter":{"bad":"x","%s":"alice"},
"owedDeaths":{"bad":["x"],"%s":[null,"bob"]},
"pendingSpawns":20,"lureReleased":3},
{"provinceId":4,"groupId":"legacy","severity":"worrying","committed":null,
"logoutGraceUntil":null,"deathOnLogin":null,"logoutCharacter":{"%s":null}}]
""".formatted(PLAYER, PLAYER, PLAYER, PLAYER, PLAYER));
"logoutGraceUntil":null,"deathOnLogin":null,"logoutCharacter":{"%s":null},
"owedDeaths":{"%s":null}},
{"provinceId":5,"groupId":"older","severity":"mild","owedDeaths":null}]
""".formatted(PLAYER, PLAYER, PLAYER, PLAYER, PLAYER, PLAYER, PLAYER));
List<Infestation> loaded = InfestationDatabase.load();
assertEquals(2, loaded.size());
assertEquals(3, loaded.size());
assertSame(LurePhase.NONE, loaded.getFirst().getPhase());
assertEquals(Map.of(PLAYER, Set.of("bob")), loaded.getFirst().getOwedDeaths());
assertTrue(loaded.get(1).getOwedDeaths().isEmpty());
assertTrue(loaded.get(2).getOwedDeaths().isEmpty());
assertEquals(0, loaded.getFirst().getLureReleased());
assertEquals(Set.of(PLAYER), loaded.getFirst().getCommitted());
assertEquals(6L, loaded.getFirst().getLogoutGraceUntil().get(PLAYER));
Expand Down
Loading