diff --git a/src/main/java/games/dmg/fantasystick/session/EquipmentSessions.java b/src/main/java/games/dmg/fantasystick/session/EquipmentSessions.java index 85076e8..0636c37 100644 --- a/src/main/java/games/dmg/fantasystick/session/EquipmentSessions.java +++ b/src/main/java/games/dmg/fantasystick/session/EquipmentSessions.java @@ -624,19 +624,22 @@ public final class EquipmentSessions implements AutoCloseable { if (hasRemnants(attempt.player) || !gameModes.available(attempt.player) || vitals != null && !vitals.available(attempt.player)) return false; var marker = EquipmentSessionMarker.read(attempt.player).orElse(null); - if (EquipmentSessionMarker.present(attempt.player)) { - // A terminal marker is resolved only by its matching completed file acknowledgment. - // Keep it until the next successful entry; worker acknowledgments never touch PDC. - var exit = state == null ? null : state.lastExit(); - if (marker == null || marker.phase() != EquipmentSessionMarker.Phase.RESTORED - || state == null || state.pending() != null || exit == null - || !marker.matches(exit.sessionId(), exit.characterId())) return false; - } + if (EquipmentSessionMarker.present(attempt.player) && !completedRestoredMarker(marker, state)) return false; attempt.entryMarker = marker; attempt.markerValidated = true; return true; } + private static boolean completedRestoredMarker(EquipmentSessionMarker.Token marker, + EquipmentSessionStore.State state) { + // A terminal marker is resolved only by its matching completed file acknowledgment. + // Keep it until the next successful entry; worker acknowledgments never touch PDC. + var exit = state == null ? null : state.lastExit(); + return marker != null && marker.phase() == EquipmentSessionMarker.Phase.RESTORED + && state != null && state.pending() == null && exit != null + && marker.matches(exit.sessionId(), exit.characterId()); + } + private static boolean hasRemnants(Player player) { return Arrays.stream(player.getInventory().getContents()).anyMatch(EquipmentSessions::remnant) || remnant(player.getItemOnCursor()); @@ -1940,7 +1943,9 @@ public final class EquipmentSessions implements AutoCloseable { var arrivingOwner = attempt.owner; var episode=downedDirectory==null?null:downedDirectory.all().stream().filter(value->value.player().equals(arrivingOwner)&&value.phase()==DownedEpisode.Phase.PENDING).findFirst().orElse(null); if (sheets.identify(attempt.player.getInventory().getItemInOffHand()).isPresent()) enter(attempt.player,episode); - else if (EquipmentSessionMarker.present(attempt.player) || hasRemnants(attempt.player)) { + else if (hasRemnants(attempt.player) + || (EquipmentSessionMarker.present(attempt.player) + && !completedRestoredMarker(EquipmentSessionMarker.read(attempt.player).orElse(null), state.orElse(null)))) { notice(attempt.owner, "NATIVE_RECOVERY_REQUIRED", "Native equipment remnants have no pending receipt; items were retained for inspection."); } return; // Ordinary no-sheet/no-receipt arrivals are not rejected entry attempts. diff --git a/src/nativeTest/java/games/dmg/fantasystick/EquipmentSessionEventsNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/EquipmentSessionEventsNativeTest.java index 196f055..61adf6d 100644 --- a/src/nativeTest/java/games/dmg/fantasystick/EquipmentSessionEventsNativeTest.java +++ b/src/nativeTest/java/games/dmg/fantasystick/EquipmentSessionEventsNativeTest.java @@ -1128,6 +1128,62 @@ class EquipmentSessionEventsNativeTest { } } + @Test void joinAfterCleanExitIsQuietAndPreservesPlayerAndReceiptState() throws Exception { + try (var f = new Fixture()) { + var a = f.actor; f.core.activate(a); + assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.RESTORED, + f.core.sessions.exit(a.player)); + var completed = f.core.state(a); + assertNull(completed.pending()); assertNotNull(completed.lastExit()); + a.inventory.setItemInOffHand(new ItemStack(Material.EMERALD)); + var before = a.snapshot(); var cursor = a.cursor.clone(); var marker = a.marker(); + var record = f.core.characters.find(a.record.id()).join().orElseThrow(); + int notices = f.core.notices.size(); + + f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(a.player, + (net.kyori.adventure.text.Component) null)); + f.core.tick(); + + assertEquals(notices, f.core.notices.size(), + "A matching completed RESTORED marker is clean arrival proof, not a recovery warning"); + assertArrayEquals(before, a.snapshot()); assertEquals(cursor, a.cursor); + assertEquals(marker, a.marker()); assertEquals(completed, f.core.state(a)); + assertEquals(record, f.core.characters.find(a.record.id()).join().orElseThrow()); + assertTrue(f.core.sessions.active(a.id).isEmpty()); assertTrue(f.ticks.isEmpty()); + } + } + + @Test void joinWarnsWithoutMutationForUnsafeMarkersAndGenuineRemnants() throws Exception { + for (int scenario = 0; scenario < 4; scenario++) try (var f = new Fixture()) { + var a = f.actor; f.core.activate(a); + var genuineBox = a.inventory.getItem(9).clone(); + assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.RESTORED, + f.core.sessions.exit(a.player)); + var completed = f.core.state(a); + assertNull(completed.pending()); assertNotNull(completed.lastExit()); + a.inventory.setItemInOffHand(new ItemStack(Material.EMERALD)); + if (scenario == 0) a.pdc.set(new org.bukkit.NamespacedKey("fantasystick", "equipment_session"), + org.bukkit.persistence.PersistentDataType.STRING, "malformed"); + if (scenario == 1) a.marker(java.util.UUID.randomUUID(), "RESTORED"); + if (scenario == 2) a.marker(completed.lastExit().sessionId(), "PACKED"); + if (scenario == 3) a.inventory.setItem(12, genuineBox); + var before = a.snapshot(); var cursor = a.cursor.clone(); var marker = a.marker(); + var record = f.core.characters.find(a.record.id()).join().orElseThrow(); + int notices = f.core.notices.size(); + + f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(a.player, + (net.kyori.adventure.text.Component) null)); + f.core.tick(); + + assertEquals(notices + 1, f.core.notices.size()); + assertEquals("NATIVE_RECOVERY_REQUIRED", f.core.notices.getLast().code()); + assertArrayEquals(before, a.snapshot()); assertEquals(cursor, a.cursor); + assertEquals(marker, a.marker()); assertEquals(completed, f.core.state(a)); + assertEquals(record, f.core.characters.find(a.record.id()).join().orElseThrow()); + assertTrue(f.core.sessions.active(a.id).isEmpty()); assertTrue(f.ticks.isEmpty()); + } + } + @Test void reservedWorldAndInventorySwapsRestoreBeforeExchangingOrdinaryGear() throws Exception { for (int route = 0; route < 3; route++) { try (var f = new Fixture()) {