From 6dc706b6ff32f6d063a20903eac1c53dd700e6d8 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Wed, 7 Oct 2026 13:40:35 -0400 Subject: [PATCH 1/2] fix(revival): preserve Tail authority and native custody --- .../dmg/fantasystick/FantasyStickPlugin.java | 4 +- .../dmg/fantasystick/ability/Revive.java | 169 +++-- .../events/AdminRevivalEventBridge.java | 27 +- .../events/ReviveEventBridge.java | 14 +- .../fantasystick/items/CharacterSheets.java | 20 + .../items/TailRevivalDelivery.java | 157 +++++ .../persistence/CharacterStorage.java | 17 +- .../persistence/RevivalStore.java | 79 ++- .../fantasystick/session/AdminRevival.java | 63 +- .../AdminRevivalEventsNativeTest.java | 17 +- .../fantasystick/AdminRevivalNativeTest.java | 10 +- .../dmg/fantasystick/ReviveNativeTest.java | 17 +- .../TailRevivalSafetyNativeTest.java | 635 ++++++++++++++++++ .../persistence/CharacterStorageTest.java | 34 + .../persistence/RevivalStoreTest.java | 76 +++ 15 files changed, 1206 insertions(+), 133 deletions(-) create mode 100644 src/main/java/games/dmg/fantasystick/items/TailRevivalDelivery.java create mode 100644 src/nativeTest/java/games/dmg/fantasystick/TailRevivalSafetyNativeTest.java diff --git a/src/main/java/games/dmg/fantasystick/FantasyStickPlugin.java b/src/main/java/games/dmg/fantasystick/FantasyStickPlugin.java index 023def2..9557392 100644 --- a/src/main/java/games/dmg/fantasystick/FantasyStickPlugin.java +++ b/src/main/java/games/dmg/fantasystick/FantasyStickPlugin.java @@ -563,7 +563,7 @@ public final class FantasyStickPlugin extends JavaPlugin { revivals = new games.dmg.fantasystick.persistence.RevivalStore(getDataFolder().toPath()); revive = new games.dmg.fantasystick.ability.Revive(characters, revivals, Clock.systemUTC(), nextTick, (size, title) -> getServer().createInventory(null, size, title), - problem -> getLogger().log(Level.WARNING, "Revive transition failed", problem)); + problem -> getLogger().log(Level.WARNING, "Revive transition failed", problem), getServer()::getPlayer); revive.attachDowned(downedController,downedSettings::protection); abilityRegistry.register(fireball.definition()); abilityRegistry.register(frostShard.definition()); @@ -638,7 +638,7 @@ public final class FantasyStickPlugin extends JavaPlugin { adminReviveSettings = new games.dmg.fantasystick.persistence.AdminReviveSettings(getDataFolder().toPath(), adminReviveTimeout); adminRevival = new games.dmg.fantasystick.session.AdminRevival(characters, revivals, Clock.systemUTC(), nextTick, abilityScheduler::later, (size, title) -> getServer().createInventory(null, size, title), - problem -> getLogger().log(Level.WARNING, "Admin revival failed", problem)); + problem -> getLogger().log(Level.WARNING, "Admin revival failed", problem), getServer()::getPlayer); abilityEngine = new games.dmg.fantasystick.ability.AbilityEngine(abilityRegistry, characters, sessions, parties, Clock.systemUTC(), nextTick, abilityScheduler, report -> {var current=dungeonController;if(current!=null)current.accept(report);}, combatFeedback, trainingDummyController, problem -> getLogger().log(Level.WARNING, "Ability engine failed for character " + problem.character() diff --git a/src/main/java/games/dmg/fantasystick/ability/Revive.java b/src/main/java/games/dmg/fantasystick/ability/Revive.java index b7fa883..9f5bd29 100644 --- a/src/main/java/games/dmg/fantasystick/ability/Revive.java +++ b/src/main/java/games/dmg/fantasystick/ability/Revive.java @@ -1,7 +1,7 @@ package games.dmg.fantasystick.ability; -import games.dmg.fantasystick.items.CharacterSheets; import games.dmg.fantasystick.items.TailBooks; +import games.dmg.fantasystick.items.TailRevivalDelivery; import games.dmg.fantasystick.persistence.CharacterRecord; import games.dmg.fantasystick.persistence.CharacterStorage; import games.dmg.fantasystick.persistence.RevivalStore; @@ -48,8 +48,8 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable { private final Consumer nextTick; private final PopupFactory popups; private final Consumer failures; + private final java.util.function.Function players; private final TailBooks tails = new TailBooks(); - private final CharacterSheets sheets = new CharacterSheets(); private games.dmg.fantasystick.session.DownedController downed; private games.dmg.fantasystick.events.DownedEventBridge downedEvents; private games.dmg.fantasystick.events.ReviveEventBridge reviveEvents; @@ -73,31 +73,36 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable { } void resolve(String outcome,String stage){if(cast!=null&&!eventResolved){eventResolved=true;reviveEvents.resolved(cast,outcome,stage);}} void fail(String reason){if(cast!=null&&!eventResolved){eventResolved=true;reviveEvents.failed(cast,reason);}} - void revived(RevivalStore.Reservation reservation,CharacterRecord dead,CharacterRecord restored){if(cast!=null&&!eventResolved){eventResolved=true;reviveEvents.revived(cast,reservation,dead,restored,target);}} + void revived(RevivalStore.Reservation reservation,CharacterRecord dead,CharacterRecord restored,games.dmg.fantasystick.events.ReviveEventBridge.Carrier carrier){if(cast!=null&&!eventResolved){eventResolved=true;reviveEvents.revived(cast,reservation,dead,restored,carrier);}} @Override public void close() { if (finished) return; finished = true; inventories.remove(inventory, this); targets.remove(target.getUniqueId(), this); inventory.clear(); if(!selecting)resolve("cancelled_or_expired","tail_selection_closed"); - try { target.closeInventory(); } catch (RuntimeException failure) { report(failure); } + try { if (!selecting) target.closeInventory(); } catch (RuntimeException failure) { report(failure); } } } public Revive(CharacterStorage characters, RevivalStore reservations, Clock clock, Consumer nextTick, - PopupFactory popups, Consumer failures) { + PopupFactory popups, Consumer failures, java.util.function.Function players) { this.characters = Objects.requireNonNull(characters); this.reservations = Objects.requireNonNull(reservations); this.clock = Objects.requireNonNull(clock); this.nextTick = Objects.requireNonNull(nextTick); this.popups = Objects.requireNonNull(popups); this.failures = Objects.requireNonNull(failures); + this.players = Objects.requireNonNull(players); } public void attachDowned(games.dmg.fantasystick.session.DownedController controller,java.util.function.Supplier protection){if(closed||downed!=null)throw new IllegalStateException("Downed Revive already attached or closed");downed=Objects.requireNonNull(controller);downedProtection=Objects.requireNonNull(protection);} public void attachDownedEvents(games.dmg.fantasystick.events.DownedEventBridge events){if(closed||downedEvents!=null)throw new IllegalStateException("Downed Revive events already attached or closed");downedEvents=Objects.requireNonNull(events);} public void attachEvents(games.dmg.fantasystick.events.ReviveEventBridge events){if(closed||reviveEvents!=null)throw new IllegalStateException("Revive events already attached or closed");reviveEvents=Objects.requireNonNull(events);} public void attachAdminRevivalEvents(games.dmg.fantasystick.events.AdminRevivalEventBridge events){if(closed||adminRevivalEvents!=null)throw new IllegalStateException("Admin revival events already attached or closed");adminRevivalEvents=Objects.requireNonNull(events);} - private void recoveryEvent(RevivalStore.Reservation reservation,CharacterRecord restored,Player target){ - if(reservation.administrative()){if(adminRevivalEvents!=null)adminRevivalEvents.recovered(reservation,restored,target);} - else if(reviveEvents!=null)reviveEvents.recovered(reservation,restored,target); + private Runnable recoveryAcknowledgment(RevivalStore.Reservation reservation, CharacterRecord restored, Player target) { + if (reservation.administrative()) { + var snapshot = adminRevivalEvents == null ? null : adminRevivalEvents.snapshot(target); + return () -> { if (snapshot != null) adminRevivalEvents.recovered(reservation, restored, snapshot); }; + } + var snapshot = reviveEvents == null ? null : reviveEvents.snapshot(target); + return () -> { if (snapshot != null) reviveEvents.recovered(reservation, restored, snapshot); }; } public AbilityDefinition definition() { @@ -216,7 +221,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable { if (event.getWhoClicked() != view.target || event.getRawSlot() < 0 || event.getRawSlot() >= view.inventory.getSize()) return; Choice choice = view.choices.get(event.getRawSlot()); if (choice == null || view.selecting) return; - view.selecting = true; inventories.remove(view.inventory, view); targets.remove(view.target.getUniqueId(), view); + view.selecting = true; inventories.remove(view.inventory, view); // Keep the selected operation under quit/close ownership. view.inventory.clear(); view.target.closeInventory(); confirm(view, choice); } @@ -236,7 +241,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable { @EventHandler(priority = EventPriority.MONITOR) public void quit(org.bukkit.event.player.PlayerQuitEvent event) { var targetView = targets.get(event.getPlayer().getUniqueId()); if (targetView != null) targetView.execution.finish(); - for (var view : List.copyOf(inventories.values())) { + for (var view : List.copyOf(targets.values())) { if (view.execution.input().player() == event.getPlayer()) view.execution.finish(); } } @@ -269,15 +274,24 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable { } var reservation = new RevivalStore.Reservation(UUID.randomUUID(), view.execution.character().id(), view.target.getUniqueId(), dead.id(), dead.deathId(), clock.instant().truncatedTo(ChronoUnit.SECONDS), - candidate.slot(), source.getAmount(), destination); + candidate.slot(), source.getAmount(), destination, false, dead.sheetGeneration()); reservations.reserve(reservation).whenComplete((reserved, failure) -> schedule(() -> { if (failure != null) {view.fail("reservation_save_failed"); report(failure); view.execution.input().player().sendMessage("Revive reservation could not be saved; no Tail changed."); view.execution.finish(); return; } if (reserved.isEmpty() || closed || !view.execution.stillCurrent() || !view.target.isOnline() - || !unchanged(view.target, candidate)) { + || !unchanged(view.target, candidate) + || !games.dmg.fantasystick.items.TailRevivalDelivery.destinationAvailable(view.target, reservation)) { + if (reserved.isPresent()) reservations.cancelReserved(reservation) + .exceptionally(error -> { report(error); return false; }); view.resolve("stale","tail_reservation");view.execution.input().player().sendMessage("Revive selection became stale after reservation; no Tail was changed."); view.execution.finish(); return; } - deliver(view, dead, reservation); + reservations.beginDelivery(reservation).whenComplete((begun, beginFailure) -> schedule(() -> { + if (beginFailure != null || begun.isEmpty()) { + if (beginFailure != null) report(beginFailure); + view.fail("delivery_fence_unavailable"); view.execution.finish(); return; + } + deliver(view, candidate, dead, begun.orElseThrow()); + })); })); } @@ -290,58 +304,45 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable { return -1; } - private void deliver(View view, CharacterRecord dead, RevivalStore.Reservation reservation) { + private boolean current(View view) { + return !closed && !view.finished && view.execution.stillCurrent() + && TailRevivalDelivery.currentRecipient(view.target, players); + } + + private void uncertain(View view) { + view.fail("tail_custody_uncertain"); + view.target.sendMessage("Revive could not acknowledge delivery safely; any pending custody proof is retained."); + view.execution.finish(); + } + + private void deliver(View view, Candidate candidate, CharacterRecord dead, RevivalStore.Reservation reservation) { try { - var provisional = new CharacterRecord(dead.id(), dead.name(), dead.revision(), dead.stats(), dead.level(), dead.experience(), - dead.unspentPoints(), dead.maximumHealth(), dead.learned(), dead.loadout(), dead.passives(), dead.cooldowns(), dead.restrictedKills(), - false, dead.deathId(), dead.diedAt(), dead.sheetGeneration()); - ItemStack sheet = sheets.create(provisional); - ItemStack source = view.target.getInventory().getItem(reservation.sourceSlot()); - if (reservation.sourceAmount() == 1) view.target.getInventory().setItem(reservation.sourceSlot(), sheet); - else { - view.target.getInventory().setItem(reservation.destinationSlot(), sheet); - var remaining = source.clone(); remaining.setAmount(reservation.sourceAmount() - 1); - view.target.getInventory().setItem(reservation.sourceSlot(), remaining); + if (!TailRevivalDelivery.deliver(view.target, candidate.captured(), dead, reservation, players, () -> current(view))) { + uncertain(view); return; } - view.target.updateInventory(); - } catch (RuntimeException uncertain) { - view.fail("native_tail_delivery_uncertain");report(uncertain); view.target.sendMessage("Revive item delivery is uncertain; retained proof requires recovery."); - view.execution.finish(); return; - } - var nativeLimit = games.dmg.fantasystick.session.NativeHealthLimit.read(); - characters.restoreDeathRecord(reservation.character(), reservation.death(), nativeLimit) + } catch (RuntimeException failure) { report(failure); uncertain(view); return; } + characters.restoreDeathRecord(reservation.character(), reservation.death(), + games.dmg.fantasystick.session.NativeHealthLimit.read(), reservation.sheetGeneration()) .whenComplete((restored, failure) -> schedule(() -> { if (failure != null || restored.isEmpty()) { - view.fail(failure!=null?"character_restore_failed":"character_restore_unavailable");if (failure != null) report(failure); - view.target.sendMessage("The Tail changed, but character restoration is pending recovery; do not move the sheet."); - view.execution.finish(); return; + if (failure != null) report(failure); + uncertain(view); return; } - if (installFinalSheet(view.target, restored.orElseThrow())) { - view.revived(reservation,dead,restored.orElseThrow());reservations.clear(reservation.character(), reservation.id()).exceptionally(failure2 -> { report(failure2); return false; }); - view.target.sendMessage("Restored character sheet: " + restored.orElseThrow().name() + "."); - view.execution.input().player().sendMessage("Revive restored " + restored.orElseThrow().name() + "."); - } else {view.fail("restored_but_sheet_custody_ambiguous");view.target.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");} - view.execution.finish(); + TailRevivalDelivery.complete(characters, reservations, nextTick, this::report, view.target, reservation, players, + () -> current(view), record -> { + var carrier = reviveEvents == null ? null : reviveEvents.snapshot(view.target); + return () -> { + view.revived(reservation, dead, record, carrier); + if (current(view)) { + view.target.sendMessage("Restored character sheet: " + record.name() + "."); + view.execution.input().player().sendMessage("Revive restored " + record.name() + "."); + } + view.execution.finish(); + }; + }, () -> uncertain(view)); })); } - private boolean installFinalSheet(Player target, CharacterRecord record) { - int slot = uniqueSheetSlot(target, record.id()); - if (slot < 0) return false; - target.getInventory().setItem(slot, sheets.create(record)); target.updateInventory(); return true; - } - - private int uniqueSheetSlot(Player target, UUID character) { - int found = -1; - for (int slot = 0; slot <= 40; slot++) { - if (slot > 35 && slot != 40) continue; - if (sheets.identify(target.getInventory().getItem(slot)).filter(character::equals).isEmpty()) continue; - if (found >= 0) return -2; - found = slot; - } - return found; - } - public void reconcile(Player target) { if (closed || !target.isOnline()) return; reservations.target(target.getUniqueId()).whenComplete((found, failure) -> { @@ -355,36 +356,30 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable { } private void reconcile(Player target, RevivalStore.Reservation reservation, CharacterRecord record) { - int foundSheet = uniqueSheetSlot(target, reservation.character()); - boolean hasSheet = foundSheet >= 0; - ItemStack source = target.getInventory().getItem(reservation.sourceSlot()); - boolean unchangedTail = source != null && source.getAmount() == reservation.sourceAmount() - && tails.identify(source).filter(id -> id.characterId().equals(reservation.character()) && id.deathId().equals(reservation.death())).isPresent(); - boolean consumedTail = reservation.sourceAmount() == 1 ? hasSheet - : source != null && source.getAmount() == reservation.sourceAmount() - 1 - && tails.identify(source).filter(id -> id.deathId().equals(reservation.death())).isPresent(); - if (!record.dead() && hasSheet && installFinalSheet(target, record)) { - recoveryEvent(reservation,record,target);reservations.clear(reservation.character(), reservation.id()); return; - } - if (record.dead() && unchangedTail && !hasSheet) { - reservations.clear(reservation.character(), reservation.id()); return; - } - if (record.dead() && hasSheet && (consumedTail || unchangedTail && reservation.sourceAmount() > 1)) { - if (unchangedTail && reservation.sourceAmount() > 1) { - var remaining = source.clone(); remaining.setAmount(source.getAmount() - 1); - target.getInventory().setItem(reservation.sourceSlot(), remaining); target.updateInventory(); - } - var nativeLimit = games.dmg.fantasystick.session.NativeHealthLimit.read(); - (reservation.administrative() - ? characters.restoreDeathRecordAdministratively(reservation.character(), reservation.death(), nativeLimit) - : characters.restoreDeathRecord(reservation.character(), reservation.death(), nativeLimit)) - .whenComplete((restored, failure) -> schedule(() -> { - if (failure != null) { report(failure); return; } - if (restored.isPresent() && installFinalSheet(target, restored.orElseThrow())) { - recoveryEvent(reservation,restored.orElseThrow(),target);reservations.clear(reservation.character(), reservation.id()); - } - })); + if (!TailRevivalDelivery.currentRecipient(target, players) || !TailRevivalDelivery.matchesRecord(record, reservation)) return; + if (reservation.phase() == RevivalStore.DeliveryPhase.RESERVED) { + if (record.dead() && TailRevivalDelivery.provablyUnperformed(target, reservation)) + reservations.cancelReserved(reservation).exceptionally(error -> { report(error); return false; }); + return; } + if (reservation.phase() != RevivalStore.DeliveryPhase.DELIVERY_STARTED + || TailRevivalDelivery.uniqueProofSlot(target, reservation) < 0 + || !TailRevivalDelivery.consumedTail(target, reservation)) return; + if (!record.dead()) { completeRecovery(target, reservation); return; } + var nativeLimit = games.dmg.fantasystick.session.NativeHealthLimit.read(); + (reservation.administrative() + ? characters.restoreDeathRecordAdministratively(reservation.character(), reservation.death(), nativeLimit, reservation.sheetGeneration()) + : characters.restoreDeathRecord(reservation.character(), reservation.death(), nativeLimit, reservation.sheetGeneration())) + .whenComplete((restored, failure) -> schedule(() -> { + if (failure != null) { report(failure); return; } + if (restored.isPresent()) completeRecovery(target, reservation); + })); + } + + private void completeRecovery(Player target, RevivalStore.Reservation reservation) { + TailRevivalDelivery.complete(characters, reservations, nextTick, this::report, target, reservation, players, + () -> !closed && TailRevivalDelivery.currentRecipient(target, players), + record -> recoveryAcknowledgment(reservation, record, target), () -> {}); } private void schedule(Runnable action) { @@ -394,7 +389,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable { @Override public void close() { if (closed) return; closed = true; - for (var view : List.copyOf(inventories.values())) view.execution.finish(); + for (var view : List.copyOf(targets.values())) view.execution.finish(); inventories.clear(); targets.clear(); } } diff --git a/src/main/java/games/dmg/fantasystick/events/AdminRevivalEventBridge.java b/src/main/java/games/dmg/fantasystick/events/AdminRevivalEventBridge.java index 8756a93..2e13e46 100644 --- a/src/main/java/games/dmg/fantasystick/events/AdminRevivalEventBridge.java +++ b/src/main/java/games/dmg/fantasystick/events/AdminRevivalEventBridge.java @@ -27,6 +27,12 @@ public final class AdminRevivalEventBridge implements AutoCloseable { EventInput.Location targetLocation, games.dmg.fantasystick.session.EquipmentSessions.Active targetSession) { } + public record Target(String name, EventInput.Location location, + games.dmg.fantasystick.session.EquipmentSessions.Active session) { } + public Target snapshot(Player target) { + return new Target(target.getName(), safeLocation(target), sessions.active(target.getUniqueId()).orElse(null)); + } + private final Function> sink; private final FeatureEventOutbox outbox; private final Clock clock; @@ -63,9 +69,14 @@ public final class AdminRevivalEventBridge implements AutoCloseable { public void revived(Selection selection, RevivalStore.Reservation reservation, CharacterRecord dead, CharacterRecord restored, Player target) { + revived(selection, reservation, dead, restored, target.getName()); + } + + public void revived(Selection selection, RevivalStore.Reservation reservation, + CharacterRecord dead, CharacterRecord restored, String targetName) { if (selection == null || selection.actor() == null) return; try { - var data = revivalData(reservation, restored, target); + var data = revivalData(reservation, restored, targetName); data.put("revision_before", dead.revision().toString()); data.put("death_time", dead.diedAt().toString()); data.put("death_age_seconds", Long.toString(Math.max(0, Duration.between(dead.diedAt(), clock.instant()).getSeconds()))); @@ -82,9 +93,13 @@ public final class AdminRevivalEventBridge implements AutoCloseable { /** Reconciliation has the reservation's exact admin UUID, but never invents its old location or name. */ public void recovered(RevivalStore.Reservation reservation, CharacterRecord restored, Player target) { + recovered(reservation, restored, snapshot(target)); + } + + public void recovered(RevivalStore.Reservation reservation, CharacterRecord restored, Target target) { if (!reservation.administrative()) return; try { - var data = revivalData(reservation, restored, target); + var data = revivalData(reservation, restored, target.name()); data.put("actor_kind", "system"); data.put("admin_player_id", reservation.casterCharacter().toString()); data.put("admin_name_status", "unavailable"); @@ -94,18 +109,18 @@ public final class AdminRevivalEventBridge implements AutoCloseable { data.put("death_age_seconds", Long.toString(Math.max(0, Duration.between(restored.diedAt(), reservation.createdAt()).getSeconds()))); data.put("recovery_reconciliation", true); - location(data, "target_location", safeLocation(target)); - targetSession(data, sessions.active(target.getUniqueId()).orElse(null)); + location(data, "target_location", target.location()); + targetSession(data, target.session()); durable("character.revived", reservation.id(), null, data); } catch (RuntimeException ignored) { } } - private Map revivalData(RevivalStore.Reservation reservation, CharacterRecord restored, Player target) { + private Map revivalData(RevivalStore.Reservation reservation, CharacterRecord restored, String targetName) { var data = new LinkedHashMap(); data.put("revival_kind", "admin_tail"); data.put("revival_operation_id", reservation.id().toString()); data.put("target_player_id", reservation.targetPlayer().toString()); - data.put("target_player_name", target.getName()); + data.put("target_player_name", targetName); data.put("character_id", restored.id().toString()); data.put("character_name", restored.name()); data.put("death_id", reservation.death().toString()); diff --git a/src/main/java/games/dmg/fantasystick/events/ReviveEventBridge.java b/src/main/java/games/dmg/fantasystick/events/ReviveEventBridge.java index 6acb73c..345fe9c 100644 --- a/src/main/java/games/dmg/fantasystick/events/ReviveEventBridge.java +++ b/src/main/java/games/dmg/fantasystick/events/ReviveEventBridge.java @@ -21,15 +21,23 @@ public final class ReviveEventBridge { Instant castAt,Instant cooldownUntil,double range){ public Cast{Objects.requireNonNull(actionId);Objects.requireNonNull(caster);Objects.requireNonNull(casterCharacter);Objects.requireNonNull(casterCharacterName);Objects.requireNonNull(targetPlayer);Objects.requireNonNull(targetPlayerName);Objects.requireNonNull(targetLocation);Objects.requireNonNull(castAt);Objects.requireNonNull(cooldownUntil);} } + public record Carrier(UUID id, String name, EventInput.Location location, Map locationData) { } + public Carrier snapshot(Player carrier) { + var location = carrier.getLocation(); + return new Carrier(carrier.getUniqueId(), carrier.getName(), location(location), + locationData(location, clock.instant())); + } private final Consumer sink;private final Clock clock; public ReviveEventBridge(Consumer sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);} public Cast cast(AbilityExecution execution,Player target){var input=execution.input();var caster=execution.character();var now=clock.instant();var action=new Cast(UUID.randomUUID(),context(input.player(),input.session(),caster.name()),caster.id(),caster.name(),target.getUniqueId(),target.getName(),locationData(target.getLocation(),now),now,caster.cooldowns().get("revive"),input.player().getLocation().distance(target.getLocation()));emitAbility("ability.cast",action,"accepted","cooldown_committed",null);return action;} public void resolved(Cast cast,String outcome,String stage){emitAbility("ability.resolved",cast,outcome,stage,null);} public void failed(Cast cast,String reason){emitAbility("ability.resolved",cast,"failed","tail_restoration",reason);} - public void revived(Cast cast,RevivalStore.Reservation reservation,CharacterRecord dead,CharacterRecord restored,Player carrier){revived(cast,reservation,dead,restored,carrier,false);} - public void recovered(RevivalStore.Reservation reservation,CharacterRecord restored,Player carrier){if(reservation.administrative())return;revived(null,reservation,null,restored,carrier,true);} + public void revived(Cast cast,RevivalStore.Reservation reservation,CharacterRecord dead,CharacterRecord restored,Player carrier){revived(cast,reservation,dead,restored,snapshot(carrier),false);} + public void revived(Cast cast,RevivalStore.Reservation reservation,CharacterRecord dead,CharacterRecord restored,Carrier carrier){revived(cast,reservation,dead,restored,carrier,false);} + public void recovered(RevivalStore.Reservation reservation,CharacterRecord restored,Player carrier){recovered(reservation,restored,snapshot(carrier));} + public void recovered(RevivalStore.Reservation reservation,CharacterRecord restored,Carrier carrier){if(reservation.administrative())return;revived(null,reservation,null,restored,carrier,true);} - private void revived(Cast cast,RevivalStore.Reservation reservation,CharacterRecord dead,CharacterRecord restored,Player carrier,boolean recovery){var data=new LinkedHashMap();data.put("revival_kind","learned_tail");data.put("selected_branch","tail");data.put("reservation_id",reservation.id().toString());data.put("death_id",reservation.death().toString());data.put("tail_id",reservation.death().toString());data.put("caster_character_id",reservation.casterCharacter().toString());if(cast!=null){data.put("caster_player_id",cast.caster().playerId().toString());data.put("caster_player_name",cast.caster().playerName());data.put("cast_action_id",cast.actionId().toString());}data.put("carrier_player_id",carrier.getUniqueId().toString());data.put("carrier_player_name",carrier.getName());data.put("source_slot",reservation.sourceSlot());data.put("destination_slot",reservation.sourceAmount()==1?reservation.sourceSlot():reservation.destinationSlot());data.put("source_quantity_before",Integer.toString(reservation.sourceAmount()));data.put("source_quantity_after",Integer.toString(reservation.sourceAmount()-1));data.put("consumed_quantity","1");data.put("restored_character_id",restored.id().toString());data.put("restored_character_name",restored.name());data.put("restored_revision",restored.revision().toString());data.put("restored_health",restored.health().toPlainString());data.put("maximum_health",restored.maximumHealth().toPlainString());data.put("no_auto_entry",true);data.put("recovery_reconciliation",recovery);data.put("outcome","acknowledged");data.put("carrier_location",locationData(carrier.getLocation(),clock.instant()));var context=new EventInput.Context(carrier.getUniqueId(),carrier.getName(),null,null,null,location(carrier.getLocation()));emit(id(reservation.id(),"character-revived"),reservation.id(),reservation.createdAt(),"character.revived",context,data);if(cast!=null)emitAbility("ability.resolved",cast,"restored","tail_restoration_acknowledged",null,Map.of("reservation_id",reservation.id().toString(),"death_id",reservation.death().toString(),"restored_character_id",restored.id().toString()));} + private void revived(Cast cast,RevivalStore.Reservation reservation,CharacterRecord dead,CharacterRecord restored,Carrier carrier,boolean recovery){var data=new LinkedHashMap();data.put("revival_kind","learned_tail");data.put("selected_branch","tail");data.put("reservation_id",reservation.id().toString());data.put("death_id",reservation.death().toString());data.put("tail_id",reservation.death().toString());data.put("caster_character_id",reservation.casterCharacter().toString());if(cast!=null){data.put("caster_player_id",cast.caster().playerId().toString());data.put("caster_player_name",cast.caster().playerName());data.put("cast_action_id",cast.actionId().toString());}data.put("carrier_player_id",carrier.id().toString());data.put("carrier_player_name",carrier.name());data.put("source_slot",reservation.sourceSlot());data.put("destination_slot",reservation.sourceAmount()==1?reservation.sourceSlot():reservation.destinationSlot());data.put("source_quantity_before",Integer.toString(reservation.sourceAmount()));data.put("source_quantity_after",Integer.toString(reservation.sourceAmount()-1));data.put("consumed_quantity","1");data.put("restored_character_id",restored.id().toString());data.put("restored_character_name",restored.name());data.put("restored_revision",restored.revision().toString());data.put("restored_health",restored.health().toPlainString());data.put("maximum_health",restored.maximumHealth().toPlainString());data.put("no_auto_entry",true);data.put("recovery_reconciliation",recovery);data.put("outcome","acknowledged");data.put("carrier_location",carrier.locationData());var context=new EventInput.Context(carrier.id(),carrier.name(),null,null,null,carrier.location());emit(id(reservation.id(),"character-revived"),reservation.id(),reservation.createdAt(),"character.revived",context,data);if(cast!=null)emitAbility("ability.resolved",cast,"restored","tail_restoration_acknowledged",null,Map.of("reservation_id",reservation.id().toString(),"death_id",reservation.death().toString(),"restored_character_id",restored.id().toString()));} private void emitAbility(String type,Cast cast,String outcome,String stage,String reason){emitAbility(type,cast,outcome,stage,reason,Map.of());} private void emitAbility(String type,Cast cast,String outcome,String stage,String reason,Map extra){var data=new LinkedHashMap();data.put("ability_id","revive");data.put("selected_branch","tail");data.put("stage",stage);data.put("outcome",outcome);data.put("cooldown_until",cast.cooldownUntil());data.put("caster_character_id",cast.casterCharacter().toString());data.put("target_player_id",cast.targetPlayer().toString());data.put("target_player_name",cast.targetPlayerName());data.put("target_location",cast.targetLocation());data.put("final_range",Double.toString(cast.range()));data.put("line_of_sight",true);if(reason!=null)data.put("reason",reason);data.putAll(extra);emit(id(cast.actionId(),type),cast.actionId(),cast.castAt(),type,cast.caster(),data);} private EventInput.Context context(Player player,games.dmg.fantasystick.session.EquipmentSessions.Active active,String name){return new EventInput.Context(player.getUniqueId(),player.getName(),active.character(),name,active.session(),location(player.getLocation()));} diff --git a/src/main/java/games/dmg/fantasystick/items/CharacterSheets.java b/src/main/java/games/dmg/fantasystick/items/CharacterSheets.java index 494ac63..2e3f480 100644 --- a/src/main/java/games/dmg/fantasystick/items/CharacterSheets.java +++ b/src/main/java/games/dmg/fantasystick/items/CharacterSheets.java @@ -19,6 +19,7 @@ public final class CharacterSheets { private static final NamespacedKey VERSION = new NamespacedKey("fantasystick", "sheet_version"); private static final NamespacedKey CHARACTER = new NamespacedKey("fantasystick", "character_id"); private static final NamespacedKey GENERATION = new NamespacedKey("fantasystick", "sheet_generation"); + private static final NamespacedKey REVIVAL = new NamespacedKey("fantasystick", "tail_revival_operation"); private static final NamespacedKey RECOVERY = new NamespacedKey("fantasystick", "boss_recovery_operation"); private static final List INSTRUCTION = List.of(Component.text("Character sheet:"), Component.text("Place in off hand"), Component.text("to enter fantasy world.")); @@ -58,6 +59,25 @@ public final class CharacterSheets { public boolean hasRecovery(ItemStack item,UUID operation){if(identify(item).isEmpty())return false;return operation.toString().equals(item.getItemMeta().getPersistentDataContainer().get(RECOVERY,PersistentDataType.STRING));} public void clearRecovery(ItemStack item){if(item==null||!item.hasItemMeta())return;var meta=item.getItemMeta();meta.getPersistentDataContainer().remove(RECOVERY);item.setItemMeta(meta);} + public void markRevival(ItemStack item, UUID operation) { + if (identity(item).filter(value -> !value.legacy()).isEmpty()) + throw new IllegalArgumentException("Not an explicitly generated character sheet"); + var meta = item.getItemMeta(); + meta.getPersistentDataContainer().set(REVIVAL, PersistentDataType.STRING, operation.toString()); + item.setItemMeta(meta); + } + + public boolean hasRevival(ItemStack item, UUID operation) { + return identify(item).isPresent() && operation.toString().equals( + item.getItemMeta().getPersistentDataContainer().get(REVIVAL, PersistentDataType.STRING)); + } + + public void clearRevival(ItemStack item) { + if (item == null || !item.hasItemMeta()) return; + var meta = item.getItemMeta(); + meta.getPersistentDataContainer().remove(REVIVAL); item.setItemMeta(meta); + } + /** Adds only the cosmetic model association to an already authentic sheet. */ public boolean applyModel(ItemStack item) { if (identify(item).isEmpty()) return false; diff --git a/src/main/java/games/dmg/fantasystick/items/TailRevivalDelivery.java b/src/main/java/games/dmg/fantasystick/items/TailRevivalDelivery.java new file mode 100644 index 0000000..6750f38 --- /dev/null +++ b/src/main/java/games/dmg/fantasystick/items/TailRevivalDelivery.java @@ -0,0 +1,157 @@ +package games.dmg.fantasystick.items; + +import games.dmg.fantasystick.persistence.CharacterRecord; +import games.dmg.fantasystick.persistence.CharacterStorage; +import games.dmg.fantasystick.persistence.RevivalStore; +import java.util.function.BooleanSupplier; +import java.util.function.Consumer; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +/** Feature-scoped native custody proof shared by the existing learned and admin Tail routes. */ +public final class TailRevivalDelivery { + private static final CharacterSheets SHEETS = new CharacterSheets(); + private static final TailBooks TAILS = new TailBooks(); + private TailRevivalDelivery() { } + + public static boolean currentRecipient(Player target, java.util.function.Function players) { + return target.isOnline() && players.apply(target.getUniqueId()) == target; + } + + public static boolean destinationAvailable(Player target, RevivalStore.Reservation reservation) { + if (reservation.sourceAmount() == 1) return reservation.destinationSlot() == -1; + var item = target.getInventory().getItem(reservation.destinationSlot()); + return item == null || item.isEmpty(); + } + + public static boolean matchesRecord(CharacterRecord record, RevivalStore.Reservation reservation) { + return reservation.sheetGeneration() != null && record.id().equals(reservation.character()) + && record.sheetGeneration().equals(reservation.sheetGeneration()) + && reservation.death().equals(record.deathId()); + } + + public static ItemStack provisional(CharacterRecord dead, RevivalStore.Reservation reservation) { + if (!dead.dead() || !matchesRecord(dead, reservation) + || reservation.phase() != RevivalStore.DeliveryPhase.DELIVERY_STARTED) + throw new IllegalArgumentException("Unbound Tail revival"); + var living = new CharacterRecord(dead.id(), dead.name(), dead.revision(), dead.stats(), dead.level(), + dead.experience(), dead.unspentPoints(), dead.maximumHealth(), dead.learned(), dead.loadout(), + dead.passives(), dead.cooldowns(), dead.restrictedKills(), false, dead.deathId(), dead.diedAt(), + // Death-only escrow remains in the saved dead record until restore commits; a living + // reference projection must satisfy the same zero-escrow invariant as saved restoration. + dead.coins(), dead.coinsInitialized(), dead.rewardReceipts(), java.math.BigInteger.ZERO, dead.gear(), + dead.sheetGeneration()); + var sheet = SHEETS.create(living); SHEETS.markRevival(sheet, reservation.id()); return sheet; + } + + public static boolean proves(ItemStack item, RevivalStore.Reservation reservation) { + return reservation.phase() == RevivalStore.DeliveryPhase.DELIVERY_STARTED + && SHEETS.identity(item).filter(value -> !value.legacy() + && value.character().equals(reservation.character()) + && value.generation().equals(reservation.sheetGeneration())).isPresent() + && SHEETS.hasRevival(item, reservation.id()); + } + + /** Count marked units, not UUID references. Revoked/unrelated same-character sheets stay untouched. */ + public static int uniqueProofSlot(Player target, RevivalStore.Reservation reservation) { + int found = -1; + for (int slot = 0; slot <= 40; slot++) { + if (slot > 35 && slot != 40) continue; + var item = target.getInventory().getItem(slot); + if (!proves(item, reservation)) continue; + if (found >= 0 || item.getAmount() != 1) return -2; + found = slot; + } + return found; + } + + public static boolean unchangedTail(Player target, RevivalStore.Reservation reservation) { + var source = target.getInventory().getItem(reservation.sourceSlot()); + return source != null && source.getAmount() == reservation.sourceAmount() && matchingTail(source, reservation); + } + + public static boolean consumedTail(Player target, RevivalStore.Reservation reservation) { + var source = target.getInventory().getItem(reservation.sourceSlot()); + if (reservation.sourceAmount() == 1) return !matchingTail(source, reservation); + return source != null && source.getAmount() == reservation.sourceAmount() - 1 && matchingTail(source, reservation); + } + + private static boolean matchingTail(ItemStack item, RevivalStore.Reservation reservation) { + return TAILS.identify(item).filter(value -> value.characterId().equals(reservation.character()) + && value.deathId().equals(reservation.death())).isPresent(); + } + + /** A RESERVED journal proves no writes began; item absence alone never proves non-delivery. */ + public static boolean provablyUnperformed(Player target, RevivalStore.Reservation reservation) { + if (reservation.phase() != RevivalStore.DeliveryPhase.RESERVED || !unchangedTail(target, reservation)) return false; + for (int slot = 0; slot <= 40; slot++) { + if (slot > 35 && slot != 40) continue; + if (SHEETS.hasRevival(target.getInventory().getItem(slot), reservation.id())) return false; + } + return true; + } + + /** All native access runs on the caller's server thread, including reentrant setter postconditions. */ + public static boolean deliver(Player target, ItemStack captured, CharacterRecord dead, + RevivalStore.Reservation reservation, java.util.function.Function players, BooleanSupplier current) { + if (!current.getAsBoolean() || !currentRecipient(target, players) + || !captured.equals(target.getInventory().getItem(reservation.sourceSlot())) + || !destinationAvailable(target, reservation)) return false; + var sheet = provisional(dead, reservation); + if (reservation.sourceAmount() == 1) target.getInventory().setItem(reservation.sourceSlot(), sheet); + else { + target.getInventory().setItem(reservation.destinationSlot(), sheet); + // A setter may reenter plugin/native code. Never overwrite a changed source after it returns. + if (!current.getAsBoolean() || !currentRecipient(target, players) + || !captured.equals(target.getInventory().getItem(reservation.sourceSlot())) + || !sheet.equals(target.getInventory().getItem(reservation.destinationSlot()))) return false; + var remaining = captured.clone(); remaining.setAmount(reservation.sourceAmount() - 1); + target.getInventory().setItem(reservation.sourceSlot(), remaining); + } + target.updateInventory(); + return current.getAsBoolean() && currentRecipient(target, players) && consumedTail(target, reservation) + && uniqueProofSlot(target, reservation) >= 0; + } + + /** + * Reload both durable authorities before acknowledgment. The provisional reference already is the + * current-generation sheet: finalization does not mint/upgrade/replace another item. Only the + * conditional-clear winner may report success. The issued marker remains harmless final provenance. + * Later ordinary inventory movement is not an atomic part of the saved completion acknowledgment. + */ + public static void complete(CharacterStorage characters, RevivalStore reservations, Consumer nextTick, + Consumer failures, Player target, RevivalStore.Reservation reservation, java.util.function.Function players, + BooleanSupplier current, java.util.function.Function acknowledgment, Runnable uncertain) { + reservations.find(reservation.character()).whenComplete((saved, saveFailure) -> { + if (saveFailure != null || saved.isEmpty() || !saved.orElseThrow().equals(reservation)) { + schedule(nextTick, failures, () -> { if (saveFailure != null) failures.accept(saveFailure); uncertain.run(); }); + return; + } + characters.find(reservation.character()).whenComplete((found, loadFailure) -> schedule(nextTick, failures, () -> { + if (loadFailure != null) { failures.accept(loadFailure); uncertain.run(); return; } + if (!current.getAsBoolean() || !currentRecipient(target, players) || found.isEmpty()) { uncertain.run(); return; } + var record = found.orElseThrow(); + int slot = uniqueProofSlot(target, reservation); + if (record.dead() || !matchesRecord(record, reservation) || slot < 0 + || !consumedTail(target, reservation) + || !SHEETS.authorizes(target.getInventory().getItem(slot), record)) { uncertain.run(); return; } + final Runnable acknowledged; + try { acknowledged = java.util.Objects.requireNonNull(acknowledgment.apply(record)); } + catch (RuntimeException captureFailure) { failures.accept(captureFailure); uncertain.run(); return; } + reservations.clear(reservation).whenComplete((cleared, clearFailure) -> + schedule(nextTick, failures, () -> { + if (clearFailure != null) { failures.accept(clearFailure); uncertain.run(); return; } + // Successful clearance commits already proved historical completion. Later quit/expiry + // cannot turn it into unresolved custody after deleting its proof. Feedback is gated + // by each route; audit facts were captured above, while the native context was valid. + if (Boolean.TRUE.equals(cleared)) acknowledged.run(); + else uncertain.run(); + })); + })); + }); + } + + private static void schedule(Consumer nextTick, Consumer failures, Runnable action) { + try { nextTick.accept(action); } catch (RuntimeException rejected) { failures.accept(rejected); } + } +} diff --git a/src/main/java/games/dmg/fantasystick/persistence/CharacterStorage.java b/src/main/java/games/dmg/fantasystick/persistence/CharacterStorage.java index b6876dc..4184a4c 100644 --- a/src/main/java/games/dmg/fantasystick/persistence/CharacterStorage.java +++ b/src/main/java/games/dmg/fantasystick/persistence/CharacterStorage.java @@ -1017,17 +1017,27 @@ public final class CharacterStorage implements AutoCloseable { /** Native Revive supplies the current plugin-controlled health ceiling captured on the server thread. */ public CompletableFuture> restoreDeathRecord(UUID characterId, UUID deathId, java.math.BigDecimal nativeLimit) { - return restore(characterId, deathId, nativeLimit, true); + return restore(characterId, deathId, nativeLimit, true, null); } /** Administrator revival retains death identity/consume-once checks but deliberately ignores death age. */ public CompletableFuture> restoreDeathRecordAdministratively( UUID characterId, UUID deathId, java.math.BigDecimal nativeLimit) { - return restore(characterId, deathId, nativeLimit, false); + return restore(characterId, deathId, nativeLimit, false, null); + } + + public CompletableFuture> restoreDeathRecord(UUID characterId, UUID deathId, + java.math.BigDecimal nativeLimit, BigInteger expectedGeneration) { + return restore(characterId, deathId, nativeLimit, true, Objects.requireNonNull(expectedGeneration)); + } + + public CompletableFuture> restoreDeathRecordAdministratively(UUID characterId, + UUID deathId, java.math.BigDecimal nativeLimit, BigInteger expectedGeneration) { + return restore(characterId, deathId, nativeLimit, false, Objects.requireNonNull(expectedGeneration)); } private CompletableFuture> restore(UUID characterId, UUID deathId, - java.math.BigDecimal nativeLimit, boolean ageLimited) { + java.math.BigDecimal nativeLimit, boolean ageLimited, BigInteger expectedGeneration) { Objects.requireNonNull(characterId); Objects.requireNonNull(deathId); if (nativeLimit != null && nativeLimit.signum() <= 0) throw new IllegalArgumentException("Invalid revival health limit"); Instant selectedAt = clock.instant(); @@ -1037,6 +1047,7 @@ public final class CharacterStorage implements AutoCloseable { CharacterRecord old = found.orElseThrow(); Duration age = Duration.between(old.diedAt(), selectedAt); if (!deathId.equals(old.deathId()) || age.isNegative() + || expectedGeneration != null && !expectedGeneration.equals(old.sheetGeneration()) || ageLimited && age.compareTo(Duration.ofHours(24)) >= 0) return Optional.empty(); var health = nativeLimit == null ? old.maximumHealth() : old.maximumHealth().min(nativeLimit); var restored = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), diff --git a/src/main/java/games/dmg/fantasystick/persistence/RevivalStore.java b/src/main/java/games/dmg/fantasystick/persistence/RevivalStore.java index ae82378..3a8eff6 100644 --- a/src/main/java/games/dmg/fantasystick/persistence/RevivalStore.java +++ b/src/main/java/games/dmg/fantasystick/persistence/RevivalStore.java @@ -33,8 +33,30 @@ public final class RevivalStore implements AutoCloseable { "target-player-id", "character-id", "death-id", "created-at", "source-slot", "source-amount", "destination-slot"); private static final Set KEYS_V2 = java.util.stream.Stream.concat(KEYS_V1.stream(), java.util.stream.Stream.of("administrative")) .collect(java.util.stream.Collectors.toUnmodifiableSet()); + private static final Set KEYS_V3 = java.util.stream.Stream.concat(KEYS_V2.stream(), + java.util.stream.Stream.of("sheet-generation", "delivery-phase")) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + public enum DeliveryPhase { LEGACY, RESERVED, DELIVERY_STARTED } public record Reservation(UUID id, UUID casterCharacter, UUID targetPlayer, UUID character, UUID death, - Instant createdAt, int sourceSlot, int sourceAmount, int destinationSlot, boolean administrative) { + Instant createdAt, int sourceSlot, int sourceAmount, int destinationSlot, + boolean administrative, java.math.BigInteger sheetGeneration, DeliveryPhase phase) { + /** Old reservations deliberately carry no inferred generation or delivery authority. */ + public Reservation(UUID id, UUID casterCharacter, UUID targetPlayer, UUID character, UUID death, + Instant createdAt, int sourceSlot, int sourceAmount, int destinationSlot, boolean administrative) { + this(id, casterCharacter, targetPlayer, character, death, createdAt, sourceSlot, sourceAmount, + destinationSlot, administrative, null, DeliveryPhase.LEGACY); + } + public Reservation(UUID id, UUID casterCharacter, UUID targetPlayer, UUID character, UUID death, + Instant createdAt, int sourceSlot, int sourceAmount, int destinationSlot, + boolean administrative, java.math.BigInteger sheetGeneration) { + this(id, casterCharacter, targetPlayer, character, death, createdAt, sourceSlot, sourceAmount, + destinationSlot, administrative, Objects.requireNonNull(sheetGeneration), DeliveryPhase.RESERVED); + } + public Reservation deliveryStarted() { + if (phase != DeliveryPhase.RESERVED) throw new IllegalStateException("Delivery is not reserved"); + return new Reservation(id, casterCharacter, targetPlayer, character, death, createdAt, sourceSlot, + sourceAmount, destinationSlot, administrative, sheetGeneration, DeliveryPhase.DELIVERY_STARTED); + } public Reservation(UUID id, UUID casterCharacter, UUID targetPlayer, UUID character, UUID death, Instant createdAt, int sourceSlot, int sourceAmount, int destinationSlot) { this(id, casterCharacter, targetPlayer, character, death, createdAt, sourceSlot, sourceAmount, destinationSlot, false); @@ -42,6 +64,11 @@ public final class RevivalStore implements AutoCloseable { public Reservation { Objects.requireNonNull(id); Objects.requireNonNull(casterCharacter); Objects.requireNonNull(targetPlayer); Objects.requireNonNull(character); Objects.requireNonNull(death); Objects.requireNonNull(createdAt); + Objects.requireNonNull(phase); + if ((sheetGeneration == null) != (phase == DeliveryPhase.LEGACY) + || sheetGeneration != null && (sheetGeneration.signum() < 0 + || (sourceAmount == 1 ? destinationSlot != -1 : destinationSlot < 0))) + throw new IllegalArgumentException("Invalid revival authority"); if (createdAt.getNano() != 0 || sourceSlot < 0 || sourceSlot > 40 || sourceSlot > 35 && sourceSlot != 40 || sourceAmount < 1 || sourceAmount > 99 || destinationSlot < -1 || destinationSlot > 40 || destinationSlot > 35 && destinationSlot != 40 || destinationSlot == sourceSlot) { @@ -74,6 +101,25 @@ public final class RevivalStore implements AutoCloseable { }); } + /** Durable attempted-delivery fence precedes every native write; stale/legacy work cannot begin. */ + public CompletableFuture> beginDelivery(Reservation expected) { + Objects.requireNonNull(expected); + return submit(() -> { + var found = load(file(expected.character())); + if (expected.phase() != DeliveryPhase.RESERVED || found.isEmpty() + || !found.orElseThrow().equals(expected)) return Optional.empty(); + var started = expected.deliveryStarted(); + Path temporary = directory.resolve("." + expected.character() + "." + UUID.randomUUID() + ".tmp"); + try { + Files.writeString(temporary, encode(started), StandardCharsets.UTF_8, + StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); + Files.move(temporary, file(expected.character()), StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } finally { Files.deleteIfExists(temporary); } + return Optional.of(started); + }); + } + public CompletableFuture> find(UUID character) { Objects.requireNonNull(character); return submit(() -> load(file(character))); @@ -99,6 +145,23 @@ public final class RevivalStore implements AutoCloseable { }); } + /** A stale RESERVED callback cannot retire a later attempted-delivery phase of the same operation. */ + public CompletableFuture cancelReserved(Reservation expected) { + Objects.requireNonNull(expected); + if (expected.phase() != DeliveryPhase.RESERVED) return CompletableFuture.completedFuture(false); + return clear(expected); + } + + /** Completion/cancellation must match the full captured authority, not merely an operation UUID. */ + public CompletableFuture clear(Reservation expected) { + Objects.requireNonNull(expected); + return submit(() -> { + var found = load(file(expected.character())); + if (found.isEmpty() || !found.orElseThrow().equals(expected)) return false; + return Files.deleteIfExists(file(expected.character())); + }); + } + public CompletableFuture clear(UUID character, UUID reservation) { Objects.requireNonNull(character); Objects.requireNonNull(reservation); return submit(() -> { @@ -127,13 +190,15 @@ public final class RevivalStore implements AutoCloseable { var options = new LoaderOptions(); options.setAllowDuplicateKeys(false); options.setMaxAliasesForCollections(0); decoded = new Yaml(new SafeConstructor(options)).load(Files.readString(file, StandardCharsets.UTF_8)); } catch (RuntimeException invalid) { throw new IOException("Invalid revival reservation", invalid); } - if (!(decoded instanceof Map raw) || !(raw.keySet().equals(KEYS_V1) || raw.keySet().equals(KEYS_V2))) throw new IOException("Invalid revival reservation keys"); + if (!(decoded instanceof Map raw) || !(raw.keySet().equals(KEYS_V1) || raw.keySet().equals(KEYS_V2) || raw.keySet().equals(KEYS_V3))) throw new IOException("Invalid revival reservation keys"); try { - String schema=text(raw,"schema-version");if(!(schema.equals("1")&&raw.keySet().equals(KEYS_V1)||schema.equals("2")&&raw.keySet().equals(KEYS_V2)))throw new IllegalArgumentException("schema"); + String schema=text(raw,"schema-version");if(!(schema.equals("1")&&raw.keySet().equals(KEYS_V1)||schema.equals("2")&&raw.keySet().equals(KEYS_V2)||schema.equals("3")&&raw.keySet().equals(KEYS_V3)))throw new IllegalArgumentException("schema"); var value = new Reservation(uuid(raw, "reservation-id"), uuid(raw, "caster-character-id"), uuid(raw, "target-player-id"), uuid(raw, "character-id"), uuid(raw, "death-id"), Instant.parse(text(raw, "created-at")), integer(raw, "source-slot"), integer(raw, "source-amount"), - integer(raw, "destination-slot"), schema.equals("2") && bool(raw,"administrative")); + integer(raw, "destination-slot"), !schema.equals("1") && bool(raw,"administrative"), + schema.equals("3") ? new java.math.BigInteger(text(raw,"sheet-generation")) : null, + schema.equals("3") ? DeliveryPhase.valueOf(text(raw,"delivery-phase")) : DeliveryPhase.LEGACY); if (!file.getFileName().toString().equals(value.character() + ".yml")) throw new IllegalArgumentException("identity"); return Optional.of(value); } catch (RuntimeException invalid) { throw new IOException("Invalid revival reservation", invalid); } @@ -141,12 +206,16 @@ public final class RevivalStore implements AutoCloseable { private static String encode(Reservation value) { var map = new LinkedHashMap(); - map.put("schema-version", "2"); map.put("reservation-id", value.id().toString()); + map.put("schema-version", value.phase() == DeliveryPhase.LEGACY ? "2" : "3"); map.put("reservation-id", value.id().toString()); map.put("caster-character-id", value.casterCharacter().toString()); map.put("target-player-id", value.targetPlayer().toString()); map.put("character-id", value.character().toString()); map.put("death-id", value.death().toString()); map.put("created-at", value.createdAt().toString()); map.put("source-slot", Integer.toString(value.sourceSlot())); map.put("source-amount", Integer.toString(value.sourceAmount())); map.put("destination-slot", Integer.toString(value.destinationSlot())); map.put("administrative", Boolean.toString(value.administrative())); + if (value.phase() != DeliveryPhase.LEGACY) { + map.put("sheet-generation", value.sheetGeneration().toString()); + map.put("delivery-phase", value.phase().name()); + } return new Yaml().dump(map); } diff --git a/src/main/java/games/dmg/fantasystick/session/AdminRevival.java b/src/main/java/games/dmg/fantasystick/session/AdminRevival.java index 666fe1c..9c0bad8 100644 --- a/src/main/java/games/dmg/fantasystick/session/AdminRevival.java +++ b/src/main/java/games/dmg/fantasystick/session/AdminRevival.java @@ -1,8 +1,8 @@ package games.dmg.fantasystick.session; import games.dmg.fantasystick.events.AdminActionEventBridge; -import games.dmg.fantasystick.items.CharacterSheets; import games.dmg.fantasystick.items.TailBooks; +import games.dmg.fantasystick.items.TailRevivalDelivery; import games.dmg.fantasystick.persistence.CharacterRecord; import games.dmg.fantasystick.persistence.CharacterStorage; import games.dmg.fantasystick.persistence.RevivalStore; @@ -35,8 +35,11 @@ public final class AdminRevival implements Listener, AutoCloseable { private record Choice(Candidate candidate, CharacterRecord record) { } private final CharacterStorage characters; private final RevivalStore reservations; private final Clock clock; private final Consumer nextTick; private final Scheduler scheduler; private final PopupFactory popups; - private final Consumer failures; private final TailBooks tails=new TailBooks();private final CharacterSheets sheets=new CharacterSheets(); - private final Map views=new IdentityHashMap<>();private boolean closed; + private final Consumer failures; private final TailBooks tails=new TailBooks(); + private final java.util.function.Function players; + private final Map views=new IdentityHashMap<>(); + private final java.util.Set activeViews=java.util.Collections.newSetFromMap(new IdentityHashMap<>()); + private boolean closed; private games.dmg.fantasystick.events.AdminRevivalEventBridge auditEvents; private AdminActionEventBridge adminAudit; @@ -58,29 +61,65 @@ public final class AdminRevival implements Listener, AutoCloseable { private final class View implements AutoCloseable { final Player admin,target;final Inventory inventory;final Map choices;final Instant deadline;final AutoCloseable expiry;final AdminActionEventBridge.Action action;boolean selecting,finished; View(Player admin,Player target,Inventory inventory,Map choices,Instant deadline,AdminActionEventBridge.Action action){this.admin=admin;this.target=target;this.inventory=inventory;this.choices=choices;this.deadline=deadline;this.action=action;this.expiry=scheduler.later(Duration.between(clock.instant(),deadline),this::close);} - boolean current(){return !closed&&!finished&&admin.isOnline()&&admin.hasPermission(EquipmentAdminCommand.PERMISSION)&&target.isOnline()&&clock.instant().isBefore(deadline);} - @Override public void close(){if(finished)return;finished=true;views.remove(inventory,this);inventory.clear();try{expiry.close();}catch(Exception e){report(e);}try{admin.closeInventory();}catch(RuntimeException e){report(e);}} + boolean current(){return !closed&&!finished&&TailRevivalDelivery.currentRecipient(admin,players)&&admin.hasPermission(EquipmentAdminCommand.PERMISSION)&&TailRevivalDelivery.currentRecipient(target,players)&&clock.instant().isBefore(deadline);} + @Override public void close(){if(finished)return;finished=true;views.remove(inventory,this);activeViews.remove(this);inventory.clear();try{expiry.close();}catch(Exception e){report(e);}try{if(!selecting)admin.closeInventory();}catch(RuntimeException e){report(e);}} } - public AdminRevival(CharacterStorage characters,RevivalStore reservations,Clock clock,Consumer nextTick,Scheduler scheduler,PopupFactory popups,Consumer failures){this.characters=Objects.requireNonNull(characters);this.reservations=Objects.requireNonNull(reservations);this.clock=Objects.requireNonNull(clock);this.nextTick=Objects.requireNonNull(nextTick);this.scheduler=Objects.requireNonNull(scheduler);this.popups=Objects.requireNonNull(popups);this.failures=Objects.requireNonNull(failures);} + public AdminRevival(CharacterStorage characters,RevivalStore reservations,Clock clock,Consumer nextTick,Scheduler scheduler,PopupFactory popups,Consumer failures,java.util.function.Function players){this.characters=Objects.requireNonNull(characters);this.reservations=Objects.requireNonNull(reservations);this.clock=Objects.requireNonNull(clock);this.nextTick=Objects.requireNonNull(nextTick);this.scheduler=Objects.requireNonNull(scheduler);this.popups=Objects.requireNonNull(popups);this.failures=Objects.requireNonNull(failures);this.players=Objects.requireNonNull(players);} public void open(Player admin,Player target,Duration timeout){Objects.requireNonNull(admin);Objects.requireNonNull(target);if(closed||!admin.isOnline()||!admin.hasPermission(EquipmentAdminCommand.PERMISSION)||!target.isOnline()){admin.sendMessage("Admin revival refused: authorization or target is no longer available.");return;}var candidates=candidates(target);if(candidates.isEmpty()){admin.sendMessage("That player has no authentic direct-inventory Tails.");return;}var action=adminAudit==null?null:adminAudit.current();var loads=candidates.stream().map(c->characters.find(c.identity.characterId())).toList();CompletableFuture.allOf(loads.toArray(CompletableFuture[]::new)).whenComplete((ignored,failure)->schedule(()->{if(failure!=null){report(failure);admin.sendMessage("Admin revival could not inspect those Tails safely.");return;}if(closed||!admin.isOnline()||!admin.hasPermission(EquipmentAdminCommand.PERMISSION)||!target.isOnline())return;var choices=new ArrayList();for(int i=0;i candidates(Player target){var result=new ArrayList();for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40)continue;var item=target.getInventory().getItem(slot);if(item==null||item.isEmpty())continue;var identity=tails.identify(item);if(identity.isPresent())result.add(new Candidate(slot,item.clone(),identity.orElseThrow()));}return List.copyOf(result);} private boolean eligible(CharacterRecord record,TailBooks.Identity id){return record.dead()&&record.id().equals(id.characterId())&&Objects.equals(record.deathId(),id.deathId())&&!record.diedAt().isAfter(clock.instant());} private static boolean unchanged(Player target,Candidate candidate){var item=target.getInventory().getItem(candidate.slot);return item!=null&&item.equals(candidate.item);} - private void show(Player admin,Player target,List eligible,Duration timeout,AdminActionEventBridge.Action action){int size=Math.min(54,Math.max(9,((eligible.size()+8)/9)*9));var inventory=popups.create(size,Component.text("Choose a Tail for "+target.getName()));var choices=new LinkedHashMap();for(int i=0;i eligible,Duration timeout,AdminActionEventBridge.Action action){int size=Math.min(54,Math.max(9,((eligible.size()+8)/9)*9));var inventory=popups.create(size,Component.text("Choose a Tail for "+target.getName()));var choices=new LinkedHashMap();for(int i=0;i=view.inventory.getSize())return;var choice=view.choices.get(event.getRawSlot());if(choice==null||view.selecting)return;view.selecting=true;views.remove(view.inventory,view);if(adminAudit!=null)adminAudit.confirmation(view.action,view.admin.isOnline()&&view.admin.hasPermission(EquipmentAdminCommand.PERMISSION),"inventory_confirmation",null);view.inventory.clear();view.admin.closeInventory();confirm(view,choice);} @EventHandler(priority=EventPriority.HIGHEST) public void drag(org.bukkit.event.inventory.InventoryDragEvent event){var view=views.get(event.getView().getTopInventory());if(view!=null&&event.getRawSlots().stream().anyMatch(slot->slotschedule(()->{if(failure!=null){report(failure);outcome(view.action,AdminActionEventBridge.Outcome.FAILED,"operation_failed",Map.of());view.admin.sendMessage("Admin revival could not recheck that Tail; no item changed.");view.close();return;}if(!view.current()||found.isEmpty()||!eligible(found.orElseThrow(),choice.candidate.identity)||!unchanged(view.target,choice.candidate)){outcome(view.action,AdminActionEventBridge.Outcome.REFUSED,"stale",Map.of());view.admin.sendMessage("That Tail selection is stale; no item changed.");view.close();return;}reserve(view,choice.candidate,found.orElseThrow());}));} - private void reserve(View view,Candidate candidate,CharacterRecord dead){var source=view.target.getInventory().getItem(candidate.slot);int destination=source.getAmount()==1?-1:emptySlot(view.target,candidate.slot);if(source.getAmount()>1&&destination<0){outcome(view.action,AdminActionEventBridge.Outcome.REFUSED,"owner_refused",Map.of());view.admin.sendMessage("Admin revival needs one empty direct inventory slot to preserve copied Tails.");view.close();return;}var reservation=new RevivalStore.Reservation(UUID.randomUUID(),view.admin.getUniqueId(),view.target.getUniqueId(),dead.id(),dead.deathId(),clock.instant().truncatedTo(ChronoUnit.SECONDS),candidate.slot,source.getAmount(),destination,true);var audit=auditEvents==null?null:auditEvents.selection(view.admin,view.target);reservations.reserve(reservation).whenComplete((saved,failure)->schedule(()->{if(failure!=null){report(failure);outcome(view.action,AdminActionEventBridge.Outcome.UNCERTAIN,"operation_uncertain",Map.of("domain_operation_id",reservation.id()));view.admin.sendMessage("Admin revival reservation failed; no item changed.");view.close();return;}if(saved.isEmpty()||!view.current()||!unchanged(view.target,candidate)){outcome(view.action,saved.isEmpty()?AdminActionEventBridge.Outcome.REFUSED:AdminActionEventBridge.Outcome.UNCERTAIN,saved.isEmpty()?"stale":"operation_uncertain",saved.isEmpty()?Map.of():Map.of("domain_operation_id",reservation.id()));view.admin.sendMessage("Admin revival became stale after reservation; no item changed.");view.close();return;}deliver(view,dead,reservation,audit);}));} + private void reserve(View view,Candidate candidate,CharacterRecord dead){var source=view.target.getInventory().getItem(candidate.slot);int destination=source.getAmount()==1?-1:emptySlot(view.target,candidate.slot);if(source.getAmount()>1&&destination<0){outcome(view.action,AdminActionEventBridge.Outcome.REFUSED,"owner_refused",Map.of());view.admin.sendMessage("Admin revival needs one empty direct inventory slot to preserve copied Tails.");view.close();return;}var reservation=new RevivalStore.Reservation(UUID.randomUUID(),view.admin.getUniqueId(),view.target.getUniqueId(),dead.id(),dead.deathId(),clock.instant().truncatedTo(ChronoUnit.SECONDS),candidate.slot,source.getAmount(),destination,true,dead.sheetGeneration());var audit=auditEvents==null?null:auditEvents.selection(view.admin,view.target);reservations.reserve(reservation).whenComplete((saved,failure)->schedule(()->{if(failure!=null){report(failure);outcome(view.action,AdminActionEventBridge.Outcome.UNCERTAIN,"operation_uncertain",Map.of("domain_operation_id",reservation.id()));view.admin.sendMessage("Admin revival reservation failed; no item changed.");view.close();return;}if(saved.isEmpty()||!view.current()||!unchanged(view.target,candidate)||!games.dmg.fantasystick.items.TailRevivalDelivery.destinationAvailable(view.target,reservation)){if(saved.isPresent())reservations.cancelReserved(reservation).exceptionally(error->{report(error);return false;});outcome(view.action,saved.isEmpty()?AdminActionEventBridge.Outcome.REFUSED:AdminActionEventBridge.Outcome.UNCERTAIN,saved.isEmpty()?"stale":"operation_uncertain",saved.isEmpty()?Map.of():Map.of("domain_operation_id",reservation.id()));view.admin.sendMessage("Admin revival became stale after reservation; no item changed.");view.close();return;}reservations.beginDelivery(reservation).whenComplete((begun,beginFailure)->schedule(()->{if(beginFailure!=null||begun.isEmpty()){if(beginFailure!=null)report(beginFailure);uncertain(view,reservation);return;}deliver(view,candidate,dead,begun.orElseThrow(),audit);}));}));} private static int emptySlot(Player target,int source){for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40||slot==source)continue;var item=target.getInventory().getItem(slot);if(item==null||item.isEmpty())return slot;}return-1;} - private void deliver(View view,CharacterRecord dead,RevivalStore.Reservation reservation,games.dmg.fantasystick.events.AdminRevivalEventBridge.Selection audit){try{var provisional=new CharacterRecord(dead.id(),dead.name(),dead.revision(),dead.stats(),dead.level(),dead.experience(),dead.unspentPoints(),dead.maximumHealth(),dead.learned(),dead.loadout(),dead.passives(),dead.cooldowns(),dead.restrictedKills(),false,dead.deathId(),dead.diedAt(),dead.sheetGeneration());var sheet=sheets.create(provisional);var source=view.target.getInventory().getItem(reservation.sourceSlot());if(reservation.sourceAmount()==1)view.target.getInventory().setItem(reservation.sourceSlot(),sheet);else{view.target.getInventory().setItem(reservation.destinationSlot(),sheet);var remaining=source.clone();remaining.setAmount(reservation.sourceAmount()-1);view.target.getInventory().setItem(reservation.sourceSlot(),remaining);}view.target.updateInventory();}catch(RuntimeException uncertain){report(uncertain);outcome(view.action,AdminActionEventBridge.Outcome.UNCERTAIN,"operation_uncertain",Map.of("domain_operation_id",reservation.id()));view.admin.sendMessage("Admin revival delivery is uncertain; retained proof requires recovery.");view.close();return;}characters.restoreDeathRecordAdministratively(reservation.character(),reservation.death(),NativeHealthLimit.read()).whenComplete((restored,failure)->schedule(()->{if(failure!=null||restored.isEmpty()){if(failure!=null)report(failure);outcome(view.action,AdminActionEventBridge.Outcome.UNCERTAIN,"operation_uncertain",Map.of("domain_operation_id",reservation.id()));view.admin.sendMessage("The Tail changed, but restoration is pending proof-based recovery.");view.close();return;}boolean installed;try{installed=installFinal(view.target,restored.orElseThrow());}catch(RuntimeException uncertain){report(uncertain);outcome(view.action,AdminActionEventBridge.Outcome.UNCERTAIN,"operation_uncertain",Map.of("domain_operation_id",reservation.id()));view.admin.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");view.close();return;}if(installed){if(auditEvents!=null)auditEvents.revived(audit,reservation,dead,restored.orElseThrow(),view.target);reservations.clear(reservation.character(),reservation.id()).exceptionally(error->{report(error);return false;});outcome(view.action,AdminActionEventBridge.Outcome.COMPLETED,"acknowledged",Map.of("domain_operation_id",reservation.id(),"subject_id",dead.id(),"target_id",view.target.getUniqueId()));view.admin.sendMessage("Administratively restored "+restored.orElseThrow().name()+" for "+view.target.getName()+".");view.target.sendMessage("An administrator restored character sheet: "+restored.orElseThrow().name()+".");}else{outcome(view.action,AdminActionEventBridge.Outcome.UNCERTAIN,"operation_uncertain",Map.of("domain_operation_id",reservation.id()));view.admin.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");}view.close();}));} - private boolean installFinal(Player target,CharacterRecord record){int found=-1;for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40)continue;if(sheets.identify(target.getInventory().getItem(slot)).filter(record.id()::equals).isEmpty())continue;if(found>=0)return false;found=slot;}if(found<0)return false;target.getInventory().setItem(found,sheets.create(record));target.updateInventory();return sheets.authorizes(target.getInventory().getItem(found),record);} + private void uncertain(View view, RevivalStore.Reservation reservation) { + outcome(view.action, AdminActionEventBridge.Outcome.UNCERTAIN, "operation_uncertain", + Map.of("domain_operation_id", reservation.id())); + view.admin.sendMessage("Admin revival could not acknowledge delivery safely; any pending custody proof is retained."); + view.close(); + } + + private void deliver(View view, Candidate candidate, CharacterRecord dead, RevivalStore.Reservation reservation, + games.dmg.fantasystick.events.AdminRevivalEventBridge.Selection audit) { + try { + if (!TailRevivalDelivery.deliver(view.target, candidate.item, dead, reservation, players, view::current)) { + uncertain(view, reservation); return; + } + } catch (RuntimeException failure) { report(failure); uncertain(view, reservation); return; } + characters.restoreDeathRecordAdministratively(reservation.character(), reservation.death(), + NativeHealthLimit.read(), reservation.sheetGeneration()).whenComplete((restored, failure) -> schedule(() -> { + if (failure != null || restored.isEmpty()) { + if (failure != null) report(failure); + uncertain(view, reservation); return; + } + TailRevivalDelivery.complete(characters, reservations, nextTick, this::report, view.target, reservation, players, + view::current, record -> { + var targetName = view.target.getName(); + return () -> { + if (auditEvents != null) auditEvents.revived(audit, reservation, dead, record, targetName); + outcome(view.action, AdminActionEventBridge.Outcome.COMPLETED, "acknowledged", + Map.of("domain_operation_id", reservation.id(), "subject_id", dead.id(), + "target_id", reservation.targetPlayer())); + if (view.current()) { + view.admin.sendMessage("Administratively restored " + record.name() + " for " + targetName + "."); + view.target.sendMessage("An administrator restored character sheet: " + record.name() + "."); + } + view.close(); + }; + }, () -> uncertain(view, reservation)); + })); + } + private void schedule(Runnable action){try{nextTick.accept(action);}catch(RuntimeException rejected){report(rejected);}} private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}} - @Override public void close(){if(closed)return;closed=true;for(var view:List.copyOf(views.values()))view.close();views.clear();} + @Override public void close(){if(closed)return;closed=true;for(var view:List.copyOf(activeViews))view.close();views.clear();activeViews.clear();} } diff --git a/src/nativeTest/java/games/dmg/fantasystick/AdminRevivalEventsNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/AdminRevivalEventsNativeTest.java index 7ddbfd9..b63127d 100644 --- a/src/nativeTest/java/games/dmg/fantasystick/AdminRevivalEventsNativeTest.java +++ b/src/nativeTest/java/games/dmg/fantasystick/AdminRevivalEventsNativeTest.java @@ -53,6 +53,7 @@ class AdminRevivalEventsNativeTest { when(view.getTopInventory()).thenReturn(popup); when(view.getBottomInventory()).thenReturn(admin.core.inventory); when(view.getPlayer()).thenReturn(admin.player); + when(view.getType()).thenReturn(org.bukkit.event.inventory.InventoryType.CHEST); var click = new org.bukkit.event.inventory.InventoryClickEvent(view, InventoryType.SlotType.CONTAINER, 0, ClickType.LEFT, InventoryAction.PICKUP_ALL); f.dispatcher.accept(click); assertTrue(click.isCancelled()); @@ -60,7 +61,7 @@ class AdminRevivalEventsNativeTest { var reservations = (games.dmg.fantasystick.persistence.RevivalStore) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "revivals"); reservations.find(dead.id()).join(); - for (int i = 0; i < 4; i++) PartyBookNativeTest.drain(f); + TailRevivalSafetyNativeTest.settle(f); var saved = storage.find(dead.id()).get(5, TimeUnit.SECONDS).orElseThrow(); assertFalse(saved.dead()); assertEquals(dead.id(), new games.dmg.fantasystick.items.CharacterSheets() @@ -163,13 +164,14 @@ class AdminRevivalEventsNativeTest { when(view.getTopInventory()).thenReturn(popup); when(view.getBottomInventory()).thenReturn(admin.core.inventory); when(view.getPlayer()).thenReturn(admin.player); + when(view.getType()).thenReturn(org.bukkit.event.inventory.InventoryType.CHEST); f.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view, InventoryType.SlotType.CONTAINER, 0, ClickType.LEFT, InventoryAction.PICKUP_ALL)); PartyBookNativeTest.drain(f); var reservations = (games.dmg.fantasystick.persistence.RevivalStore) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "revivals"); reservations.find(dead.id()).join(); - for (int i = 0; i < 4; i++) PartyBookNativeTest.drain(f); + TailRevivalSafetyNativeTest.settle(f); assertTrue(reservations.find(dead.id()).join().isPresent()); assertFalse(admin.messages.stream().anyMatch(message -> message.startsWith("Administratively restored"))); target.core.mutation = () -> {}; @@ -198,12 +200,13 @@ class AdminRevivalEventsNativeTest { when(view.getTopInventory()).thenReturn(popup); when(view.getBottomInventory()).thenReturn(admin.core.inventory); when(view.getPlayer()).thenReturn(admin.player); + when(view.getType()).thenReturn(org.bukkit.event.inventory.InventoryType.CHEST); f.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view, InventoryType.SlotType.CONTAINER, 0, ClickType.LEFT, InventoryAction.PICKUP_ALL)); PartyBookNativeTest.drain(f); ((games.dmg.fantasystick.persistence.RevivalStore) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "revivals")).find(dead.id()).join(); - for (int i = 0; i < 4; i++) PartyBookNativeTest.drain(f); + TailRevivalSafetyNativeTest.settle(f); var record = ((games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters")) .find(dead.id()).get(5, TimeUnit.SECONDS).orElseThrow(); @@ -232,7 +235,10 @@ class AdminRevivalEventsNativeTest { var operation = UUID.randomUUID(); var reservation = new games.dmg.fantasystick.persistence.RevivalStore.Reservation(operation, adminId, target.core.id, dead.id(), dead.deathId(), Instant.now().truncatedTo(ChronoUnit.SECONDS), - 10, 1, -1, true); + 10, 1, -1, true, dead.sheetGeneration()).deliveryStarted(); + var bound = target.core.inventory.getItem(10).clone(); + new games.dmg.fantasystick.items.CharacterSheets().markRevival(bound, reservation.id()); + target.core.inventory.setItem(10, bound); try (var store = new games.dmg.fantasystick.persistence.RevivalStore(data)) { assertTrue(store.reserve(reservation).join().isPresent()); } @@ -241,7 +247,7 @@ class AdminRevivalEventsNativeTest { var store = (games.dmg.fantasystick.persistence.RevivalStore) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "revivals"); store.target(target.core.id).join(); - for (int i = 0; i < 4; i++) PartyBookNativeTest.drain(f); + TailRevivalSafetyNativeTest.settle(f); var storage = (games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"); assertFalse(storage.find(dead.id()).get(5, TimeUnit.SECONDS).orElseThrow().dead()); @@ -309,6 +315,7 @@ class AdminRevivalEventsNativeTest { when(view.getTopInventory()).thenReturn(popup); when(view.getBottomInventory()).thenReturn(admin.core.inventory); when(view.getPlayer()).thenReturn(admin.player); + when(view.getType()).thenReturn(org.bukkit.event.inventory.InventoryType.CHEST); f.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view, InventoryType.SlotType.CONTAINER, 0, ClickType.LEFT, InventoryAction.PICKUP_ALL)); PartyBookNativeTest.drain(f); diff --git a/src/nativeTest/java/games/dmg/fantasystick/AdminRevivalNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/AdminRevivalNativeTest.java index 77e4a06..a6f8d84 100644 --- a/src/nativeTest/java/games/dmg/fantasystick/AdminRevivalNativeTest.java +++ b/src/nativeTest/java/games/dmg/fantasystick/AdminRevivalNativeTest.java @@ -18,11 +18,11 @@ class AdminRevivalNativeTest { var failed=data.resolve("failed-write");java.nio.file.Files.createDirectories(failed);try(var f=new FantasyStickLifecycleNativeTest.Fixture(failed)){f.plugin.setEnabled(true);java.nio.file.Files.createDirectory(failed.resolve("config.yml"));var sender=new EquipmentAdminCommandNativeTest.Sender();f.command.execute(sender.port,"fantasystick",new String[]{"config","admin-revive-selection-timeout-seconds","90"});var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(sender.messages.getLast().contains("save failed"));f.command.execute(sender.port,"fantasystick",new String[]{"config","admin-revive-selection-timeout-seconds"});assertTrue(sender.messages.getLast().contains("=60"));} for(String invalid:java.util.List.of("0","-1","1.5","bad")){var dir=data.resolve(invalid.replace('.','_'));java.nio.file.Files.createDirectories(dir);java.nio.file.Files.writeString(dir.resolve("config.yml"),"admin-revive-selection-timeout-seconds: '"+invalid+"'\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){assertThrows(IllegalArgumentException.class,()->f.plugin.setEnabled(true));assertTrue(f.order.isEmpty());}}} @Test void registeredAdminSelectsAnExpiredDirectTailAndRestoresSameCharacterWithoutChangingTargetState()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var admin=new FantasyStickLifecycleNativeTest.Actor(data);var target=new FantasyStickLifecycleNativeTest.Actor(data);doReturn("Admin").when(admin.player).getName();doReturn("Target").when(target.player).getName();doReturn(true).when(admin.player).hasPermission("fantasystick.admin");f.players.put(admin.core.id,admin.player);f.players.put(target.core.id,target.player);f.activate(target);var dead=dead(data,Instant.now().minusSeconds(172800).truncatedTo(ChronoUnit.SECONDS));var tail=new games.dmg.fantasystick.items.TailBooks().create(dead);tail.setAmount(2);target.core.inventory.setItem(10,tail);var beforeHealth=target.core.health;var beforeMode=target.core.gameMode;var beforeLocation=target.core.location.clone(); - assertTrue(f.command.execute(admin.player,"fantasystick",new String[]{"revive","Target"}));var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");storage.find(dead.id()).get();f.tick();assertEquals(1,admin.inventories.size());assertTrue(target.inventories.isEmpty());var popup=admin.inventories.getFirst();assertEquals(1,java.util.stream.IntStream.range(0,popup.getSize()).filter(i->popup.getItem(i)!=null).count());var view=mock(org.bukkit.inventory.InventoryView.class);when(view.getTopInventory()).thenReturn(popup);when(view.getBottomInventory()).thenReturn(admin.core.inventory);when(view.getPlayer()).thenReturn(admin.player);var click=new org.bukkit.event.inventory.InventoryClickEvent(view,org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,0,org.bukkit.event.inventory.ClickType.LEFT,org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);f.dispatcher.accept(click);assertTrue(click.isCancelled());PartyBookNativeTest.drain(f);((games.dmg.fantasystick.persistence.RevivalStore)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"revivals")).find(dead.id()).join();for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);var restored=storage.find(dead.id()).get().orElseThrow();assertFalse(restored.dead());var sheets=new games.dmg.fantasystick.items.CharacterSheets();assertEquals(1,java.util.stream.IntStream.range(0,41).filter(slot->(slot<=35||slot==40)&&sheets.identify(target.core.inventory.getItem(slot)).filter(dead.id()::equals).isPresent()).count());assertEquals(1,target.core.inventory.getItem(10).getAmount());assertEquals(dead.deathId(),new games.dmg.fantasystick.items.TailBooks().identify(target.core.inventory.getItem(10)).orElseThrow().deathId());assertEquals(restored.maximumHealth(),restored.health());assertEquals(dead.stats(),restored.stats());assertEquals(dead.level(),restored.level());assertEquals(dead.experience(),restored.experience());assertEquals(dead.unspentPoints(),restored.unspentPoints());assertEquals(dead.learned(),restored.learned());assertEquals(dead.loadout(),restored.loadout());assertEquals(dead.cooldowns(),restored.cooldowns());f.dispatcher.accept(click);assertEquals(1,java.util.stream.IntStream.range(0,41).filter(slot->(slot<=35||slot==40)&&sheets.identify(target.core.inventory.getItem(slot)).filter(dead.id()::equals).isPresent()).count());assertEquals(1,target.core.inventory.getItem(10).getAmount());assertEquals(beforeHealth,target.core.health);assertEquals(beforeMode,target.core.gameMode);assertEquals(beforeLocation,target.core.location);assertEquals(target.core.record.id(),((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(target.core.id).orElseThrow().character());}} - @Test void failedCharacterRestoreRetainsOneSheetAndReservationWithoutFalseSuccess()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var admin=new FantasyStickLifecycleNativeTest.Actor(data);var target=new FantasyStickLifecycleNativeTest.Actor(data);doReturn("Admin").when(admin.player).getName();doReturn("Target").when(target.player).getName();doReturn(true).when(admin.player).hasPermission("fantasystick.admin");f.players.put(admin.core.id,admin.player);f.players.put(target.core.id,target.player);var dead=dead(data,Instant.now().minusSeconds(172800).truncatedTo(ChronoUnit.SECONDS));target.core.inventory.setItem(10,new games.dmg.fantasystick.items.TailBooks().create(dead));f.command.execute(admin.player,"fantasystick",new String[]{"revive","Target"});PartyBookNativeTest.drain(f);var popup=admin.inventories.getFirst();var corrupted=new java.util.concurrent.atomic.AtomicBoolean();target.core.mutation=()->{if(!corrupted.compareAndSet(false,true))return;try{var path=data.resolve("characters").resolve(dead.id()+".yml");java.nio.file.Files.delete(path);java.nio.file.Files.createDirectory(path);}catch(Exception e){throw new RuntimeException(e);}};var view=mock(org.bukkit.inventory.InventoryView.class);when(view.getTopInventory()).thenReturn(popup);when(view.getBottomInventory()).thenReturn(admin.core.inventory);when(view.getPlayer()).thenReturn(admin.player);f.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view,org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,0,org.bukkit.event.inventory.ClickType.LEFT,org.bukkit.event.inventory.InventoryAction.PICKUP_ALL));PartyBookNativeTest.drain(f);var revival=(games.dmg.fantasystick.persistence.RevivalStore)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"revivals");revival.find(dead.id()).join();for(int i=0;i<3;i++)PartyBookNativeTest.drain(f);assertEquals(dead.id(),new games.dmg.fantasystick.items.CharacterSheets().identify(target.core.inventory.getItem(10)).orElseThrow());assertTrue(revival.find(dead.id()).join().isPresent());assertFalse(admin.messages.stream().anyMatch(message->message.startsWith("Administratively restored")));target.core.mutation=()->{};}} - @Test void competingAdministratorsCanConsumeOneDeathOnlyOnce()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);var b=new FantasyStickLifecycleNativeTest.Actor(data);var target=new FantasyStickLifecycleNativeTest.Actor(data);doReturn("AdminA").when(a.player).getName();doReturn("AdminB").when(b.player).getName();doReturn("Target").when(target.player).getName();doReturn(true).when(a.player).hasPermission("fantasystick.admin");doReturn(true).when(b.player).hasPermission("fantasystick.admin");f.players.put(a.core.id,a.player);f.players.put(b.core.id,b.player);f.players.put(target.core.id,target.player);var dead=dead(data,Instant.now().minusSeconds(172800).truncatedTo(ChronoUnit.SECONDS));target.core.inventory.setItem(10,new games.dmg.fantasystick.items.TailBooks().create(dead));f.command.execute(a.player,"fantasystick",new String[]{"revive","Target"});f.command.execute(b.player,"fantasystick",new String[]{"revive","Target"});PartyBookNativeTest.drain(f);assertEquals(1,a.inventories.size());assertEquals(1,b.inventories.size());for(var admin:java.util.List.of(a,b)){var popup=admin.inventories.getFirst();var view=mock(org.bukkit.inventory.InventoryView.class);when(view.getTopInventory()).thenReturn(popup);when(view.getBottomInventory()).thenReturn(admin.core.inventory);when(view.getPlayer()).thenReturn(admin.player);f.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view,org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,0,org.bukkit.event.inventory.ClickType.LEFT,org.bukkit.event.inventory.InventoryAction.PICKUP_ALL));}PartyBookNativeTest.drain(f);var revival=(games.dmg.fantasystick.persistence.RevivalStore)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"revivals");revival.find(dead.id()).join();for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(storage.find(dead.id()).get().orElseThrow().dead());var sheets=new games.dmg.fantasystick.items.CharacterSheets();assertEquals(1,java.util.stream.IntStream.range(0,41).filter(slot->(slot<=35||slot==40)&&sheets.identify(target.core.inventory.getItem(slot)).filter(dead.id()::equals).isPresent()).count());}} + assertTrue(f.command.execute(admin.player,"fantasystick",new String[]{"revive","Target"}));var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");storage.find(dead.id()).get();f.tick();assertEquals(1,admin.inventories.size());assertTrue(target.inventories.isEmpty());var popup=admin.inventories.getFirst();assertEquals(1,java.util.stream.IntStream.range(0,popup.getSize()).filter(i->popup.getItem(i)!=null).count());var view=mock(org.bukkit.inventory.InventoryView.class);when(view.getTopInventory()).thenReturn(popup);when(view.getBottomInventory()).thenReturn(admin.core.inventory);when(view.getPlayer()).thenReturn(admin.player);when(view.getType()).thenReturn(org.bukkit.event.inventory.InventoryType.CHEST);var click=new org.bukkit.event.inventory.InventoryClickEvent(view,org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,0,org.bukkit.event.inventory.ClickType.LEFT,org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);f.dispatcher.accept(click);assertTrue(click.isCancelled());PartyBookNativeTest.drain(f);((games.dmg.fantasystick.persistence.RevivalStore)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"revivals")).find(dead.id()).join();TailRevivalSafetyNativeTest.settle(f);var restored=storage.find(dead.id()).get().orElseThrow();assertFalse(restored.dead());var sheets=new games.dmg.fantasystick.items.CharacterSheets();assertEquals(1,java.util.stream.IntStream.range(0,41).filter(slot->(slot<=35||slot==40)&&sheets.identify(target.core.inventory.getItem(slot)).filter(dead.id()::equals).isPresent()).count());assertEquals(1,target.core.inventory.getItem(10).getAmount());assertEquals(dead.deathId(),new games.dmg.fantasystick.items.TailBooks().identify(target.core.inventory.getItem(10)).orElseThrow().deathId());assertEquals(restored.maximumHealth(),restored.health());assertEquals(dead.stats(),restored.stats());assertEquals(dead.level(),restored.level());assertEquals(dead.experience(),restored.experience());assertEquals(dead.unspentPoints(),restored.unspentPoints());assertEquals(dead.learned(),restored.learned());assertEquals(dead.loadout(),restored.loadout());assertEquals(dead.cooldowns(),restored.cooldowns());f.dispatcher.accept(click);assertEquals(1,java.util.stream.IntStream.range(0,41).filter(slot->(slot<=35||slot==40)&&sheets.identify(target.core.inventory.getItem(slot)).filter(dead.id()::equals).isPresent()).count());assertEquals(1,target.core.inventory.getItem(10).getAmount());assertEquals(beforeHealth,target.core.health);assertEquals(beforeMode,target.core.gameMode);assertEquals(beforeLocation,target.core.location);assertEquals(target.core.record.id(),((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(target.core.id).orElseThrow().character());}} + @Test void failedCharacterRestoreRetainsOneSheetAndReservationWithoutFalseSuccess()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var admin=new FantasyStickLifecycleNativeTest.Actor(data);var target=new FantasyStickLifecycleNativeTest.Actor(data);doReturn("Admin").when(admin.player).getName();doReturn("Target").when(target.player).getName();doReturn(true).when(admin.player).hasPermission("fantasystick.admin");f.players.put(admin.core.id,admin.player);f.players.put(target.core.id,target.player);var dead=dead(data,Instant.now().minusSeconds(172800).truncatedTo(ChronoUnit.SECONDS));target.core.inventory.setItem(10,new games.dmg.fantasystick.items.TailBooks().create(dead));f.command.execute(admin.player,"fantasystick",new String[]{"revive","Target"});PartyBookNativeTest.drain(f);var popup=admin.inventories.getFirst();var corrupted=new java.util.concurrent.atomic.AtomicBoolean();target.core.mutation=()->{if(!corrupted.compareAndSet(false,true))return;try{var path=data.resolve("characters").resolve(dead.id()+".yml");java.nio.file.Files.delete(path);java.nio.file.Files.createDirectory(path);}catch(Exception e){throw new RuntimeException(e);}};var view=mock(org.bukkit.inventory.InventoryView.class);when(view.getTopInventory()).thenReturn(popup);when(view.getBottomInventory()).thenReturn(admin.core.inventory);when(view.getPlayer()).thenReturn(admin.player);when(view.getType()).thenReturn(org.bukkit.event.inventory.InventoryType.CHEST);f.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view,org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,0,org.bukkit.event.inventory.ClickType.LEFT,org.bukkit.event.inventory.InventoryAction.PICKUP_ALL));PartyBookNativeTest.drain(f);var revival=(games.dmg.fantasystick.persistence.RevivalStore)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"revivals");revival.find(dead.id()).join();TailRevivalSafetyNativeTest.settle(f);assertEquals(dead.id(),new games.dmg.fantasystick.items.CharacterSheets().identify(target.core.inventory.getItem(10)).orElseThrow());assertTrue(revival.find(dead.id()).join().isPresent());assertFalse(admin.messages.stream().anyMatch(message->message.startsWith("Administratively restored")));target.core.mutation=()->{};}} + @Test void competingAdministratorsCanConsumeOneDeathOnlyOnce()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);var b=new FantasyStickLifecycleNativeTest.Actor(data);var target=new FantasyStickLifecycleNativeTest.Actor(data);doReturn("AdminA").when(a.player).getName();doReturn("AdminB").when(b.player).getName();doReturn("Target").when(target.player).getName();doReturn(true).when(a.player).hasPermission("fantasystick.admin");doReturn(true).when(b.player).hasPermission("fantasystick.admin");f.players.put(a.core.id,a.player);f.players.put(b.core.id,b.player);f.players.put(target.core.id,target.player);var dead=dead(data,Instant.now().minusSeconds(172800).truncatedTo(ChronoUnit.SECONDS));target.core.inventory.setItem(10,new games.dmg.fantasystick.items.TailBooks().create(dead));f.command.execute(a.player,"fantasystick",new String[]{"revive","Target"});f.command.execute(b.player,"fantasystick",new String[]{"revive","Target"});PartyBookNativeTest.drain(f);assertEquals(1,a.inventories.size());assertEquals(1,b.inventories.size());for(var admin:java.util.List.of(a,b)){var popup=admin.inventories.getFirst();var view=mock(org.bukkit.inventory.InventoryView.class);when(view.getTopInventory()).thenReturn(popup);when(view.getBottomInventory()).thenReturn(admin.core.inventory);when(view.getPlayer()).thenReturn(admin.player);when(view.getType()).thenReturn(org.bukkit.event.inventory.InventoryType.CHEST);f.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view,org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,0,org.bukkit.event.inventory.ClickType.LEFT,org.bukkit.event.inventory.InventoryAction.PICKUP_ALL));}PartyBookNativeTest.drain(f);var revival=(games.dmg.fantasystick.persistence.RevivalStore)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"revivals");revival.find(dead.id()).join();TailRevivalSafetyNativeTest.settle(f);var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(storage.find(dead.id()).get().orElseThrow().dead());var sheets=new games.dmg.fantasystick.items.CharacterSheets();assertEquals(1,java.util.stream.IntStream.range(0,41).filter(slot->(slot<=35||slot==40)&&sheets.identify(target.core.inventory.getItem(slot)).filter(dead.id()::equals).isPresent()).count());}} @Test void counterfeitLivingAndLifecycleCancelledSelectionsNeverChangeItems()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var admin=new FantasyStickLifecycleNativeTest.Actor(data);var target=new FantasyStickLifecycleNativeTest.Actor(data);doReturn("Admin").when(admin.player).getName();doReturn("Target").when(target.player).getName();doReturn(true).when(admin.player).hasPermission("fantasystick.admin");f.players.put(admin.core.id,admin.player);f.players.put(target.core.id,target.player);target.core.inventory.setItem(10,new org.bukkit.inventory.ItemStack(org.bukkit.Material.WRITTEN_BOOK));f.command.execute(admin.player,"fantasystick",new String[]{"revive","Target"});assertTrue(admin.messages.getLast().contains("no authentic"));var livingTailRecord=dead(data,Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.SECONDS));var livingTail=new games.dmg.fantasystick.items.TailBooks().create(livingTailRecord);var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");storage.restoreDeathRecordAdministratively(livingTailRecord.id(),livingTailRecord.deathId(),new java.math.BigDecimal("1024")).get();target.core.inventory.setItem(10,livingTail);f.command.execute(admin.player,"fantasystick",new String[]{"revive","Target"});storage.find(livingTailRecord.id()).get();f.tick();assertTrue(admin.messages.getLast().contains("no current"));var dead=dead(data,Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.SECONDS));var tail=new games.dmg.fantasystick.items.TailBooks().create(dead);target.core.inventory.setItem(10,tail);f.command.execute(admin.player,"fantasystick",new String[]{"revive","Target"});storage.find(dead.id()).get();f.tick();var popup=admin.inventories.getFirst();f.plugin.setEnabled(false);assertNull(popup.getItem(0));assertEquals(tail,target.core.inventory.getItem(10));}} - @Test void administrativeReservationResumesAfterRestartWithoutWeakeningDeathIdentity()throws Exception{var target=new FantasyStickLifecycleNativeTest.Actor(data);doReturn("Target").when(target.player).getName();var dead=dead(data,Instant.now().minusSeconds(172800).truncatedTo(ChronoUnit.SECONDS));var provisional=new games.dmg.fantasystick.persistence.CharacterRecord(dead.id(),dead.name(),dead.revision(),dead.stats(),dead.level(),dead.experience(),dead.unspentPoints(),dead.maximumHealth(),dead.learned(),dead.loadout(),dead.cooldowns(),dead.restrictedKills(),false,dead.deathId(),dead.diedAt());target.core.inventory.setItem(10,new games.dmg.fantasystick.items.CharacterSheets().create(provisional));var reservation=new games.dmg.fantasystick.persistence.RevivalStore.Reservation(java.util.UUID.randomUUID(),java.util.UUID.randomUUID(),target.core.id,dead.id(),dead.deathId(),Instant.now().truncatedTo(ChronoUnit.SECONDS),10,1,-1,true);try(var store=new games.dmg.fantasystick.persistence.RevivalStore(data)){assertTrue(store.reserve(reservation).join().isPresent());}try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.players.put(target.core.id,target.player);f.plugin.setEnabled(true);var revivals=(games.dmg.fantasystick.persistence.RevivalStore)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"revivals");revivals.target(target.core.id).join();for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(storage.find(dead.id()).get().orElseThrow().dead());assertTrue(revivals.find(dead.id()).join().isEmpty());assertEquals(dead.id(),new games.dmg.fantasystick.items.CharacterSheets().identify(target.core.inventory.getItem(10)).orElseThrow());}} - @Test void multipleChoicesSelectExactItemWhilePermissionLossMovementAndTimeoutCancelWithoutMutation()throws Exception{for(String route:java.util.List.of("select-recent","select-second","permission","movement","timeout")){var dir=data.resolve(route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var admin=new FantasyStickLifecycleNativeTest.Actor(dir);var target=new FantasyStickLifecycleNativeTest.Actor(dir);doReturn("Admin").when(admin.player).getName();doReturn("Target").when(target.player).getName();doReturn(true).when(admin.player).hasPermission("fantasystick.admin");f.players.put(admin.core.id,admin.player);f.players.put(target.core.id,target.player);var first=dead(dir,Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.SECONDS));var second=dead(dir,Instant.now().minusSeconds(86400).truncatedTo(ChronoUnit.SECONDS));var firstTail=new games.dmg.fantasystick.items.TailBooks().create(first);var secondTail=new games.dmg.fantasystick.items.TailBooks().create(second);target.core.inventory.setItem(10,firstTail);target.core.inventory.setItem(11,secondTail);var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");f.command.execute(admin.player,"fantasystick",new String[]{"revive","Target"});storage.find(first.id()).get();f.tick();var popup=admin.inventories.getFirst();assertEquals(2,java.util.stream.IntStream.range(0,popup.getSize()).filter(i->popup.getItem(i)!=null).count());assertTrue(f.delayedTicks.contains(1200L));if(route.equals("timeout")){f.delayed.getLast().run();assertEquals(firstTail,target.core.inventory.getItem(10));assertEquals(secondTail,target.core.inventory.getItem(11));continue;}var view=mock(org.bukkit.inventory.InventoryView.class);when(view.getTopInventory()).thenReturn(popup);when(view.getBottomInventory()).thenReturn(admin.core.inventory);when(view.getPlayer()).thenReturn(admin.player);if(route.equals("permission"))doReturn(false).when(admin.player).hasPermission("fantasystick.admin");if(route.equals("movement"))target.core.inventory.setItem(11,new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND));int selected=route.equals("select-recent")?0:1;f.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view,org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,selected,org.bukkit.event.inventory.ClickType.LEFT,org.bukkit.event.inventory.InventoryAction.PICKUP_ALL));PartyBookNativeTest.drain(f);if(route.startsWith("select-")){var selectedRecord=route.equals("select-recent")?first:second;var revival=(games.dmg.fantasystick.persistence.RevivalStore)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"revivals");revival.find(selectedRecord.id()).join();for(int i=0;i<3;i++)PartyBookNativeTest.drain(f);assertEquals(!route.equals("select-recent"),storage.find(first.id()).get().orElseThrow().dead());assertEquals(route.equals("select-recent"),storage.find(second.id()).get().orElseThrow().dead());if(route.equals("select-recent")){assertEquals(first.id(),new games.dmg.fantasystick.items.CharacterSheets().identify(target.core.inventory.getItem(10)).orElseThrow());assertEquals(secondTail,target.core.inventory.getItem(11));}else{assertEquals(firstTail,target.core.inventory.getItem(10));assertEquals(second.id(),new games.dmg.fantasystick.items.CharacterSheets().identify(target.core.inventory.getItem(11)).orElseThrow());}}else{assertTrue(storage.find(first.id()).get().orElseThrow().dead());assertTrue(storage.find(second.id()).get().orElseThrow().dead());assertEquals(firstTail,target.core.inventory.getItem(10));assertEquals(route.equals("movement")?org.bukkit.Material.DIAMOND:org.bukkit.Material.WRITTEN_BOOK,target.core.inventory.getItem(11).getType());}}}} + @Test void administrativeReservationResumesAfterRestartWithoutWeakeningDeathIdentity()throws Exception{var target=new FantasyStickLifecycleNativeTest.Actor(data);doReturn("Target").when(target.player).getName();var dead=dead(data,Instant.now().minusSeconds(172800).truncatedTo(ChronoUnit.SECONDS));var provisional=new games.dmg.fantasystick.persistence.CharacterRecord(dead.id(),dead.name(),dead.revision(),dead.stats(),dead.level(),dead.experience(),dead.unspentPoints(),dead.maximumHealth(),dead.learned(),dead.loadout(),dead.cooldowns(),dead.restrictedKills(),false,dead.deathId(),dead.diedAt());target.core.inventory.setItem(10,new games.dmg.fantasystick.items.CharacterSheets().create(provisional));var reservation=new games.dmg.fantasystick.persistence.RevivalStore.Reservation(java.util.UUID.randomUUID(),java.util.UUID.randomUUID(),target.core.id,dead.id(),dead.deathId(),Instant.now().truncatedTo(ChronoUnit.SECONDS),10,1,-1,true,dead.sheetGeneration()).deliveryStarted();var bound=target.core.inventory.getItem(10).clone();new games.dmg.fantasystick.items.CharacterSheets().markRevival(bound,reservation.id());target.core.inventory.setItem(10,bound);try(var store=new games.dmg.fantasystick.persistence.RevivalStore(data)){assertTrue(store.reserve(reservation).join().isPresent());}try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.players.put(target.core.id,target.player);f.plugin.setEnabled(true);var revivals=(games.dmg.fantasystick.persistence.RevivalStore)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"revivals");revivals.target(target.core.id).join();TailRevivalSafetyNativeTest.settle(f);var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(storage.find(dead.id()).get().orElseThrow().dead());assertTrue(revivals.find(dead.id()).join().isEmpty());assertEquals(dead.id(),new games.dmg.fantasystick.items.CharacterSheets().identify(target.core.inventory.getItem(10)).orElseThrow());}} + @Test void multipleChoicesSelectExactItemWhilePermissionLossMovementAndTimeoutCancelWithoutMutation()throws Exception{for(String route:java.util.List.of("select-recent","select-second","permission","movement","timeout")){var dir=data.resolve(route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var admin=new FantasyStickLifecycleNativeTest.Actor(dir);var target=new FantasyStickLifecycleNativeTest.Actor(dir);doReturn("Admin").when(admin.player).getName();doReturn("Target").when(target.player).getName();doReturn(true).when(admin.player).hasPermission("fantasystick.admin");f.players.put(admin.core.id,admin.player);f.players.put(target.core.id,target.player);var first=dead(dir,Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.SECONDS));var second=dead(dir,Instant.now().minusSeconds(86400).truncatedTo(ChronoUnit.SECONDS));var firstTail=new games.dmg.fantasystick.items.TailBooks().create(first);var secondTail=new games.dmg.fantasystick.items.TailBooks().create(second);target.core.inventory.setItem(10,firstTail);target.core.inventory.setItem(11,secondTail);var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");f.command.execute(admin.player,"fantasystick",new String[]{"revive","Target"});storage.find(first.id()).get();f.tick();var popup=admin.inventories.getFirst();assertEquals(2,java.util.stream.IntStream.range(0,popup.getSize()).filter(i->popup.getItem(i)!=null).count());assertTrue(f.delayedTicks.contains(1200L));if(route.equals("timeout")){f.delayed.getLast().run();assertEquals(firstTail,target.core.inventory.getItem(10));assertEquals(secondTail,target.core.inventory.getItem(11));continue;}var view=mock(org.bukkit.inventory.InventoryView.class);when(view.getTopInventory()).thenReturn(popup);when(view.getBottomInventory()).thenReturn(admin.core.inventory);when(view.getPlayer()).thenReturn(admin.player);when(view.getType()).thenReturn(org.bukkit.event.inventory.InventoryType.CHEST);if(route.equals("permission"))doReturn(false).when(admin.player).hasPermission("fantasystick.admin");if(route.equals("movement"))target.core.inventory.setItem(11,new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND));int selected=route.equals("select-recent")?0:1;f.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view,org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,selected,org.bukkit.event.inventory.ClickType.LEFT,org.bukkit.event.inventory.InventoryAction.PICKUP_ALL));PartyBookNativeTest.drain(f);if(route.startsWith("select-")){var selectedRecord=route.equals("select-recent")?first:second;var revival=(games.dmg.fantasystick.persistence.RevivalStore)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"revivals");revival.find(selectedRecord.id()).join();TailRevivalSafetyNativeTest.settle(f);assertEquals(!route.equals("select-recent"),storage.find(first.id()).get().orElseThrow().dead());assertEquals(route.equals("select-recent"),storage.find(second.id()).get().orElseThrow().dead());if(route.equals("select-recent")){assertEquals(first.id(),new games.dmg.fantasystick.items.CharacterSheets().identify(target.core.inventory.getItem(10)).orElseThrow());assertEquals(secondTail,target.core.inventory.getItem(11));}else{assertEquals(firstTail,target.core.inventory.getItem(10));assertEquals(second.id(),new games.dmg.fantasystick.items.CharacterSheets().identify(target.core.inventory.getItem(11)).orElseThrow());}}else{assertTrue(storage.find(first.id()).get().orElseThrow().dead());assertTrue(storage.find(second.id()).get().orElseThrow().dead());assertEquals(firstTail,target.core.inventory.getItem(10));assertEquals(route.equals("movement")?org.bukkit.Material.DIAMOND:org.bukkit.Material.WRITTEN_BOOK,target.core.inventory.getItem(11).getType());}}}} static games.dmg.fantasystick.persistence.CharacterRecord dead(Path data,Instant died)throws Exception{var base=new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());var record=new games.dmg.fantasystick.persistence.CharacterRecord(base.id(),"Ancient Mira",base.revision(),base.stats(),7,BigInteger.valueOf(642),BigInteger.valueOf(11),java.math.BigDecimal.ZERO,java.util.Set.of("fireball"),java.util.List.of("fireball","","","","","",""),java.util.Map.of("fireball",Instant.now().plusSeconds(30)),base.restrictedKills(),true,java.util.UUID.randomUUID(),died);new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).create(record);return record;} } diff --git a/src/nativeTest/java/games/dmg/fantasystick/ReviveNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/ReviveNativeTest.java index f125bb8..5a04549 100644 --- a/src/nativeTest/java/games/dmg/fantasystick/ReviveNativeTest.java +++ b/src/nativeTest/java/games/dmg/fantasystick/ReviveNativeTest.java @@ -92,6 +92,7 @@ class ReviveNativeTest { org.mockito.Mockito.when(view.getTopInventory()).thenReturn(popup); org.mockito.Mockito.when(view.getBottomInventory()).thenReturn(target.core.inventory); org.mockito.Mockito.when(view.getPlayer()).thenReturn(target.player); + org.mockito.Mockito.when(view.getType()).thenReturn(org.bukkit.event.inventory.InventoryType.CHEST); var selection = new org.bukkit.event.inventory.InventoryClickEvent(view, org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER, 0, org.bukkit.event.inventory.ClickType.LEFT, org.bukkit.event.inventory.InventoryAction.PICKUP_ALL); @@ -100,7 +101,7 @@ class ReviveNativeTest { FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "revivals"); PartyBookNativeTest.drain(fixture); assertTrue(revivalStore.find(dead.id()).join().isPresent()); - PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture); + TailRevivalSafetyNativeTest.settle(fixture); var storage = (games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters"); @@ -159,6 +160,7 @@ class ReviveNativeTest { org.mockito.Mockito.when(view.getTopInventory()).thenReturn(popup); org.mockito.Mockito.when(view.getBottomInventory()).thenReturn(target.core.inventory); org.mockito.Mockito.when(view.getPlayer()).thenReturn(target.player); + org.mockito.Mockito.when(view.getType()).thenReturn(org.bukkit.event.inventory.InventoryType.CHEST); fixture.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view, org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER, 0, org.bukkit.event.inventory.ClickType.LEFT, org.bukkit.event.inventory.InventoryAction.PICKUP_ALL)); @@ -208,6 +210,7 @@ class ReviveNativeTest { org.mockito.Mockito.when(view.getTopInventory()).thenReturn(popup); org.mockito.Mockito.when(view.getBottomInventory()).thenReturn(target.core.inventory); org.mockito.Mockito.when(view.getPlayer()).thenReturn(target.player); + org.mockito.Mockito.when(view.getType()).thenReturn(org.bukkit.event.inventory.InventoryType.CHEST); fixture.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view, org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER, 0, org.bukkit.event.inventory.ClickType.LEFT, org.bukkit.event.inventory.InventoryAction.PICKUP_ALL)); @@ -286,6 +289,7 @@ class ReviveNativeTest { org.mockito.Mockito.when(view.getTopInventory()).thenReturn(popup); org.mockito.Mockito.when(view.getBottomInventory()).thenReturn(target.core.inventory); org.mockito.Mockito.when(view.getPlayer()).thenReturn(target.player); + org.mockito.Mockito.when(view.getType()).thenReturn(org.bukkit.event.inventory.InventoryType.CHEST); caster.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, caster); PartyBookNativeTest.click(fixture, caster, PartyBookNativeTest.button(caster, "[Unlearn]")); @@ -341,7 +345,10 @@ class ReviveNativeTest { target.core.inventory.setItem(10, new games.dmg.fantasystick.items.CharacterSheets().create(provisional)); var reservation = new games.dmg.fantasystick.persistence.RevivalStore.Reservation(java.util.UUID.randomUUID(), java.util.UUID.randomUUID(), target.core.id, dead.id(), death, java.time.Instant.now().truncatedTo(java.time.temporal.ChronoUnit.SECONDS), - 10, 1, -1); + 10, 1, -1, false, dead.sheetGeneration()).deliveryStarted(); + var bound = target.core.inventory.getItem(10).clone(); + new games.dmg.fantasystick.items.CharacterSheets().markRevival(bound, reservation.id()); + target.core.inventory.setItem(10, bound); try (var store = new games.dmg.fantasystick.persistence.RevivalStore(data)) { store.reserve(reservation).join(); } var untouchedReservationId=java.util.UUID.randomUUID(); @@ -349,7 +356,7 @@ class ReviveNativeTest { fixture.players.put(target.core.id, target.player); fixture.plugin.setEnabled(true); var store = (games.dmg.fantasystick.persistence.RevivalStore) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "revivals"); - store.target(target.core.id).join(); PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture); + store.target(target.core.id).join(); TailRevivalSafetyNativeTest.settle(fixture); var storage = (games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters"); assertFalse(storage.find(dead.id()).get().orElseThrow().dead()); @@ -366,10 +373,10 @@ class ReviveNativeTest { repository.create(second); var tail = new games.dmg.fantasystick.items.TailBooks().create(second); target.core.inventory.setItem(12, tail); var untouched = new games.dmg.fantasystick.persistence.RevivalStore.Reservation(untouchedReservationId, java.util.UUID.randomUUID(), target.core.id, second.id(), secondDeath, - java.time.Instant.now().truncatedTo(java.time.temporal.ChronoUnit.SECONDS), 12, 1, -1); + java.time.Instant.now().truncatedTo(java.time.temporal.ChronoUnit.SECONDS), 12, 1, -1, false, second.sheetGeneration()); store.reserve(untouched).join(); ((games.dmg.fantasystick.ability.Revive) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "revive")).reconcile(target.player); - store.target(target.core.id).join(); PartyBookNativeTest.drain(fixture); + store.target(target.core.id).join(); TailRevivalSafetyNativeTest.settle(fixture); assertTrue(storage.find(second.id()).get().orElseThrow().dead()); assertEquals(tail, target.core.inventory.getItem(12)); assertTrue(store.find(second.id()).join().isEmpty()); } diff --git a/src/nativeTest/java/games/dmg/fantasystick/TailRevivalSafetyNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/TailRevivalSafetyNativeTest.java new file mode 100644 index 0000000..337bb9e --- /dev/null +++ b/src/nativeTest/java/games/dmg/fantasystick/TailRevivalSafetyNativeTest.java @@ -0,0 +1,635 @@ +package games.dmg.fantasystick; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import games.dmg.fantasystick.persistence.CharacterRecord; +import games.dmg.fantasystick.persistence.CharacterStorage; +import games.dmg.fantasystick.persistence.RevivalStore; +import java.math.BigInteger; +import java.nio.file.Path; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; +import org.bukkit.Material; +import org.bukkit.event.inventory.ClickType; +import org.bukkit.event.inventory.InventoryAction; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.InventoryView; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +/** Actual registered spell/admin selection paths, with controlled async/native custody boundaries. */ +class TailRevivalSafetyNativeTest { + enum Route { LEARNED, ADMIN } + @TempDir Path data; + @BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); } + + @ParameterizedTest @EnumSource(Route.class) + void stackedTailCannotOverwriteDestinationOccupiedAfterReservation(Route route) throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + var attempt = prepare(f, route, 2); + f.dispatcher.accept(attempt.selection()); + var store = revivals(f); + await(f, () -> store.find(attempt.dead().id()).join().isPresent()); + var reservation = store.find(attempt.dead().id()).get(5, TimeUnit.SECONDS).orElseThrow(); + assertTrue(reservation.destinationSlot() >= 0); + var diamond = new ItemStack(Material.DIAMOND, 7); + attempt.target().core.inventory.setItem(reservation.destinationSlot(), diamond); + var source = attempt.target().core.inventory.getItem(reservation.sourceSlot()).clone(); + settle(f); + assertEquals(diamond, attempt.target().core.inventory.getItem(reservation.destinationSlot()), + "A native pickup/move during the saved reservation must not be overwritten"); + assertEquals(source, attempt.target().core.inventory.getItem(reservation.sourceSlot()), + "Destination refusal must preserve the entire selected Tail stack"); + assertTrue(characters(f).find(attempt.dead().id()).get(5, TimeUnit.SECONDS).orElseThrow().dead()); + assertFalse(attempt.operator().messages.stream().anyMatch(message -> message.startsWith("Administratively restored"))); + f.dispatcher.accept(attempt.selection()); + settle(f); + assertEquals(diamond, attempt.target().core.inventory.getItem(reservation.destinationSlot())); + assertEquals(source, attempt.target().core.inventory.getItem(reservation.sourceSlot())); + f.plugin.setEnabled(false); + assertTrue(ReskillEventsNativeTest.records(data).stream() + .noneMatch(event -> event.get("type").getAsString().endsWith("character.revived"))); + } + } + + @ParameterizedTest @EnumSource(Route.class) + void substitutedRevokedSheetCannotBeUpgradedDuringFinalization(Route route) throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + var attempt = prepare(f, route, 1, BigInteger.ONE); + var revoked = sheet(attempt.dead(), BigInteger.ZERO); + var replaced = new java.util.concurrent.atomic.AtomicBoolean(); + attempt.target().core.mutation = () -> { + if (replaced.compareAndSet(false, true)) attempt.target().core.inventory.setItem(10, revoked.clone()); + }; + f.dispatcher.accept(attempt.selection()); + settle(f); + attempt.target().core.mutation = () -> {}; + assertTrue(replaced.get(), "Exercise the actual native delivery, not a refused selection"); + assertEquals(revoked, attempt.target().core.inventory.getItem(10), + "A same-character revoked reference is not the issued operation's sheet"); + assertTrue(characters(f).find(attempt.dead().id()).join().orElseThrow().dead(), + "Unproved native delivery must not restore saved death authority"); + assertTrue(revivals(f).find(attempt.dead().id()).join().isPresent()); + f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(attempt.target().player, + (net.kyori.adventure.text.Component) null)); + settle(f); + assertEquals(revoked, attempt.target().core.inventory.getItem(10)); + assertTrue(revivals(f).find(attempt.dead().id()).join().isPresent()); + f.plugin.setEnabled(false); + assertTrue(ReskillEventsNativeTest.records(data).stream() + .noneMatch(event -> event.get("type").getAsString().endsWith("character.revived"))); + } + } + + @ParameterizedTest @EnumSource(Route.class) + void reconnectCannotTurnRemainingRevokedReferenceIntoCurrentSheet(Route route) throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + var attempt = prepare(f, route, 1, BigInteger.ONE); + var revoked = sheet(attempt.dead(), BigInteger.ZERO); + attempt.target().core.inventory.setItem(11, revoked.clone()); + f.dispatcher.accept(attempt.selection()); + settle(f); + assertFalse(characters(f).find(attempt.dead().id()).join().orElseThrow().dead()); + assertEquals(revoked, attempt.target().core.inventory.getItem(11)); + // The delivered current sheet leaves direct custody. Only the revoked reference remains. + attempt.target().core.inventory.setItem(10, null); + for (int i = 0; i < 2; i++) { + f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(attempt.target().player, + (net.kyori.adventure.text.Component) null)); + settle(f); + } + assertEquals(revoked, attempt.target().core.inventory.getItem(11), + "Reconciliation must not authorize a different-generation same-UUID reference"); + assertNull(attempt.target().core.inventory.getItem(10), "Reconnect cannot replay delivery"); + f.plugin.setEnabled(false); + assertTrue(ReskillEventsNativeTest.records(data).stream() + .filter(event -> event.get("type").getAsString().endsWith("character.revived")).count() <= 1); + } + } + + @ParameterizedTest @EnumSource(Route.class) + void unboundLegacyReservationAndGenerationZeroReferenceRemainUnresolved(Route route) throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); + var target = new FantasyStickLifecycleNativeTest.Actor(data); + f.players.put(target.core.id, target.player); + var dead = AdminRevivalNativeTest.dead(data, Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.SECONDS)); + var unbound = sheet(dead, BigInteger.ZERO); + target.core.inventory.setItem(10, unbound.clone()); + var legacy = new RevivalStore.Reservation(java.util.UUID.randomUUID(), java.util.UUID.randomUUID(), + target.core.id, dead.id(), dead.deathId(), Instant.now().truncatedTo(ChronoUnit.SECONDS), + 10, 1, -1, route == Route.ADMIN); + assertTrue(revivals(f).reserve(legacy).join().isPresent()); + for (int i = 0; i < 2; i++) { + f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(target.player, + (net.kyori.adventure.text.Component) null)); + settle(f); + } + assertTrue(characters(f).find(dead.id()).join().orElseThrow().dead(), + "Generation-zero entry compatibility is not legacy revival-operation proof"); + assertEquals(unbound, target.core.inventory.getItem(10)); + assertEquals(legacy, revivals(f).find(dead.id()).join().orElseThrow()); + f.plugin.setEnabled(false); + assertTrue(ReskillEventsNativeTest.records(data).stream() + .noneMatch(event -> event.get("type").getAsString().endsWith("character.revived"))); + } + } + + @ParameterizedTest @EnumSource(Route.class) + void staleReservationCallbackCannotDeleteStartedDeliveryProof(Route route) throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + var attempt = prepare(f, route, 1, BigInteger.ONE); + f.dispatcher.accept(attempt.selection()); + await(f, () -> revivals(f).find(attempt.dead().id()).join().isPresent()); + var reserved = revivals(f).find(attempt.dead().id()).join().orElseThrow(); + assertEquals(RevivalStore.DeliveryPhase.RESERVED, reserved.phase()); + var reservationCallback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(reservationCallback); + reservationCallback.run(); + var started = revivals(f).find(attempt.dead().id()).join().orElseThrow(); + assertEquals(RevivalStore.DeliveryPhase.DELIVERY_STARTED, started.phase(), "Own the delivery-fence checkpoint"); + var revoked = sheet(attempt.dead(), BigInteger.ZERO); + var swapped = new java.util.concurrent.atomic.AtomicBoolean(); + attempt.target().core.mutation = () -> { + if (swapped.compareAndSet(false, true)) attempt.target().core.inventory.setItem(10, revoked.clone()); + }; + var deliveryCallback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(deliveryCallback); + deliveryCallback.run(); + attempt.target().core.mutation = () -> {}; + assertTrue(swapped.get()); + assertEquals(started, revivals(f).find(attempt.dead().id()).join().orElseThrow()); + reservationCallback.run(); // A stale callback has only RESERVED, not delivery-started, authority. + settle(f); + assertTrue(revivals(f).find(attempt.dead().id()).join().isPresent(), + "ID-only cancellation must not delete another phase's uncertain delivery proof"); + assertEquals(started, revivals(f).find(attempt.dead().id()).join().orElseThrow()); + assertEquals(revoked, attempt.target().core.inventory.getItem(10)); + assertTrue(characters(f).find(attempt.dead().id()).join().orElseThrow().dead()); + } + } + + @ParameterizedTest @EnumSource(Route.class) + void committedCompletionIsNotDowngradedByQuitBeforeMainAcknowledgment(Route route) throws Exception { + java.util.UUID operation; + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + var attempt = prepare(f, route, 1, BigInteger.ONE); + f.dispatcher.accept(attempt.selection()); + await(f, () -> revivals(f).find(attempt.dead().id()).join().isPresent()); + operation = revivals(f).find(attempt.dead().id()).join().orElseThrow().id(); + Runnable clearAcknowledgment = null; + for (int i = 0; i < 12; i++) { + var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); + // Worker FIFO ensures an accepted conditional clear has finished before this read. + if (revivals(f).find(attempt.dead().id()).join().isEmpty()) { clearAcknowledgment = callback; break; } + callback.run(); + } + assertNotNull(clearAcknowledgment, "Hold the genuine post-clear acknowledgment, not a pre-delivery callback"); + var restored = characters(f).find(attempt.dead().id()).join().orElseThrow(); + assertFalse(restored.dead()); + assertTrue(new games.dmg.fantasystick.items.CharacterSheets().authorizes( + attempt.target().core.inventory.getItem(10), restored)); + var delivered = attempt.target().core.inventory.getItem(10).clone(); + doReturn(null).when(attempt.target().player).getFishHook(); // No fishing hook is owned by this ordinary target. + f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(attempt.target().player, + (net.kyori.adventure.text.Component) null)); + attempt.target().core.online = false; + f.players.remove(attempt.target().core.id); + clearAcknowledgment.run(); + settle(f); + assertEquals(delivered, attempt.target().core.inventory.getItem(10)); + assertTrue(revivals(f).find(attempt.dead().id()).join().isEmpty()); + assertFalse(attempt.operator().messages.stream().anyMatch(message -> message.contains("could not acknowledge")), + "Already committed, proved completion is historical success, not unresolved custody"); + attempt.target().core.online = true; + f.players.put(attempt.target().core.id, attempt.target().player); + for (int i = 0; i < 2; i++) { + f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(attempt.target().player, + (net.kyori.adventure.text.Component) null)); + settle(f); + } + assertEquals(delivered, attempt.target().core.inventory.getItem(10)); + } + var events = ReskillEventsNativeTest.records(data).stream() + .filter(event -> event.get("type").getAsString().endsWith("character.revived")).toList(); + assertEquals(1, events.size(), "Historical acknowledgment and repeated reconnect export one completion"); + var payload = events.getFirst().getAsJsonObject("data"); + var key = route == Route.ADMIN ? "revival_operation_id" : "reservation_id"; + assertEquals(operation.toString(), payload.get(key).getAsString()); + } + + @ParameterizedTest + @org.junit.jupiter.params.provider.CsvSource({"LEARNED,0", "ADMIN,0", "LEARNED,1", "ADMIN,1"}) + void restoredStackedTailSheetTransfersAndEntersNormally(Route route, BigInteger generation) throws Exception { + java.util.UUID operation; + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + var attempt = prepare(f, route, 2, generation); + var unrelated = new ItemStack(Material.DIAMOND, 7); + attempt.target().core.inventory.setItem(11, unrelated.clone()); + var ordinaryOffhand = attempt.target().core.inventory.getItemInOffHand().clone(); + var ordinaryLocation = attempt.target().core.location.clone(); + attempt.target().core.health = 7; + f.dispatcher.accept(attempt.selection()); + await(f, () -> revivals(f).find(attempt.dead().id()).join().isPresent()); + var reserved = revivals(f).find(attempt.dead().id()).join().orElseThrow(); operation = reserved.id(); + settle(f); + var restored = characters(f).find(attempt.dead().id()).join().orElseThrow(); + assertFalse(restored.dead()); + assertEquals(attempt.dead().revision().add(BigInteger.ONE), restored.revision()); + assertEquals(generation, restored.sheetGeneration()); + assertEquals(attempt.dead().stats(), restored.stats()); + assertEquals(attempt.dead().experience(), restored.experience()); + assertEquals(attempt.dead().level(), restored.level()); + assertEquals(attempt.dead().learned(), restored.learned()); + assertEquals(attempt.dead().loadout(), restored.loadout()); + assertEquals(attempt.dead().passives(), restored.passives()); + assertEquals(attempt.dead().cooldowns(), restored.cooldowns()); + assertEquals(restored.maximumHealth().min(games.dmg.fantasystick.session.NativeHealthLimit.read()), restored.health()); + assertEquals(1, attempt.target().core.inventory.getItem(10).getAmount()); + assertTrue(new games.dmg.fantasystick.items.TailBooks().identify(attempt.target().core.inventory.getItem(10)).isPresent()); + assertEquals(unrelated, attempt.target().core.inventory.getItem(11)); + assertEquals(ordinaryOffhand, attempt.target().core.inventory.getItemInOffHand()); + assertEquals(ordinaryLocation, attempt.target().core.location); + assertEquals(7, attempt.target().core.health); + assertEquals(org.bukkit.GameMode.SURVIVAL, attempt.target().core.gameMode); + assertTrue(revivals(f).find(restored.id()).join().isEmpty()); + if (route == Route.LEARNED) assertTrue(characters(f).find(attempt.operator().core.record.id()).join() + .orElseThrow().cooldowns().get("revive").isAfter(Instant.now().plusSeconds(23 * 3600))); + var delivered = attempt.target().core.inventory.getItem(reserved.destinationSlot()).clone(); + assertTrue(new games.dmg.fantasystick.items.CharacterSheets().authorizes(delivered, restored)); + attempt.target().core.inventory.setItem(reserved.destinationSlot(), null); + var holder = new FantasyStickLifecycleNativeTest.Actor(data); + holder.core.inventory.setItemInOffHand(delivered); + f.players.put(holder.core.id, holder.player); + f.activate(holder); + var sessions = (games.dmg.fantasystick.session.EquipmentSessions) + FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"); + assertEquals(restored.id(), sessions.active(holder.core.id).orElseThrow().character()); + assertEquals(restored.health().doubleValue(), holder.core.health); + } + var events = ReskillEventsNativeTest.records(data).stream() + .filter(event -> event.get("type").getAsString().endsWith("character.revived")).toList(); + assertEquals(1, events.size()); + var key = route == Route.ADMIN ? "revival_operation_id" : "reservation_id"; + assertEquals(operation.toString(), events.getFirst().getAsJsonObject("data").get(key).getAsString()); + } + + @ParameterizedTest @EnumSource(Route.class) + void pendingLivingReservationWithOnlyRevokedReferenceRetainsProofAcrossReconnect(Route route) throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + var attempt = prepare(f, route, 1, BigInteger.ONE); + var revoked = sheet(attempt.dead(), BigInteger.ZERO); + attempt.target().core.inventory.setItem(11, revoked.clone()); + f.dispatcher.accept(attempt.selection()); + await(f, () -> revivals(f).find(attempt.dead().id()).join().isPresent()); + var operation = revivals(f).find(attempt.dead().id()).join().orElseThrow().id(); + await(f, () -> !characters(f).find(attempt.dead().id()).join().orElseThrow().dead()); + // Interrupt before acknowledgment; leave a living saved record and unresolved issued operation. + attempt.target().core.inventory.setItem(10, null); + settle(f); + assertTrue(revivals(f).find(attempt.dead().id()).join().isPresent()); + for (int i = 0; i < 2; i++) { + f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(attempt.target().player, + (net.kyori.adventure.text.Component) null)); + settle(f); + } + assertEquals(operation, revivals(f).find(attempt.dead().id()).join().orElseThrow().id()); + assertEquals(revoked, attempt.target().core.inventory.getItem(11)); + assertNull(attempt.target().core.inventory.getItem(10)); + } + assertTrue(ReskillEventsNativeTest.records(data).stream() + .noneMatch(event -> event.get("type").getAsString().endsWith("character.revived"))); + } + + @ParameterizedTest @EnumSource(Route.class) + void destinationOccupiedAfterDeliveryFencePreservesBothStacks(Route route) throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + var attempt = prepare(f, route, 2, BigInteger.ONE); + f.dispatcher.accept(attempt.selection()); + var started = deliveryFence(f, attempt); + var diamond = new ItemStack(Material.DIAMOND, 7); + var tails = attempt.target().core.inventory.getItem(10).clone(); + attempt.target().core.inventory.setItem(started.destinationSlot(), diamond.clone()); + settle(f); + assertEquals(diamond, attempt.target().core.inventory.getItem(started.destinationSlot())); + assertEquals(tails, attempt.target().core.inventory.getItem(10)); + assertTrue(characters(f).find(attempt.dead().id()).join().orElseThrow().dead()); + assertEquals(started, revivals(f).find(attempt.dead().id()).join().orElseThrow()); + } + assertNoRevival(); + } + + @ParameterizedTest @EnumSource(Route.class) + void reentrantStackedSourceChangeCannotOverwritePickupOrDebitMovedTails(Route route) throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + var attempt = prepare(f, route, 2, BigInteger.ONE); + var original = attempt.target().core.inventory.getItem(10).clone(); + var diamond = new ItemStack(Material.DIAMOND, 7); + var moved = new java.util.concurrent.atomic.AtomicBoolean(); + attempt.target().core.mutation = () -> { + if (moved.compareAndSet(false, true)) { + attempt.target().core.inventory.setItem(12, original.clone()); + attempt.target().core.inventory.setItem(10, diamond.clone()); + } + }; + f.dispatcher.accept(attempt.selection()); settle(f); + attempt.target().core.mutation = () -> {}; + assertTrue(moved.get()); + assertEquals(diamond, attempt.target().core.inventory.getItem(10)); + assertEquals(original, attempt.target().core.inventory.getItem(12)); + assertTrue(characters(f).find(attempt.dead().id()).join().orElseThrow().dead()); + assertTrue(revivals(f).find(attempt.dead().id()).join().isPresent()); + f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(attempt.target().player, + (net.kyori.adventure.text.Component) null)); settle(f); + assertEquals(diamond, attempt.target().core.inventory.getItem(10)); + assertEquals(original, attempt.target().core.inventory.getItem(12)); + assertTrue(characters(f).find(attempt.dead().id()).join().orElseThrow().dead()); + } + assertNoRevival(); + } + + @ParameterizedTest + @org.junit.jupiter.params.provider.CsvSource({ + "LEARNED,delivery,generation", "ADMIN,delivery,generation", "LEARNED,delivery,death", "ADMIN,delivery,death", + "LEARNED,finalization,generation", "ADMIN,finalization,generation", "LEARNED,finalization,death", "ADMIN,finalization,death"}) + void savedAuthorityChangesBeforeRestorationOrAcknowledgmentFailClosed(Route route, String point, String change) throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + var attempt = prepare(f, route, 1, BigInteger.ONE); + var changed = new java.util.concurrent.atomic.AtomicBoolean(); + if (point.equals("delivery")) attempt.target().core.mutation = () -> { + if (changed.compareAndSet(false, true)) changeSavedAuthority(attempt.dead().id(), change); + }; + f.dispatcher.accept(attempt.selection()); + await(f, () -> revivals(f).find(attempt.dead().id()).join().isPresent()); + if (point.equals("finalization")) { + await(f, () -> !characters(f).find(attempt.dead().id()).join().orElseThrow().dead()); + changeSavedAuthority(attempt.dead().id(), change); changed.set(true); + } + settle(f); attempt.target().core.mutation = () -> {}; + assertTrue(changed.get()); + var current = characters(f).find(attempt.dead().id()).join().orElseThrow(); + assertTrue(revivals(f).find(current.id()).join().isPresent()); + if (change.equals("generation")) { + assertEquals(BigInteger.TWO, current.sheetGeneration()); + assertFalse(new games.dmg.fantasystick.items.CharacterSheets().authorizes( + attempt.target().core.inventory.getItem(10), current)); + } else assertNotEquals(attempt.dead().deathId(), current.deathId()); + if (point.equals("delivery") || change.equals("death")) assertTrue(current.dead()); + f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(attempt.target().player, + (net.kyori.adventure.text.Component) null)); settle(f); + assertEquals(current, characters(f).find(current.id()).join().orElseThrow()); + assertTrue(revivals(f).find(current.id()).join().isPresent()); + } + assertNoRevival(); + } + + private void changeSavedAuthority(java.util.UUID character, String change) { + try { + // Trusted fixture-only external state mutation; production still performs all saved I/O on workers. + var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data); + var old = repository.load(character).orElseThrow(); + var deathChanged = change.equals("death"); + var updated = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), + old.level(), old.experience(), old.unspentPoints(), deathChanged ? java.math.BigDecimal.ZERO : old.health(), + old.learned(), old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), + deathChanged || old.dead(), deathChanged ? java.util.UUID.randomUUID() : old.deathId(), + deathChanged ? Instant.now().truncatedTo(ChronoUnit.SECONDS) : old.diedAt(), old.coins(), old.coinsInitialized(), + old.rewardReceipts(), old.pvpCoinEscrow(), old.gear(), deathChanged ? old.sheetGeneration() : old.sheetGeneration().add(BigInteger.ONE)); + repository.replace(updated, old.revision()); + } catch (Exception failure) { throw new AssertionError(failure); } + } + + @ParameterizedTest + @org.junit.jupiter.params.provider.CsvSource({"LEARNED,outside", "ADMIN,outside", "LEARNED,copies", "ADMIN,copies", "LEARNED,quantity", "ADMIN,quantity"}) + void uncertainIssuedCustodyNeverTriggersBlindRestorationOrReissue(Route route, String change) throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + var attempt = prepare(f, route, 1, BigInteger.ONE); + var changed = new java.util.concurrent.atomic.AtomicBoolean(); + var escaped = new java.util.concurrent.atomic.AtomicReference(); + attempt.target().core.mutation = () -> { + if (!changed.compareAndSet(false, true)) return; + var sheet = attempt.target().core.inventory.getItem(10).clone(); escaped.set(sheet); + if (change.equals("outside")) attempt.target().core.inventory.setItem(10, null); + else if (change.equals("copies")) attempt.target().core.inventory.setItem(12, sheet.clone()); + else { sheet.setAmount(2); attempt.target().core.inventory.setItem(10, sheet); } + }; + f.dispatcher.accept(attempt.selection()); settle(f); + attempt.target().core.mutation = () -> {}; assertTrue(changed.get()); assertNotNull(escaped.get()); + var before = attempt.target().core.inventory.getItem(10); + if (before != null) before = before.clone(); + var other = attempt.target().core.inventory.getItem(12); + if (other != null) other = other.clone(); + for (int i = 0; i < 2; i++) { + f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(attempt.target().player, + (net.kyori.adventure.text.Component) null)); settle(f); + } + assertEquals(before, attempt.target().core.inventory.getItem(10)); + assertEquals(other, attempt.target().core.inventory.getItem(12)); + assertTrue(characters(f).find(attempt.dead().id()).join().orElseThrow().dead()); + assertTrue(revivals(f).find(attempt.dead().id()).join().isPresent()); + } + assertNoRevival(); + } + + @ParameterizedTest @EnumSource(Route.class) + void newRecipientInstanceReconcilesIssuedCurrentGenerationOnceAndCanEnter(Route route) throws Exception { + java.util.UUID operation; + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + var attempt = prepare(f, route, 1, BigInteger.ONE); + f.dispatcher.accept(attempt.selection()); + await(f, () -> revivals(f).find(attempt.dead().id()).join().isPresent()); + operation = revivals(f).find(attempt.dead().id()).join().orElseThrow().id(); + await(f, () -> !characters(f).find(attempt.dead().id()).join().orElseThrow().dead()); + var delivered = attempt.target().core.inventory.getItem(10).clone(); + doReturn(null).when(attempt.target().player).getFishHook(); + f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(attempt.target().player, + (net.kyori.adventure.text.Component) null)); + attempt.target().core.online = false; f.players.remove(attempt.target().core.id); settle(f); + assertTrue(revivals(f).find(attempt.dead().id()).join().isPresent()); + var recipient = new FantasyStickLifecycleNativeTest.Actor(data); + recipient.core.id = attempt.target().core.id; + // Actor defaults include an unrelated offhand sheet. Reconnect an ordinary recipient, + // rather than auto-entering that fixture character before the restored-sheet entry request. + recipient.core.inventory.setItemInOffHand(null); + recipient.core.inventory.setItem(10, delivered.clone()); f.players.put(recipient.core.id, recipient.player); + for (int i = 0; i < 2; i++) { + f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(recipient.player, + (net.kyori.adventure.text.Component) null)); settle(f); + } + assertTrue(revivals(f).find(attempt.dead().id()).join().isEmpty()); + assertEquals(delivered, recipient.core.inventory.getItem(10)); + recipient.core.inventory.setItem(10, null); recipient.core.inventory.setItemInOffHand(delivered); + f.activate(recipient); + var sessions = (games.dmg.fantasystick.session.EquipmentSessions) + FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"); + assertEquals(attempt.dead().id(), sessions.active(recipient.core.id).orElseThrow().character()); + // Native/save completion is not the asynchronous outbox's JSONL-export acknowledgment. + // Observe this operation's real export before closing its feature owner; retain the exact count. + await(f, () -> { + try { return ReskillEventsNativeTest.records(data).stream() + .filter(event -> event.get("type").getAsString().endsWith("character.revived")).count() == 1; } + catch (Exception failure) { throw new AssertionError(failure); } + }); + } + var events = ReskillEventsNativeTest.records(data).stream() + .filter(event -> event.get("type").getAsString().endsWith("character.revived")).toList(); + assertEquals(1, events.size()); + var payload = events.getFirst().getAsJsonObject("data"); + assertTrue(payload.get("recovery_reconciliation").getAsBoolean()); + assertEquals(operation.toString(), payload.get(route == Route.ADMIN ? "revival_operation_id" : "reservation_id").getAsString()); + } + + @ParameterizedTest @EnumSource(Route.class) + void finalizedPvpTailRevivesWithoutRefundingDeathEscrowOrRerollingCoins(Route route) throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + var attempt = prepare(f, route, 1, BigInteger.ONE); + var old = characters(f).find(attempt.dead().id()).join().orElseThrow(); + var lostBalance = BigInteger.ONE.shiftLeft(200); + // Completed final-death state is a prerequisite: coins already lost, death-bound escrow retained. + var dead = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), + old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), + old.passives(), old.cooldowns(), old.restrictedKills(), true, old.deathId(), old.diedAt(), + BigInteger.ZERO, true, old.rewardReceipts(), lostBalance, old.gear(), old.sheetGeneration()); + new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(dead, old.revision()); + f.dispatcher.accept(attempt.selection()); settle(f); + var restored = characters(f).find(dead.id()).join().orElseThrow(); + assertFalse(restored.dead(), "A valid finalized-PvP Tail must work through the actual revival route"); + assertEquals(BigInteger.ZERO, restored.coins()); assertTrue(restored.coinsInitialized()); + assertEquals(BigInteger.ZERO, restored.pvpCoinEscrow(), "Restoration cannot refund/reuse death-only escrow"); + assertEquals(dead.rewardReceipts(), restored.rewardReceipts()); + assertEquals(dead.sheetGeneration(), restored.sheetGeneration()); + assertEquals(dead.cooldowns(), restored.cooldowns()); + assertEquals(dead.revision().add(BigInteger.ONE), restored.revision()); + assertTrue(new games.dmg.fantasystick.items.CharacterSheets().authorizes( + attempt.target().core.inventory.getItem(10), restored)); + assertTrue(revivals(f).find(dead.id()).join().isEmpty()); + await(f, () -> { + try { return ReskillEventsNativeTest.records(data).stream() + .filter(event -> event.get("type").getAsString().endsWith("character.revived")).count() == 1; } + catch (Exception failure) { throw new AssertionError(failure); } + }); + } + assertTrue(ReskillEventsNativeTest.records(data).stream() + .noneMatch(event -> event.get("type").getAsString().endsWith("character.coins_initialized"))); + } + + private static RevivalStore.Reservation deliveryFence(FantasyStickLifecycleNativeTest.Fixture f, Attempt attempt) throws Exception { + await(f, () -> revivals(f).find(attempt.dead().id()).join().isPresent()); + var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run(); + var started = revivals(f).find(attempt.dead().id()).join().orElseThrow(); + assertEquals(RevivalStore.DeliveryPhase.DELIVERY_STARTED, started.phase()); return started; + } + + private void assertNoRevival() throws Exception { + assertTrue(ReskillEventsNativeTest.records(data).stream() + .noneMatch(event -> event.get("type").getAsString().endsWith("character.revived"))); + } + + private static ItemStack sheet(CharacterRecord dead, BigInteger generation) { + return new games.dmg.fantasystick.items.CharacterSheets().create(new CharacterRecord(dead.id(), dead.name(), + dead.revision(), dead.stats(), dead.level(), dead.experience(), dead.unspentPoints(), dead.maximumHealth(), + dead.learned(), dead.loadout(), dead.passives(), dead.cooldowns(), dead.restrictedKills(), false, + dead.deathId(), dead.diedAt(), generation)); + } + + private record Attempt(Route route, FantasyStickLifecycleNativeTest.Actor operator, + FantasyStickLifecycleNativeTest.Actor target, CharacterRecord dead, + InventoryClickEvent selection) { } + + private Attempt prepare(FantasyStickLifecycleNativeTest.Fixture f, Route route, int amount) throws Exception { + return prepare(f, route, amount, BigInteger.ZERO); + } + + private Attempt prepare(FantasyStickLifecycleNativeTest.Fixture f, Route route, int amount, + BigInteger generation) throws Exception { + f.plugin.setEnabled(true); + var operator = new FantasyStickLifecycleNativeTest.Actor(data); + var target = new FantasyStickLifecycleNativeTest.Actor(data); + doReturn("Operator").when(operator.player).getName(); + doReturn("Target").when(target.player).getName(); + doReturn(true).when(operator.player).hasPermission("fantasystick.admin"); + if (route == Route.LEARNED) withRevive(operator); + f.players.put(operator.core.id, operator.player); + f.players.put(target.core.id, target.player); + if (route == Route.LEARNED) f.activate(operator); + var dead = AdminRevivalNativeTest.dead(data, Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.SECONDS)); + if (generation.signum() > 0) { + var updated = new CharacterRecord(dead.id(), dead.name(), dead.revision().add(BigInteger.ONE), dead.stats(), + dead.level(), dead.experience(), dead.unspentPoints(), dead.health(), dead.learned(), dead.loadout(), + dead.passives(), dead.cooldowns(), dead.restrictedKills(), true, dead.deathId(), dead.diedAt(), generation); + new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(updated, dead.revision()); + dead = updated; + } + var tail = new games.dmg.fantasystick.items.TailBooks().create(dead); + tail.setAmount(amount); target.core.inventory.setItem(10, tail); + settle(f); + if (route == Route.ADMIN) { + assertTrue(f.adminCommand.execute(operator.player, "fstickadmin", new String[]{"revive", "Target"})); + } else { + operator.core.location.setWorld(operator.world); operator.core.location.setX(0); + operator.core.location.setY(64); operator.core.location.setZ(0); + target.core.location.setWorld(operator.world); target.core.location.setX(3); + target.core.location.setY(64); target.core.location.setZ(0); + var eye = new org.bukkit.Location(operator.world, 0, 65.6, 0); + eye.setDirection(new org.bukkit.util.Vector(1, 0, 0)); + doReturn(eye).when(operator.player).getEyeLocation(); + doReturn(true).when(operator.player).hasLineOfSight(target.player); + operator.core.inventory.getInventory().setSelectedSlot(2); + f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent( + operator.player, target.player, EquipmentSlot.HAND)); + } + var selecting = route == Route.ADMIN ? operator : target; + await(f, () -> !selecting.inventories.isEmpty()); + var view = mock(InventoryView.class); + when(view.getTopInventory()).thenReturn(selecting.inventories.getFirst()); + when(view.getBottomInventory()).thenReturn(selecting.core.inventory); + when(view.getPlayer()).thenReturn(selecting.player); + when(view.getType()).thenReturn(InventoryType.CHEST); + when(view.getItem(0)).thenAnswer(ignored -> selecting.inventories.getFirst().getItem(0)); + var selection = new InventoryClickEvent(view, InventoryType.SlotType.CONTAINER, 0, + ClickType.LEFT, InventoryAction.PICKUP_ALL); + return new Attempt(route, operator, target, dead, selection); + } + + private void withRevive(FantasyStickLifecycleNativeTest.Actor actor) throws Exception { + var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data); + var old = repository.load(actor.core.record.id()).orElseThrow(); + var stats = new java.util.EnumMap<>(old.stats()); + stats.put(games.dmg.fantasystick.progression.Stat.WIS, BigInteger.valueOf(30)); + stats.put(games.dmg.fantasystick.progression.Stat.CHR, BigInteger.valueOf(24)); + stats.put(games.dmg.fantasystick.progression.Stat.CON, BigInteger.valueOf(12)); + var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats, + old.level(), old.experience(), old.unspentPoints(), old.maximumHealth(), Set.of("revive"), + List.of("revive", "", "", "", "", "", ""), old.cooldowns(), old.restrictedKills(), false, null, null); + repository.replace(saved, old.revision()); + } + + private static CharacterStorage characters(FantasyStickLifecycleNativeTest.Fixture f) { + return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"); + } + private static RevivalStore revivals(FantasyStickLifecycleNativeTest.Fixture f) { + return (RevivalStore) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "revivals"); + } + private static void await(FantasyStickLifecycleNativeTest.Fixture f, BooleanSupplier condition) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (!condition.getAsBoolean() && System.nanoTime() < deadline) { + var task = f.tasks.poll(25, TimeUnit.MILLISECONDS); + if (task != null) task.run(); + } + assertTrue(condition.getAsBoolean(), "Owned async revival checkpoint did not arrive"); + } + static void settle(FantasyStickLifecycleNativeTest.Fixture f) throws Exception { + for (int i = 0; i < 12; i++) { + revivals(f).target(java.util.UUID.randomUUID()).get(5, TimeUnit.SECONDS); + PartyBookNativeTest.drain(f); + } + } +} diff --git a/src/test/java/games/dmg/fantasystick/persistence/CharacterStorageTest.java b/src/test/java/games/dmg/fantasystick/persistence/CharacterStorageTest.java index 6361b73..4e9ef95 100644 --- a/src/test/java/games/dmg/fantasystick/persistence/CharacterStorageTest.java +++ b/src/test/java/games/dmg/fantasystick/persistence/CharacterStorageTest.java @@ -522,6 +522,40 @@ class CharacterStorageTest { } } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void tailRestoreRechecksGenerationAndDeathOnItsWorker(boolean administrative) throws Exception { + var old = YamlCharacterRepositoryTest.sample(UUID.randomUUID()); + var generation = new BigInteger("92233720368547758081111"); + var dead = new CharacterRecord(old.id(), old.name(), old.revision(), old.stats(), old.level(), old.experience(), + old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(), old.cooldowns(), + old.restrictedKills(), old.dead(), old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), + old.rewardReceipts(), old.pvpCoinEscrow(), old.gear(), generation); + var repository = new YamlCharacterRepository(data); repository.create(dead); + var limit = new java.math.BigDecimal("1024"); + try (var storage = new CharacterStorage(repository, Clock.fixed(dead.diedAt().plusSeconds(60), ZoneOffset.UTC))) { + var stale = administrative ? storage.restoreDeathRecordAdministratively(dead.id(), dead.deathId(), limit, BigInteger.ZERO) + : storage.restoreDeathRecord(dead.id(), dead.deathId(), limit, BigInteger.ZERO); + assertTrue(stale.join().isEmpty()); + assertEquals(dead, repository.load(dead.id()).orElseThrow()); + var wrongDeath = administrative ? storage.restoreDeathRecordAdministratively(dead.id(), UUID.randomUUID(), limit, generation) + : storage.restoreDeathRecord(dead.id(), UUID.randomUUID(), limit, generation); + assertTrue(wrongDeath.join().isEmpty()); + assertEquals(dead, repository.load(dead.id()).orElseThrow()); + var accepted = administrative ? storage.restoreDeathRecordAdministratively(dead.id(), dead.deathId(), limit, generation) + : storage.restoreDeathRecord(dead.id(), dead.deathId(), limit, generation); + var restored = accepted.join().orElseThrow(); + assertFalse(restored.dead()); assertEquals(generation, restored.sheetGeneration()); + assertEquals(dead.maximumHealth().min(limit), restored.health()); + assertEquals(dead.revision().add(BigInteger.ONE), restored.revision()); + assertEquals(dead.stats(), restored.stats()); assertEquals(dead.cooldowns(), restored.cooldowns()); + assertEquals(dead.coins(), restored.coins()); assertEquals(dead.gear(), restored.gear()); + var replay = administrative ? storage.restoreDeathRecordAdministratively(dead.id(), dead.deathId(), limit, generation) + : storage.restoreDeathRecord(dead.id(), dead.deathId(), limit, generation); + assertTrue(replay.join().isEmpty()); assertEquals(restored, repository.load(dead.id()).orElseThrow()); + } + } + @Test void nativeReviveBoundsFullHealthWithoutCappingTransferableCon() throws Exception { var base = new games.dmg.fantasystick.crafting.CharacterFactory().create( games.dmg.fantasystick.progression.ProgressionRules.defaults()); diff --git a/src/test/java/games/dmg/fantasystick/persistence/RevivalStoreTest.java b/src/test/java/games/dmg/fantasystick/persistence/RevivalStoreTest.java index d0a5632..11b78f3 100644 --- a/src/test/java/games/dmg/fantasystick/persistence/RevivalStoreTest.java +++ b/src/test/java/games/dmg/fantasystick/persistence/RevivalStoreTest.java @@ -51,6 +51,82 @@ class RevivalStoreTest { } catch (UnsupportedOperationException ignored) { } } + @Test void explicitGenerationAndDeliveryFenceRoundTripWithoutRestartOrDuplicateBegin() throws Exception { + var legacy = reservation(); + var generation = new java.math.BigInteger("92233720368547758081111"); + var reserved = new RevivalStore.Reservation(legacy.id(), legacy.casterCharacter(), legacy.targetPlayer(), + legacy.character(), legacy.death(), legacy.createdAt(), legacy.sourceSlot(), legacy.sourceAmount(), + legacy.destinationSlot(), true, generation); + try (var store = new RevivalStore(data)) { + assertEquals(reserved, store.reserve(reserved).join().orElseThrow()); + var started = store.beginDelivery(reserved).join().orElseThrow(); + assertEquals(reserved.deliveryStarted(), started); + assertTrue(store.beginDelivery(reserved).join().isEmpty()); + assertFalse(store.cancelReserved(reserved).join(), "Stale RESERVED authority cannot retire attempted delivery"); + assertFalse(store.clear(reserved).join()); + assertEquals(started, store.find(reserved.character()).join().orElseThrow()); + } + try (var reopened = new RevivalStore(data)) { + assertEquals(reserved.deliveryStarted(), reopened.find(reserved.character()).join().orElseThrow()); + assertEquals(generation, reopened.find(reserved.character()).join().orElseThrow().sheetGeneration()); + } + } + + @Test void confirmedReservedCancellationCannotLaterBeginDelivery() throws Exception { + var old = reservation(); + var reserved = new RevivalStore.Reservation(old.id(), old.casterCharacter(), old.targetPlayer(), old.character(), + old.death(), old.createdAt(), old.sourceSlot(), old.sourceAmount(), old.destinationSlot(), false, + java.math.BigInteger.ZERO); + try (var store = new RevivalStore(data)) { + store.reserve(reserved).join(); + assertTrue(store.cancelReserved(reserved).join()); + assertTrue(store.beginDelivery(reserved).join().isEmpty()); + assertFalse(store.cancelReserved(reserved).join()); + assertTrue(store.find(reserved.character()).join().isEmpty()); + } + } + + @Test void schemaOneAndTwoRetainLegacyEvidenceWithoutInferringGenerationZero() throws Exception { + var legacy = reservation(); + try (var store = new RevivalStore(data)) { store.reserve(legacy).join(); } + Path file = data.resolve("revivals").resolve(legacy.character() + ".yml"); + try (var store = new RevivalStore(data)) { + var decoded = store.find(legacy.character()).join().orElseThrow(); + assertEquals(RevivalStore.DeliveryPhase.LEGACY, decoded.phase()); + assertNull(decoded.sheetGeneration()); + assertTrue(store.beginDelivery(decoded).join().isEmpty()); + } + var yaml = new org.yaml.snakeyaml.Yaml(); + java.util.Map value = yaml.load(Files.readString(file)); + value.put("schema-version", "1"); value.remove("administrative"); + Files.writeString(file, yaml.dump(value)); + try (var store = new RevivalStore(data)) { + assertEquals(legacy, store.find(legacy.character()).join().orElseThrow()); + assertTrue(store.beginDelivery(legacy).join().isEmpty()); + } + assertTrue(Files.exists(file)); + } + + @Test void malformedNewAuthorityIsRejectedAndNeverRewritten() throws Exception { + var old = reservation(); + var reserved = new RevivalStore.Reservation(old.id(), old.casterCharacter(), old.targetPlayer(), + old.character(), old.death(), old.createdAt(), old.sourceSlot(), old.sourceAmount(), old.destinationSlot(), + false, java.math.BigInteger.ZERO); + try (var store = new RevivalStore(data)) { store.reserve(reserved).join(); } + Path file = data.resolve("revivals").resolve(old.character() + ".yml"); + String valid = Files.readString(file); + for (var field : java.util.List.of("sheet-generation", "delivery-phase")) { + var yaml = new org.yaml.snakeyaml.Yaml(); + java.util.Map value = yaml.load(valid); + value.put(field, field.equals("sheet-generation") ? "-1" : "LEGACY"); + String invalid = yaml.dump(value); Files.writeString(file, invalid); + try (var store = new RevivalStore(data)) { + assertThrows(CompletionException.class, () -> store.find(old.character()).join()); + } + assertEquals(invalid, Files.readString(file)); + } + } + private static RevivalStore.Reservation reservation() { return new RevivalStore.Reservation(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), Instant.parse("2026-09-20T10:00:00Z"), 10, 2, 12); -- 2.54.0 From f1b3fc50942078d381cd15ff4a51c0fd53cc8767 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Wed, 7 Oct 2026 18:52:03 -0400 Subject: [PATCH 2/2] test(revival): fence administrator audit export observations --- ...nActionIdentityAndEquipmentNativeTest.java | 159 ++++++++++++++++-- 1 file changed, 141 insertions(+), 18 deletions(-) diff --git a/src/nativeTest/java/games/dmg/fantasystick/AdminActionIdentityAndEquipmentNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/AdminActionIdentityAndEquipmentNativeTest.java index a06a676..f93232f 100644 --- a/src/nativeTest/java/games/dmg/fantasystick/AdminActionIdentityAndEquipmentNativeTest.java +++ b/src/nativeTest/java/games/dmg/fantasystick/AdminActionIdentityAndEquipmentNativeTest.java @@ -61,7 +61,6 @@ class AdminActionIdentityAndEquipmentNativeTest { assertEquals(recipient.core.id.toString(), value(issue.getFirst(), "target_player_id")); assertEquals(slot, issue.getFirst().getAsJsonObject("data").get("delivered_slot").getAsInt()); assertEquals("native_inventory_acknowledged", value(issue.getFirst(), "delivery_outcome")); - settle(f); var rows = actions(data); sequence(rows, kind, "give", "completed"); linked(rows, value(issue.getFirst(), "issue_operation_id")); @@ -101,7 +100,6 @@ class AdminActionIdentityAndEquipmentNativeTest { assertEquals(after.id().toString(), value(grant, "target_character_id")); assertEquals(after.experience().toString(), value(grant, "after_xp")); assertEquals("250", value(grant, "requested_xp")); - settle(f); var rows = actions(data); sequence(rows, kind, "experience", "completed"); linked(rows, value(grant, "operation_id")); @@ -133,7 +131,6 @@ class AdminActionIdentityAndEquipmentNativeTest { assertEquals("pending", value(inspected.getFirst(), "receipt_phase")); assertEquals(pending.sessionId().toString(), value(inspected.getFirst(), "session_id")); assertEquals("packed", value(inspected.getFirst(), "native_phase")); - settle(f); var rows = actions(data); sequence(rows, kind, "inspect", "completed"); assertEquals(target.core.id.toString(), rows.getFirst().getAsJsonObject("data") @@ -159,7 +156,7 @@ class AdminActionIdentityAndEquipmentNativeTest { assertNotNull(retained.pending()); assertEquals(session, retained.pending().sessionId()); target.core.inventory.setItem(0, null); - int prior = actions(data).size(); + int prior = actions(f, data).size(); assertTrue(entry.map.dispatch(entry.sender, "fantasystick:fstickadmin recover " + target.core.id)); ReskillEventsNativeTest.await(f, () -> { try { return f.receipts().find(target.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS) @@ -179,7 +176,6 @@ class AdminActionIdentityAndEquipmentNativeTest { assertEquals("verified_box_and_marker", value(resolved.getFirst(), "custody_quality")); assertEquals(session.toString(), value(resolved.getFirst(), "session_id")); assertNotNull(value(resolved.getFirst(), "equipment_recovery_operation_id")); - settle(f); var rows = actions(data).subList(prior, actions(data).size()); sequence(rows, kind, "recover", "completed"); linked(rows, value(resolved.getFirst(), "recovery_operation_id")); @@ -201,7 +197,7 @@ class AdminActionIdentityAndEquipmentNativeTest { var stored = storage(f).inspect(source.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow(); var current = storage(f).inspect(target.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow(); var checks = new ArrayList(); - int prior = actions(data).size(); + int prior = actions(f, data).size(); assertTrue(entry.map.dispatch(entry.sender, "fstickadmin character view CurrentHolder")); ReskillEventsNativeTest.await(f, () -> !fixture.actor.books.isEmpty()); assertEquals(Material.WRITTEN_BOOK, fixture.actor.books.getLast().getType()); @@ -242,7 +238,7 @@ class AdminActionIdentityAndEquipmentNativeTest { f.activate(target); var saved = storage(f).inspect(target.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow(); var checks = new ArrayList(); - int before = actions(data).size(); + int before = actions(f, data).size(); assertTrue(entry.map.dispatch(entry.sender, "fstickadmin character find " + saved.name())); ReskillEventsNativeTest.await(f, () -> entry.messages().stream().anyMatch(m -> m.contains("Saved character results"))); assertTrue(entry.messages().stream().anyMatch(m -> m.contains(saved.id().toString()))); @@ -275,16 +271,13 @@ class AdminActionIdentityAndEquipmentNativeTest { int messages = fixture.actor.messages.size(); assertTrue(entry.map.dispatch(entry.sender, "fstickadmin inspect " + target.core.id)); fixture.actor.allowed = false; - ReskillEventsNativeTest.await(f, () -> { - try { return domain(data, "action.resolved").stream() - .anyMatch(row -> "permission_changed".equals(value(row, "reason"))); } - catch (Exception error) { throw new AssertionError(error); } - }); + awaitExport(f, () -> domain(data, "action.resolved").stream() + .anyMatch(row -> "permission_changed".equals(value(row, "reason")))); assertEquals(messages, fixture.actor.messages.size()); assertArrayEquals(nativeBefore, target.core.snapshot()); + settle(f); assertTrue(domain(data, "recovery.inspected").isEmpty()); assertEquals("permission_changed", value(domain(data, "action.resolved").getLast(), "reason")); - settle(f); var rows = actions(data); sequence(rows, "player", "inspect", "refused"); assertFalse(rows.getLast().getAsJsonObject("data").has("subject_id")); @@ -304,7 +297,7 @@ class AdminActionIdentityAndEquipmentNativeTest { "experience give NotExact 42", "inspect " + fixture.actor.core.id.toString().substring(0, 8), "recover bad-uuid", "character view NotExact", "character show " + saved.id().toString().substring(0, 8), "character show " + UUID.randomUUID())) { - int offset = actions(data).size(); + int offset = actions(f, data).size(); int feedback = fixture.actor.messages.size(); int books = fixture.actor.books.size(); assertTrue(entry.map.dispatch(entry.sender, "fstickadmin " + command)); @@ -328,6 +321,111 @@ class AdminActionIdentityAndEquipmentNativeTest { } } + @Test void issuedAuditObservationPumpsPendingNativeDeliveryBeforeExport() throws Exception { + try (var fixture = new QuestTrackingAuditNativeFixture(data)) { + var f = fixture.f; + var entry = new AdminActionEntryNativeTest.Entry(fixture, "command_block", true); + var recipient = actor(f, data, "Recipient"); + int slot = recipient.core.inventory.firstEmpty(); + assertTrue(entry.map.dispatch(entry.sender, "fantasystick:fstickadmin give Recipient")); + storage(f).find(UUID.randomUUID()).get(5, TimeUnit.SECONDS); + assertNull(recipient.core.inventory.getItem(slot), "Worker allocation still needs its native delivery callback"); + assertTrue(observeExport(f, () -> domain(data, "character.issued").isEmpty(), TimeUnit.SECONDS.toNanos(5)), + "No success export exists before native delivery"); + awaitDomain(f, data, "character.issued", 1); + var sheets = new CharacterSheets(); + var item = recipient.core.inventory.getItem(slot); + var issued = sheets.identify(item).orElseThrow(); + assertTrue(sheets.authorizes(item, storage(f).find(issued).get(5, TimeUnit.SECONDS).orElseThrow())); + var issue = domain(data, "character.issued"); + assertEquals(1, issue.size()); + assertEquals(issued.toString(), value(issue.getFirst(), "character_id")); + assertEquals(recipient.core.id.toString(), value(issue.getFirst(), "target_player_id")); + sequence(actions(data), "command_block", "give", "completed"); + } + } + + @Test void issuedAuditObservationWaitsForOwnedInFlightAppendBeforeStrictParsing() throws Exception { + try (var fixture = new QuestTrackingAuditNativeFixture(data)) { + var f = fixture.f; + var segment = issueAndSettle(fixture); + var complete = java.nio.file.Files.readAllBytes(segment); + var writer = (java.util.concurrent.ThreadPoolExecutor) FantasyStickLifecycleNativeTest.Fixture.field( + FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog"), "worker"); + var partialVisible = new java.util.concurrent.CountDownLatch(1); + var release = new java.util.concurrent.CountDownLatch(1); + var returned = new java.util.concurrent.CountDownLatch(1); + var premature = new java.util.concurrent.atomic.AtomicBoolean(); + // Replay visibility of the same genuine exported bytes, not a fabricated issue/result. + // Only the fixture-owned writer is active; completed bytes/counts must be unchanged. + var append = writer.submit(() -> { + try { + java.nio.file.Files.write(segment, java.util.Arrays.copyOf(complete, complete.length - 2)); + partialVisible.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + } catch (Exception error) { throw new AssertionError(error); } + finally { + try { java.nio.file.Files.write(segment, complete); } + catch (Exception error) { throw new AssertionError(error); } + } + }); + java.util.concurrent.CompletableFuture observer = null; + try { + assertTrue(partialVisible.await(5, TimeUnit.SECONDS)); + observer = java.util.concurrent.CompletableFuture.runAsync(() -> { + try { premature.set(returned.await(200, TimeUnit.MILLISECONDS)); } + catch (InterruptedException error) { Thread.currentThread().interrupt(); throw new AssertionError(error); } + finally { release.countDown(); } + }); + awaitDomain(f, data, "character.issued", 1); + returned.countDown(); observer.get(5, TimeUnit.SECONDS); + assertFalse(premature.get(), "Audit observation must wait for its owned append, not a visible prefix"); + } finally { + release.countDown(); + try { if (observer != null) observer.get(5, TimeUnit.SECONDS); } + finally { try { append.get(5, TimeUnit.SECONDS); } finally { settle(f); } } + } + assertArrayEquals(complete, java.nio.file.Files.readAllBytes(segment)); + assertEquals(1, domain(data, "character.issued").size()); + sequence(actions(data), "command_block", "give", "completed"); + } + } + + @Test void completedMalformedIssuedAuditStillFailsStrictVerification() throws Exception { + try (var fixture = new QuestTrackingAuditNativeFixture(data)) { + var f = fixture.f; + var segment = issueAndSettle(fixture); + var complete = java.nio.file.Files.readAllBytes(segment); + try { + java.nio.file.Files.write(segment, java.util.Arrays.copyOf(complete, complete.length - 2)); + assertTrue(FixtureAuditAcknowledgmentNativeTest.snapshot(f).idle()); + var failure = assertThrows(AssertionError.class, () -> awaitDomain(f, data, "character.issued", 1)); + assertInstanceOf(com.google.gson.JsonSyntaxException.class, failure.getCause(), + "Completed malformed export must not be skipped, swallowed or accepted as success"); + } finally { java.nio.file.Files.write(segment, complete); } + assertEquals(1, domain(data, "character.issued").size()); + sequence(actions(data), "command_block", "give", "completed"); + } + } + + private Path issueAndSettle(QuestTrackingAuditNativeFixture fixture) throws Exception { + var f = fixture.f; + var entry = new AdminActionEntryNativeTest.Entry(fixture, "command_block", true); + var recipient = actor(f, data, "Recipient"); + int slot = recipient.core.inventory.firstEmpty(); + assertTrue(entry.map.dispatch(entry.sender, "fantasystick:fstickadmin give Recipient")); + storage(f).find(UUID.randomUUID()).get(5, TimeUnit.SECONDS); + ReskillEventsNativeTest.await(f, () -> recipient.core.inventory.getItem(slot) != null); + assertTrue(new CharacterSheets().authorizes(recipient.core.inventory.getItem(slot), + storage(f).find(new CharacterSheets().identify(recipient.core.inventory.getItem(slot)).orElseThrow()) + .get(5, TimeUnit.SECONDS).orElseThrow())); + settle(f); + assertEquals(1, domain(data, "character.issued").size()); + sequence(actions(data), "command_block", "give", "completed"); + return (Path) FantasyStickLifecycleNativeTest.Fixture.field( + FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog"), "segment"); + } + private static FantasyStickLifecycleNativeTest.Actor actor(FantasyStickLifecycleNativeTest.Fixture f, Path root, String name) throws Exception { var actor = new FantasyStickLifecycleNativeTest.Actor(root); actor.accountName = name; @@ -341,15 +439,40 @@ class AdminActionIdentityAndEquipmentNativeTest { return (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"); } private static List actions(Path root) throws Exception { return AdminActionEntryNativeTest.rows(root); } + private static List actions(FantasyStickLifecycleNativeTest.Fixture f, Path root) throws Exception { + return observeExport(f, () -> actions(root), TimeUnit.SECONDS.toNanos(5)); + } private static List domain(Path root, String suffix) throws Exception { return ReskillEventsNativeTest.records(root).stream().filter(row -> row.get("type").getAsString() .equals("games.dmg.minecraft.fantasystick." + suffix)).toList(); } private static void awaitDomain(FantasyStickLifecycleNativeTest.Fixture f, Path root, String suffix, int count) throws Exception { - ReskillEventsNativeTest.await(f, () -> { - try { return domain(root, suffix).size() >= count; } - catch (Exception error) { throw new AssertionError(error); } - }); + awaitExport(f, () -> domain(root, suffix).size() >= count); + } + private static T observeExport(FantasyStickLifecycleNativeTest.Fixture f, + java.util.concurrent.Callable read, long remaining) throws Exception { + var writer = (java.util.concurrent.ThreadPoolExecutor) FantasyStickLifecycleNativeTest.Fixture.field( + FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog"), "worker"); + try { return writer.submit(read).get(remaining, TimeUnit.NANOSECONDS); } + catch (java.util.concurrent.ExecutionException error) { throw new AssertionError(error.getCause()); } + } + private static void awaitExport(FantasyStickLifecycleNativeTest.Fixture f, + java.util.concurrent.Callable observed) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (true) { + long remaining = deadline - System.nanoTime(); + assertTrue(remaining > 0, "Expected audit export did not arrive within its existing five-second bound"); + // Strict file I/O shares the actual owned serial writer: never parse mid-append. + // Missing rows may await native callbacks; malformed completed rows still fail. + if (observeExport(f, observed, remaining)) { + settle(f); // Relocated existing acknowledgment fence before subsequent strict assertions. + return; + } + remaining = deadline - System.nanoTime(); + assertTrue(remaining > 0, "Expected audit export did not arrive within its existing five-second bound"); + var task = f.tasks.poll(Math.min(remaining, TimeUnit.MILLISECONDS.toNanos(50)), TimeUnit.NANOSECONDS); + if (task != null) task.run(); + } } private static void settle(FantasyStickLifecycleNativeTest.Fixture f) throws Exception { FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); -- 2.54.0