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
5 changes: 5 additions & 0 deletions src/main/java/net/tfminecraft/infestations/Messages.java
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ public static String get(String path, String... keyValues) {
return format(raw);
}

/** For keys that a messages.yml from an older release may lack. */
public static String getOrDefault(String path, String fallback) {
return format(applyPrefix(config != null ? config.getString(path, fallback) : fallback));
}

public static String getRaw(String path) {
if (config == null) {
return path;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@
import net.tfminecraft.simplefactions.events.PlayerProvinceLeaveEvent;
import net.tfminecraft.tlibs.TLibs;
import net.tfminecraft.tlibs.character.CharacterSession;
import net.tfminecraft.tlibs.character.CharacterDeathEvent;
import net.tfminecraft.tlibs.character.CharacterRef;
import net.tfminecraft.tlibs.character.CharacterSessionEndEvent;
import net.tfminecraft.tlibs.character.CharacterSessionStartEvent;
import net.tfminecraft.infestations.Infestations;
Expand Down Expand Up @@ -117,14 +119,6 @@ public void start() {
}

public void shutdown() {
if (usesCharacters()) {
for (Player player : Bukkit.getOnlinePlayers()) {
CharacterSession session = Characters.session(player);
if (session != null) {
recordDeparture(player.getUniqueId(), session.ref().characterId().toString());
}
}
}
if (task != null) {
task.cancel();
task = null;
Expand Down Expand Up @@ -889,9 +883,8 @@ private int highlightLureMobs(Infestation infestation) {

private boolean readyToJoin(Player player) {
if (usesCharacters() && Characters.session(player) == null) {
String message = Messages.get("character-required");
player.sendMessage("character-required".equals(message)
? "Your character must be ready before joining a lure." : message);
player.sendMessage(Messages.getOrDefault("character-required",
"{prefix}#c45c5cYour character must be ready before joining a lure."));
return false;
}
return true;
Expand Down Expand Up @@ -1187,8 +1180,11 @@ private void onPlayerDeath(Player player) {
}

private void settleDeath(UUID id, String character) {
Player online = Bukkit.getPlayer(id);
String current = online == null ? null : Characters.activeId(online);
for (Infestation infestation : List.copyOf(byProvince.values())) {
String owner = infestation.getLogoutCharacter().get(id);
// Without a recorded departure, the commitment belongs to the current character, if any.
String owner = infestation.getLogoutCharacter().getOrDefault(id, current);
if (!infestation.isCommitted(id) || (owner != null && !owner.equalsIgnoreCase(character))) {
continue;
}
Expand All @@ -1207,11 +1203,23 @@ private void settleDeath(UUID id, String character) {
public void onCharacterEnd(CharacterSessionEndEvent event) {
characterTracking = true;
CharacterSession session = event.getSession();
if (event.getReason() == CharacterSessionEndEvent.Reason.DEATH) {
settleDeath(session.ref().ownerId(), session.ref().characterId().toString());
} else {
recordDeparture(session.ref().ownerId(), session.ref().characterId().toString());
save();
// A restart is not a departure: with nobody back, the lure fails without penalty.
// A death settles through onCharacterDeath once RPCharacters has saved it.
if (event.getReason() == CharacterSessionEndEvent.Reason.SHUTDOWN
|| event.getReason() == CharacterSessionEndEvent.Reason.DEATH) {
return;
}
recordDeparture(session.ref().ownerId(), session.ref().characterId().toString());
save();
}

/** Settles the dead character's commitments. A repeat, or a replay after a revival, changes nothing. */
@EventHandler
public void onCharacterDeath(CharacterDeathEvent event) {
characterTracking = true;
CharacterRef character = event.getCharacter();
if (Characters.isDead(character)) {
settleDeath(character.ownerId(), character.characterId().toString());
}
}

Expand Down
10 changes: 8 additions & 2 deletions src/main/java/net/tfminecraft/infestations/utils/Characters.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import org.bukkit.entity.Player;

import net.tfminecraft.tlibs.character.CharacterRef;
import net.tfminecraft.tlibs.character.CharacterSession;

/** Shared identity lookups; unavailable and not ready are distinct from standalone play. */
Expand Down Expand Up @@ -39,8 +40,13 @@ public static boolean isDead(Player player, String characterId) {
if (!id.toString().equalsIgnoreCase(characterId)) {
return false;
}
return net.tfminecraft.tlibs.character.Characters.info(id)
.filter(info -> info.ref().ownerId().equals(player.getUniqueId()))
return isDead(new CharacterRef(player.getUniqueId(), id));
}

/** Whether the provider still records this character of this account as not alive. */
public static boolean isDead(CharacterRef character) {
return net.tfminecraft.tlibs.character.Characters.info(character.characterId())
.filter(info -> info.ref().ownerId().equals(character.ownerId()))
.map(info -> !info.alive()).orElse(false);
}
}
3 changes: 1 addition & 2 deletions src/main/resources/messages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,4 @@ lure-victory: "{prefix}#7dba7aThe infestation is cleared."
lure-fail: "{prefix}#c45c5cThe lure failed. The infestation remains."
hologram-joining: "#c9a44aLure - join {seconds}s"
hologram-remaining: "#c45c5cRemaining: #f0e6d0{remaining}"

character-required: "{prefix}&cYour character must be ready before joining a lure."
character-required: "{prefix}#c45c5cYour character must be ready before joining a lure."
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import net.tfminecraft.infestations.spawn.SpawnLog;
import net.tfminecraft.infestations.utils.Characters;
import net.tfminecraft.infestations.utils.Provinces;
import net.tfminecraft.tlibs.character.CharacterDeathEvent;
import net.tfminecraft.tlibs.character.CharacterIdentity;
import net.tfminecraft.tlibs.character.CharacterInfo;
import net.tfminecraft.tlibs.character.CharacterRef;
Expand Down Expand Up @@ -60,10 +61,13 @@
assertEquals("missing", Messages.getRaw("missing"));
assertEquals("hello", Messages.get("hello"));
assertEquals("", Messages.get(null));
assertEquals("搂cfallback", Messages.getOrDefault("missing", "{prefix}&cfallback"));
Path file = Files.writeString(directory.resolve("messages.yml"), "prefix: '&a[Infestations] '\ngreet: '{prefix}Hello {who}{blank}'\n");
Messages.load(file.toFile());
assertEquals("搂a[Infestations] Hello Ryan", Messages.get("greet", "who", "Ryan", "blank", null, "unpaired"));
assertEquals("missing", Messages.getRaw("missing"));
assertEquals("搂a[Infestations] fallback", Messages.getOrDefault("missing", "{prefix}fallback"));
assertEquals("搂a[Infestations] Hello {who}{blank}", Messages.getOrDefault("greet", "fallback"));
Files.writeString(file, "invalid: [bad"); Messages.load(file.toFile());
assertEquals("missing", Messages.get("missing"));
Messages.load(directory.resolve("absent").toFile());
Expand Down Expand Up @@ -111,7 +115,7 @@
}
}
@ParameterizedTest
@EnumSource(value = Reason.class, names = {"SWITCH", "QUIT", "SHUTDOWN"})
@EnumSource(value = Reason.class, names = {"SWITCH", "QUIT"})
void sharedEndRecordsTheInvalidatedOutgoingCharacterAndPreservesPriorDebt(Reason reason) {
Player player = MockBukkit.getMock().addPlayer();
try (CharacterProvider provider = new CharacterProvider();
Expand All @@ -135,11 +139,26 @@
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));
}
}
@Test void sharedDeathSettlesOnlyTheOutgoingCharactersCommitmentsAfterInvalidation() {
@Test void sharedShutdownEndRecordsNoDeparture() {
Player player = MockBukkit.getMock().addPlayer();
try (CharacterProvider provider = new CharacterProvider();
var database = mockStatic(InfestationDatabase.class);
var export = mockStatic(InfestationMapExport.class)) {
InfestationManager manager = registeredManager(provider);
Infestation lure = commitment(manager, 1, player);
CharacterSession outgoing = provider.activate(player, UUID.randomUUID());
provider.invalidate(player);
Bukkit.getPluginManager().callEvent(new CharacterSessionEndEvent(player, outgoing, Reason.SHUTDOWN));
assertTrue(lure.isCommitted(player.getUniqueId()));
assertTrue(lure.getLogoutCharacter().isEmpty());
assertTrue(lure.getLogoutGraceUntil().isEmpty());
}
}
@Test void sharedDeathSettlesOnlyOnceSavedAndOnlyTheDeadCharactersCommitments() {
Player player = MockBukkit.getMock().addPlayer();
try (CharacterProvider provider = new CharacterProvider();
var database = mockStatic(InfestationDatabase.class);
Expand All @@ -157,8 +176,17 @@
other.getLogoutCharacter().put(owner, bob.toString());
CharacterSession outgoing = provider.activate(player, alice);
provider.invalidate(player);
assertFalse(Characters.isCurrent(outgoing));
// The session ends before RPCharacters saves the death; a failed save leaves the character alive.
Bukkit.getPluginManager().callEvent(new CharacterSessionEndEvent(player, outgoing, Reason.DEATH));
for (Infestation infestation : List.of(matching, ongoing, other)) {
assertTrue(infestation.isCommitted(owner));
}
assertTrue(ongoing.getLogoutGraceUntil().isEmpty());

provider.describe(owner, alice, CharacterStatus.DEAD);
CharacterDeathEvent death = new CharacterDeathEvent(new CharacterRef(owner, alice), UUID.randomUUID(),
java.time.Instant.now(), null, null, "LURE");
Bukkit.getPluginManager().callEvent(death);
for (Infestation infestation : List.of(matching, ongoing)) {
assertFalse(infestation.isCommitted(owner));
assertTrue(infestation.getLogoutCharacter().isEmpty());
Expand All @@ -169,6 +197,13 @@
assertEquals(Map.of(owner, bob.toString()), other.getLogoutCharacter());
assertEquals(Map.of(owner, 1234L), other.getLogoutGraceUntil());
assertEquals(Set.of(owner), other.getDeathOnLogin());

// A replay after the character is revived and joins again settles nothing.
provider.describe(owner, alice, CharacterStatus.ALIVE);
provider.activate(player, alice);
matching.getCommitted().add(owner);
Bukkit.getPluginManager().callEvent(death);
assertTrue(matching.isCommitted(owner));
}
}
@Test void sharedStartAppliesOnlyTheCurrentCharactersGraceAndDeath() {
Expand Down Expand Up @@ -221,7 +256,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 259 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 @@ -54,6 +54,7 @@
import net.tfminecraft.simplefactions.events.PlayerProvinceEnterEvent;
import net.tfminecraft.simplefactions.events.PlayerProvinceLeaveEvent;
import net.tfminecraft.tlibs.TLibs;
import net.tfminecraft.tlibs.character.CharacterDeathEvent;
import net.tfminecraft.tlibs.character.CharacterRef;
import net.tfminecraft.tlibs.character.CharacterSession;
import net.tfminecraft.tlibs.character.CharacterSessionEndEvent;
Expand Down Expand Up @@ -1059,7 +1060,7 @@ void waterAndSeaSpreadLogTheirRoutes(boolean sea) throws Exception {
characters.when(Characters::enabled).thenReturn(true);
call("tryJoin", p, lure);
assertFalse(lure.isCommitted(id));
verify(p).sendMessage("Your character must be ready before joining a lure.");
verify(p).sendMessage("character-required");
start(p, ALICE); call("tryJoin", p, lure);
assertTrue(lure.isCommitted(id));
CharacterSession old = sessions.get(id);
Expand All @@ -1081,31 +1082,61 @@ void waterAndSeaSpreadLogTheirRoutes(boolean sea) throws Exception {
assertTrue(manager.leaveLure(p));
}

@Test void deathEndUsesInvalidatedOutgoingTokenAndDoesNotSettleAnotherCharacter() {
@Test void onlyTheSavedDeathOfTheDepartedCharacterSettlesItsLure() {
Player p = player(1); UUID id = p.getUniqueId();
Infestation lure = lure(1, LurePhase.ACTIVE);
lure.getCommitted().add(id); lure.getLogoutCharacter().put(id, ALICE.toUpperCase(Locale.ROOT));
lure.getDeathOnLogin().add(id);
characters.when(Characters::enabled).thenReturn(true);
current(p, BOB); end(p, CharacterSessionEndEvent.Reason.DEATH);
assertTrue(lure.isCommitted(id));
current(p, ALICE); end(p, CharacterSessionEndEvent.Reason.DEATH);
assertTrue(lure.isCommitted(id)); assertTrue(lure.getDeathOnLogin().contains(id));

characters.when(() -> Characters.isDead(any(CharacterRef.class))).thenReturn(true);
manager.onCharacterDeath(died(id, BOB));
assertTrue(lure.isCommitted(id));
characters.when(() -> Characters.isDead(any(CharacterRef.class))).thenReturn(false);
manager.onCharacterDeath(died(id, ALICE));
assertTrue(lure.isCommitted(id));

characters.when(() -> Characters.isDead(any(CharacterRef.class))).thenReturn(true);
manager.onCharacterDeath(died(id, ALICE));
assertFalse(lure.isCommitted(id)); assertTrue(lure.getDeathOnLogin().isEmpty());
assertEquals(LurePhase.NONE, lure.getPhase());
assertDoesNotThrow(() -> manager.onCharacterDeath(died(id, ALICE)));
}

@Test void withoutADepartureOnlyTheCurrentOrJustEndedCharactersDeathSettles() {
Player p = player(1); UUID id = p.getUniqueId();
Infestation lure = lure(1, LurePhase.ACTIVE); lure.getCommitted().add(id);
characters.when(Characters::enabled).thenReturn(true);
characters.when(() -> Characters.isDead(any(CharacterRef.class))).thenReturn(true);
current(p, BOB);
manager.onCharacterDeath(died(id, ALICE));
assertTrue(lure.isCommitted(id));
end(p, CharacterSessionEndEvent.Reason.DEATH);
assertTrue(lure.isCommitted(id)); assertTrue(lure.getLogoutGraceUntil().isEmpty());
manager.onCharacterDeath(died(id, BOB));
assertFalse(lure.isCommitted(id));
}

private static CharacterDeathEvent died(UUID owner, String character) {
return new CharacterDeathEvent(new CharacterRef(owner, UUID.fromString(character)), UUID.randomUUID(),
java.time.Instant.now(), null, null, "LURE");
}

@Test void shutdownRecordsReadyCommitmentsBeforeProviderDisablesWithoutExtendingGrace() {
Player p = player(1); UUID id = p.getUniqueId(); player(2);
@Test void restartIsNotADepartureSoTheEmptyLureFailsWithoutPenalty() {
Player p = player(1); UUID id = p.getUniqueId();
Infestation lure = lure(1, LurePhase.ACTIVE); lure.getCommitted().add(id);
characters.when(Characters::enabled).thenReturn(true); current(p, ALICE);
manager.shutdown();
assertEquals(ALICE, lure.getLogoutCharacter().get(id));
long deadline = lure.getLogoutGraceUntil().get(id);
manager.shutdown();
assertEquals(deadline, lure.getLogoutGraceUntil().get(id));
characters.when(Characters::enabled).thenReturn(false);
manager.characterReturned(p);
assertEquals(ALICE, lure.getLogoutCharacter().get(id));
end(p, CharacterSessionEndEvent.Reason.SHUTDOWN);
assertTrue(lure.getLogoutGraceUntil().isEmpty());
assertTrue(lure.getLogoutCharacter().isEmpty());
assertTrue(lure.getDeathOnLogin().isEmpty());
online.remove(id); when(p.isOnline()).thenReturn(false);
call("checkWipe", lure);
assertFalse(lure.isCommitted(id));
assertEquals(LurePhase.NONE, lure.getPhase());
}

@Test void loadedCharacterTagsEnableStickyTrackingWithoutAProvider() {
Expand Down