Compare commits

...
1 Commits
Author SHA1 Message Date
dmg 9837afc549 fix(sessions): preserve cross-world Tail delivery
Release / release (push) Successful in 11m37s
2026-09-23 08:22:11 -04:00
4 changed files with 244 additions and 29 deletions

No files matched your search

@@ -752,7 +752,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
UUID owner = event.getPlayer().getUniqueId();
cancelCustody(owner);
swaps.remove(owner);
sessions.invalidateTail(owner);
sessions.invalidateTail(owner, event.getView());
}
private boolean reservedExchange(Player player, int slot) {
@@ -114,7 +114,12 @@ public final class EquipmentSessions implements AutoCloseable {
boolean bookDelivered;
boolean deliveryInvalidated;
boolean conversionReported;
boolean partyDefeatAcknowledged;
boolean tailCallbackQueued;
boolean defeatReturnTeleport;
CharacterRecord deathRecord;
ItemStack deathReference;
Object deathNativeReference;
org.bukkit.inventory.InventoryView deathView;
Attempt(Player player, UUID owner, UUID character) {
this.player = player; this.owner = owner; this.character = character;
@@ -426,7 +431,12 @@ public final class EquipmentSessions implements AutoCloseable {
}
if (active.containsKey(owner)) return;
cancelEntry(owner);
if (owned.containsKey(owner)) {
var previous = owned.get(owner);
if (previous != null && previous.defeated) {
retryTail(player, previous);
return;
}
if (previous != null) {
notice(owner, "BUSY", "Equipment persistence or recovery is pending; retry after it completes.");
return;
}
@@ -673,30 +683,35 @@ public final class EquipmentSessions implements AutoCloseable {
if (failure != null) { diagnostic(attempt, failure); conversionUncertain(attempt, "Death save failed or is uncertain"); return; }
if (dead.isEmpty()) { conversionUncertain(attempt, "Character was missing or already dead; no new Tail authorized"); return; }
attempt.deathResolved = true;
attempt.deathRecord = dead.orElseThrow();
if (closed) { conversionUncertain(attempt, "Shutdown before party defeat and Tail conversion"); return; }
java.util.concurrent.CompletionStage<?> partyMutation;
try { partyMutation = partyDefeat.apply(attempt.character); }
catch (RuntimeException error) { diagnostic(attempt,error); conversionUncertain(attempt,"Party defeat mutation rejected"); return; }
partyMutation.whenComplete((ignored,partyFailure) -> {
if (partyFailure != null) { diagnostic(attempt,partyFailure); conversionUncertain(attempt,"Party defeat mutation failed"); return; }
if (closed) { conversionUncertain(attempt,"Shutdown before Tail conversion"); return; }
try {
nextTick.accept(() -> {
synchronized (EquipmentSessions.this) { deliverTail(attempt, dead.orElseThrow()); }
});
} catch (RuntimeException error) { diagnostic(attempt, error); conversionUncertain(attempt, "Tail callback rejected"); }
synchronized (EquipmentSessions.this) {
if (partyFailure != null) { diagnostic(attempt,partyFailure); conversionUncertain(attempt,"Party defeat mutation failed"); return; }
if (closed) { conversionUncertain(attempt,"Shutdown before Tail conversion"); return; }
attempt.partyDefeatAcknowledged = true;
queueTail(attempt);
}
});
});
try {
attempt.deathReference = copy(attempt.player.getInventory().getItemInOffHand());
attempt.deathView = attempt.player.getOpenInventory();
restoreExit(attempt);
var restored = restoreExit(attempt);
if (restored == EquipmentStorage.RestoreResult.RESTORED) {
var retained = attempt.player.getInventory().getItemInOffHand();
attempt.deathReference = copy(retained);
attempt.deathNativeReference = nativeIdentity(retained);
attempt.deathView = attempt.player.getOpenInventory();
}
// Read the current maximum after owned overlay cleanup, including legitimate
// ordinary modifiers. Never reconstruct attributes from an entry snapshot.
double maximum = Objects.requireNonNull(attempt.player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).getValue();
event.setReviveHealth(maximum);
attempt.player.setHealth(maximum); // Native cancellation only fills health when <= 0.
notice(attempt.owner, "DEFEAT", "§cYour character was defeated.");
attempt.defeatReturnTeleport = true;
try {
if (!attempt.player.teleport(attempt.entryLocation.clone())) {
notice(attempt.owner, "DEFEAT_RETURN_FAILED", "Return to the session entry location was refused; you remain here.");
@@ -704,6 +719,10 @@ public final class EquipmentSessions implements AutoCloseable {
} catch (RuntimeException failure) {
diagnostic(attempt, failure);
notice(attempt.owner, "DEFEAT_RETURN_FAILED", "Return to the session entry location failed; you remain here.");
} finally {
attempt.defeatReturnTeleport = false;
if (!attempt.deliveryInvalidated && owned.get(attempt.owner) == attempt && attempt.player.isOnline())
attempt.deathView = attempt.player.getOpenInventory();
}
} catch (RuntimeException failure) {
diagnostic(attempt, failure);
@@ -727,21 +746,66 @@ public final class EquipmentSessions implements AutoCloseable {
if (attempt != null && attempt.defeated) attempt.deliveryInvalidated = true;
}
private void deliverTail(Attempt attempt, CharacterRecord dead) {
if (attempt.bookAttempted) return;
attempt.bookAttempted = true; // Even an exception after a native write never permits replay.
/** The exact view closed by this plugin's defeat-return teleport may be rebound after teleport. */
public synchronized void invalidateTail(UUID owner, org.bukkit.inventory.InventoryView closedView) {
var attempt = owned.get(owner);
if (attempt == null || !attempt.defeated) return;
if (attempt.defeatReturnTeleport && attempt.deathView == closedView) {
attempt.deathView = null;
return;
}
attempt.deliveryInvalidated = true;
}
private void queueTail(Attempt attempt) {
if (closed || attempt.tailCallbackQueued || attempt.bookAttempted || attempt.bookDelivered
|| attempt.deliveryInvalidated || !attempt.deathResolved || !attempt.partyDefeatAcknowledged
|| !attempt.gearAcknowledged || attempt.deathRecord == null) return;
attempt.tailCallbackQueued = true;
try {
if (!tailContext(attempt)
|| !Objects.equals(attempt.deathReference, copy(attempt.player.getInventory().getItemInOffHand()))
|| !sheets.identify(attempt.deathReference).filter(attempt.character::equals).isPresent()
|| attempt.deathReference.getAmount() < 1
|| attempt.deathReference.getAmount() > attempt.deathReference.getMaxStackSize()) {
nextTick.accept(() -> {
synchronized (EquipmentSessions.this) {
attempt.tailCallbackQueued = false;
deliverTail(attempt, attempt.deathRecord);
}
});
} catch (RuntimeException error) {
attempt.tailCallbackQueued = false;
conversionNotStarted(attempt, "Tail callback was rejected before its native write ("
+ error.getClass().getSimpleName() + ")");
}
}
private void retryTail(Player player, Attempt attempt) {
if (attempt.player != player || !player.isOnline() || attempt.bookAttempted || attempt.bookDelivered
|| attempt.deliveryInvalidated || !tailCustody(attempt)) {
attempt.deliveryInvalidated = true;
notice(attempt.owner, "DEFEAT_INSPECTION_REQUIRED",
"Death or Tail custody is ambiguous; items were retained for administrator inspection.");
return;
}
if (!attempt.deathResolved || !attempt.partyDefeatAcknowledged || !attempt.gearAcknowledged
|| attempt.deathRecord == null) {
notice(attempt.owner, "DEFEAT_PENDING",
"Death persistence, restoration or Tail prerequisites are still pending; no item was changed.");
return;
}
attempt.tailCallbackQueued = false;
deliverTail(attempt, attempt.deathRecord);
}
private void deliverTail(Attempt attempt, CharacterRecord dead) {
if (attempt.bookAttempted || attempt.bookDelivered) return;
try {
if (!tailContext(attempt) || !tailCustody(attempt)) {
conversionUncertain(attempt, "Tail destination/context is stale or equipment restoration is unresolved"); return;
}
// Constructing a book cannot mutate native inventory and remains retryable if it fails.
var book = new games.dmg.fantasystick.items.TailBooks().create(dead);
attempt.bookAttempted = true; // From this point an exception may follow a native write; never replay.
// An exact captured stack of copied references represents ONE character, not N
// deaths. Replace that whole stack with ONE book; never scan/delete other copies,
// move items, overwrite a changed stack, or grant into an available slot.
var book = new games.dmg.fantasystick.items.TailBooks().create(dead);
attempt.player.getInventory().setItemInOffHand(book);
if (!tailContext(attempt) || !book.equals(attempt.player.getInventory().getItemInOffHand())) {
conversionUncertain(attempt, "Context changed during Tail write; delivery may already have happened"); return;
@@ -750,11 +814,24 @@ public final class EquipmentSessions implements AutoCloseable {
release(attempt);
sync(attempt);
} catch (RuntimeException failure) {
diagnostic(attempt, failure);
conversionUncertain(attempt, "Tail native write failed or is uncertain; never replay");
if (attempt.bookAttempted) {
diagnostic(attempt, failure);
conversionUncertain(attempt, "Tail native write failed or is uncertain; never replay");
} else conversionNotStarted(attempt, "Tail construction failed before its native write ("
+ failure.getClass().getSimpleName() + ")");
}
}
private boolean tailCustody(Attempt attempt) {
var current = attempt.player.getInventory().getItemInOffHand();
return attempt.deathReference != null && attempt.deathNativeReference != null
&& Objects.equals(attempt.deathReference, copy(current))
&& nativeIdentity(current) == attempt.deathNativeReference
&& sheets.identify(attempt.deathReference).filter(attempt.character::equals).isPresent()
&& attempt.deathReference.getAmount() >= 1
&& attempt.deathReference.getAmount() <= attempt.deathReference.getMaxStackSize();
}
private boolean tailContext(Attempt attempt) {
if (closed || attempt.deliveryInvalidated || owned.get(attempt.owner) != attempt
|| !attempt.player.isOnline() || attempt.player.getOpenInventory() != attempt.deathView) return false;
@@ -763,6 +840,11 @@ public final class EquipmentSessions implements AutoCloseable {
&& marker.phase() == EquipmentSessionMarker.Phase.RESTORED;
}
private void conversionNotStarted(Attempt attempt, String reason) {
diagnostic(attempt, new IllegalStateException(reason + "; character " + attempt.character
+ ". The exact same-lifetime retained sheet may retry after acknowledged prerequisites."));
}
private void conversionUncertain(Attempt attempt, String reason) {
if (attempt.conversionReported) return;
attempt.conversionReported = true;
@@ -904,8 +986,18 @@ public final class EquipmentSessions implements AutoCloseable {
? receipts.completeExit(attempt.owner, attempt.session, attempt.exitDelay)
: receipts.recordExit(attempt.owner, attempt.session, attempt.exitDelay), (state, failure) -> {
attempt.saving = false;
if (failure != null) diagnostic(attempt, failure);
else if (complete) { attempt.gearAcknowledged = true; release(attempt); }
if (failure != null) {
diagnostic(attempt, failure);
if (attempt.defeated) conversionUncertain(attempt,
"Equipment restoration persistence failed before Tail conversion");
} else if (complete) {
attempt.gearAcknowledged = true;
if (attempt.defeated) queueTail(attempt);
release(attempt);
} else if (attempt.defeated) {
conversionUncertain(attempt,
"Equipment restoration was not acknowledged; Tail conversion remains unsafe");
}
});
}
@@ -999,6 +1091,9 @@ public final class EquipmentSessions implements AutoCloseable {
});
} catch (RuntimeException failure) { fail(attempt, failure); }
}
private static Object nativeIdentity(ItemStack item) {
return item == null || item.isEmpty() ? null : org.bukkit.craftbukkit.inventory.CraftItemStack.unwrap(item);
}
private static ItemStack copy(ItemStack item) { return item == null ? null : item.clone(); }
private static ItemStack[] snapshot(Player player) {
return Arrays.stream(player.getInventory().getContents()).map(EquipmentSessions::copy).toArray(ItemStack[]::new);
@@ -633,6 +633,110 @@ class EquipmentSessionEventsNativeTest {
}
}
@Test void crossWorldDefeatCloseRebindsProvedTailDeliveryWithOrWithoutArmor() throws Exception {
for (boolean armored : new boolean[]{false, true}) try (var f = new Fixture()) {
var a = f.actor;
var overworld = org.mockito.Mockito.mock(org.bukkit.World.class);
var end = org.mockito.Mockito.mock(org.bukkit.World.class);
org.mockito.Mockito.when(overworld.getUID()).thenReturn(java.util.UUID.randomUUID());
org.mockito.Mockito.when(end.getUID()).thenReturn(java.util.UUID.randomUUID());
a.location = new org.bukkit.Location(overworld, 12, 70, -8, 45, 10);
if (!armored) a.inventory.setArmorContents(new ItemStack[4]);
var entry = a.location.clone(); f.core.activate(a);
a.location = new org.bukkit.Location(end, 80, 64, 40);
var closingView = a.view;
a.teleport = () -> {
f.dispatcher.accept(new org.bukkit.event.inventory.InventoryCloseEvent(closingView));
a.view = null;
};
f.dispatcher.accept(f.death(org.bukkit.damage.DamageType.OUT_OF_WORLD));
f.core.tick();
assertEquals(entry, a.location);
assertEquals(Material.WRITTEN_BOOK, a.inventory.getItemInOffHand().getType());
assertTrue(f.core.characters.find(a.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS)
.orElseThrow().dead());
assertNull(f.core.state(a).pending(), "Successful Tail delivery releases the completed receipt");
var deadSheet = new games.dmg.fantasystick.items.CharacterSheets().create(a.record);
a.inventory.setItemInOffHand(deadSheet); f.core.sessions.enter(a.player);
f.core.characters.find(a.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS); f.core.tick();
assertEquals("UNAVAILABLE_CHARACTER", f.core.notices.getLast().code(),
"A completed defeat has no false persistence blocker and cannot reactivate");
}
}
@Test void exactRetainedSheetUseCompletesOnlyNeverStartedSameLifetimeTailConversion() throws Exception {
try (var f = new Fixture()) {
var a = f.actor; f.core.activate(a);
var retained = a.inventory.getItemInOffHand().clone();
f.dispatcher.accept(f.death(org.bukkit.damage.DamageType.GENERIC));
var original = f.core.next();
assertEquals(Material.PAPER, a.inventory.getItemInOffHand().getType());
assertNull(f.core.state(a).pending(), "Ordinary restoration must be acknowledged before retry");
f.core.sessions.enter(a.player);
assertEquals(Material.WRITTEN_BOOK, a.inventory.getItemInOffHand().getType());
var delivered = a.inventory.getItemInOffHand().clone();
original.run(); original.run(); f.core.sessions.enter(a.player);
assertEquals(delivered, a.inventory.getItemInOffHand());
assertEquals(1, a.teleports, "Retry never replays defeat restoration or return teleport");
a.inventory.setItemInOffHand(retained); f.core.sessions.enter(a.player);
f.core.characters.find(a.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS); f.core.tick();
assertEquals("UNAVAILABLE_CHARACTER", f.core.notices.getLast().code());
assertEquals(Material.PAPER, a.inventory.getItemInOffHand().getType(),
"The dead sheet cannot activate or create a second Tail after release");
}
try (var f = new Fixture()) {
var a = f.actor; f.core.activate(a);
var copied = a.inventory.getItemInOffHand().clone();
f.dispatcher.accept(f.death(org.bukkit.damage.DamageType.GENERIC));
var original = f.core.next();
a.inventory.setItemInOffHand(copied);
f.core.sessions.enter(a.player);
assertEquals(Material.PAPER, a.inventory.getItemInOffHand().getType());
assertTrue(f.core.notices.getLast().message().contains("inspection"));
original.run(); original.run();
assertEquals(Material.PAPER, a.inventory.getItemInOffHand().getType(),
"An apparently identical replacement sheet is not process-local custody proof");
assertEquals(1, f.core.failures.size());
f.core.expectedFailure = IllegalStateException.class;
}
}
@Test void rejectedOriginalTailCallbackRetainsNarrowSameLifetimeRetryAuthority() throws Exception {
var entered = new java.util.concurrent.CountDownLatch(1);
var release = new java.util.concurrent.CountDownLatch(1);
var writes = new java.util.concurrent.atomic.AtomicInteger();
try (var f = new Fixture((source, target) -> {
if (writes.incrementAndGet() == 2) {
entered.countDown();
try { if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new java.io.IOException("Gate timeout"); }
catch (InterruptedException interrupted) { throw new java.io.IOException(interrupted); }
}
EquipmentSessionsNativeTest.move(source, target);
})) {
var a = f.actor; f.core.activate(a);
try {
f.dispatcher.accept(f.death(org.bukkit.damage.DamageType.GENERIC));
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
assertNull(f.core.state(a).pending());
f.core.rejectSchedule = true;
} finally { release.countDown(); }
f.core.characters.find(a.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS);
assertEquals(Material.PAPER, a.inventory.getItemInOffHand().getType());
assertEquals(1, f.core.failures.size()); f.core.expectedFailure = IllegalStateException.class;
f.core.rejectSchedule = false;
f.core.sessions.enter(a.player);
assertEquals(Material.WRITTEN_BOOK, a.inventory.getItemInOffHand().getType());
assertNull(f.core.state(a).pending());
assertEquals(1, a.teleports);
}
}
@Test void everyRegisteredDamageTypeUsesTheSameDeathBoundaryButOrdinaryAndPriorVetoesStayUntouched() throws Exception {
int causes = 0;
for (var type : org.bukkit.Registry.DAMAGE_TYPE) {
@@ -717,6 +821,11 @@ class EquipmentSessionEventsNativeTest {
assertEquals(1, f.core.failures.size()); assertInstanceOf(IllegalStateException.class, f.core.failures.getFirst().cause());
f.core.expectedFailure = IllegalStateException.class;
a.online = true; f.core.sessions.arrive(a.player); f.core.sessions.recover(a.player);
if (scenario == 3) {
f.core.sessions.enter(a.player);
assertEquals("DEFEAT_INSPECTION_REQUIRED", f.core.notices.getLast().code(),
"Reconnect loses same-lifetime Tail retry authority");
}
assertArrayEquals(before, a.snapshot()); assertTrue(f.core.tasks.isEmpty());
}
}
@@ -762,7 +871,7 @@ class EquipmentSessionEventsNativeTest {
assertTrue(event.isCancelled()); assertArrayEquals(before, a.snapshot());
assertTrue(a.marker().endsWith(exception ? "|RESTORING" : "|PACKED"));
assertNotNull(f.core.state(a).pending()); assertEquals(a.normalMaximum, a.health);
var callback = f.core.next(); callback.run(); callback.run();
assertTrue(f.core.tasks.isEmpty(), "Unrestored equipment never queues a Tail write");
assertArrayEquals(before, a.snapshot()); assertNotNull(f.core.state(a).pending());
assertTrue(f.core.characters.find(a.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().dead());
assertEquals(exception ? 2 : 1, f.core.failures.size()); f.core.expectedFailure = IllegalStateException.class;
@@ -790,6 +899,10 @@ class EquipmentSessionEventsNativeTest {
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
assertNull(f.core.state(a).pending(), "Gear receipt cleanup acknowledged first");
assertEquals(Material.PAPER, a.inventory.getItemInOffHand().getType());
f.core.sessions.enter(a.player);
assertEquals("DEFEAT_PENDING", f.core.notices.getLast().code());
assertEquals(Material.PAPER, a.inventory.getItemInOffHand().getType(),
"Pending death persistence grants neither activation nor a Tail");
competingRead.run();
assertEquals("CHARACTER_BUSY", f.core.notices.getLast().code());
assertTrue(f.core.sessions.active(copy.id).isEmpty()); assertTrue(f.core.tasks.isEmpty());
@@ -824,7 +937,7 @@ class EquipmentSessionEventsNativeTest {
assertEquals(Material.DIAMOND_SWORD, a.inventory.getItem(0).getType());
assertEquals(sheet, a.inventory.getItemInOffHand());
} finally { release.countDown(); }
if (route == 0) f.core.tick();
if (route == 0) assertTrue(f.core.tasks.isEmpty(), "Quit invalidates delivery before any Tail write");
f.core.sessions.close(); f.core.sessions.drained().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
var dead = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(a.record.id()).orElseThrow();
assertTrue(dead.dead()); assertEquals(sheet, a.inventory.getItemInOffHand()); assertEquals(2, writes.get());
@@ -932,6 +1045,11 @@ class EquipmentSessionEventsNativeTest {
callback.run(); a.offhandMutation = () -> {}; a.beforeOffhandMutation = () -> {};
assertEquals(afterWrite ? Material.WRITTEN_BOOK : Material.PAPER, a.inventory.getItemInOffHand().getType());
var current = a.snapshot(); callback.run(); f.core.sessions.recover(a.player);
if (!afterWrite) {
f.core.sessions.enter(a.player);
assertEquals("DEFEAT_INSPECTION_REQUIRED", f.core.notices.getLast().code(),
"A possible native write never authorizes retained-sheet retry");
}
assertArrayEquals(current, a.snapshot()); assertTrue(f.core.tasks.isEmpty());
assertEquals(2, f.core.failures.size()); f.core.expectedFailure = IllegalStateException.class;
assertTrue(f.core.characters.find(a.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().dead());
@@ -942,7 +1060,8 @@ class EquipmentSessionEventsNativeTest {
try (var f = new Fixture()) {
var a = f.actor; f.core.activate(a); var control = a.inventory.getItem(0).clone();
a.inventory.setItem(0, new ItemStack(Material.EMERALD));
f.dispatcher.accept(f.death(org.bukkit.damage.DamageType.GENERIC)); f.core.tick();
f.dispatcher.accept(f.death(org.bukkit.damage.DamageType.GENERIC));
assertTrue(f.core.tasks.isEmpty(), "Failed restoration queues no Tail write");
assertNotNull(f.core.state(a).pending()); assertTrue(a.marker().endsWith("|PACKED"));
var dead = f.core.characters.find(a.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
a.inventory.setItem(20, a.inventory.getItem(0)); a.inventory.setItem(0, control);
@@ -285,7 +285,8 @@ class WholeCharacterTransferNativeTest {
}
if (ending == Ending.DEFEAT) {
if (restoration != Restoration.SUCCESS) h.f.expectedFailure = IllegalStateException.class;
h.f.tick(); // Actual asynchronous death acknowledgment / one-use Tail attempt.
if (restoration == Restoration.SUCCESS)
h.f.tick(); // Restoration acknowledgment permits the one-use Tail attempt.
var dead = h.saved(record.id()); assertTrue(dead.dead()); assertProgress(renamed, dead);
assertEquals(0, dead.health().signum());
assertEquals(restoration == Restoration.SUCCESS ? Material.WRITTEN_BOOK : Material.PAPER,