fix(revival): preserve Tail authority and native custody
Release / release (push) Failing after 7m26s
CI / build (push) Successful in 8m1s

This commit was merged in pull request #7.
This commit is contained in:
dmg committed 2026-10-07 23:10:40 +00:00
commit 1a1b1e0693
16 files changed
+1347 -151

No files matched your search

@@ -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()
@@ -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<Runnable> nextTick;
private final PopupFactory popups;
private final Consumer<Throwable> failures;
private final java.util.function.Function<UUID, Player> 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<Runnable> nextTick,
PopupFactory popups, Consumer<Throwable> failures) {
PopupFactory popups, Consumer<Throwable> failures, java.util.function.Function<UUID, Player> 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<Duration> 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();
}
}
@@ -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<EventInput, CompletionStage<LocalEventLog.Delivery>> 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<String, Object> revivalData(RevivalStore.Reservation reservation, CharacterRecord restored, Player target) {
private Map<String, Object> revivalData(RevivalStore.Reservation reservation, CharacterRecord restored, String targetName) {
var data = new LinkedHashMap<String, Object>();
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());
@@ -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<String,Object> 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<EventInput> sink;private final Clock clock;
public ReviveEventBridge(Consumer<EventInput> 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<String,Object>();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<String,Object>();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<String,Object> extra){var data=new LinkedHashMap<String,Object>();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()));}
@@ -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<Component> 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;
@@ -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<java.util.UUID, Player> 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<java.util.UUID, Player> 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<Runnable> nextTick,
Consumer<Throwable> failures, Player target, RevivalStore.Reservation reservation, java.util.function.Function<java.util.UUID, Player> players,
BooleanSupplier current, java.util.function.Function<CharacterRecord, Runnable> 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<Runnable> nextTick, Consumer<Throwable> failures, Runnable action) {
try { nextTick.accept(action); } catch (RuntimeException rejected) { failures.accept(rejected); }
}
}
@@ -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<Optional<CharacterRecord>> 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<Optional<CharacterRecord>> restoreDeathRecordAdministratively(
UUID characterId, UUID deathId, java.math.BigDecimal nativeLimit) {
return restore(characterId, deathId, nativeLimit, false);
return restore(characterId, deathId, nativeLimit, false, null);
}
public CompletableFuture<Optional<CharacterRecord>> restoreDeathRecord(UUID characterId, UUID deathId,
java.math.BigDecimal nativeLimit, BigInteger expectedGeneration) {
return restore(characterId, deathId, nativeLimit, true, Objects.requireNonNull(expectedGeneration));
}
public CompletableFuture<Optional<CharacterRecord>> restoreDeathRecordAdministratively(UUID characterId,
UUID deathId, java.math.BigDecimal nativeLimit, BigInteger expectedGeneration) {
return restore(characterId, deathId, nativeLimit, false, Objects.requireNonNull(expectedGeneration));
}
private CompletableFuture<Optional<CharacterRecord>> 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(),
@@ -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<String> 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<String> 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<Optional<Reservation>> 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<Optional<Reservation>> 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<Boolean> 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<Boolean> 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<Boolean> 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<String, Object>();
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);
}
@@ -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<Runnable> nextTick; private final Scheduler scheduler; private final PopupFactory popups;
private final Consumer<Throwable> failures; private final TailBooks tails=new TailBooks();private final CharacterSheets sheets=new CharacterSheets();
private final Map<Inventory,View> views=new IdentityHashMap<>();private boolean closed;
private final Consumer<Throwable> failures; private final TailBooks tails=new TailBooks();
private final java.util.function.Function<UUID,Player> players;
private final Map<Inventory,View> views=new IdentityHashMap<>();
private final java.util.Set<View> 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<Integer,Choice> choices;final Instant deadline;final AutoCloseable expiry;final AdminActionEventBridge.Action action;boolean selecting,finished;
View(Player admin,Player target,Inventory inventory,Map<Integer,Choice> 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<Runnable> nextTick,Scheduler scheduler,PopupFactory popups,Consumer<Throwable> 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<Runnable> nextTick,Scheduler scheduler,PopupFactory popups,Consumer<Throwable> failures,java.util.function.Function<UUID,Player> 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<Choice>();for(int i=0;i<candidates.size();i++){var found=loads.get(i).join();if(found.isPresent()&&eligible(found.orElseThrow(),candidates.get(i).identity)&&unchanged(target,candidates.get(i)))choices.add(new Choice(candidates.get(i),found.orElseThrow()));}if(choices.isEmpty()){admin.sendMessage("That player has no current unconsumed Tail.");return;}show(admin,target,choices,timeout,action);}));}
private List<Candidate> candidates(Player target){var result=new ArrayList<Candidate>();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<Choice> 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<Integer,Choice>();for(int i=0;i<eligible.size()&&i<size;i++){var choice=eligible.get(i);var item=choice.candidate.item.clone();item.setAmount(1);var meta=item.getItemMeta();meta.lore(List.of(Component.text("Character: "+choice.record.name()),Component.text("Level "+choice.record.level()+" · target slot "+choice.candidate.slot),Component.text("Click to restore this exact death")));item.setItemMeta(meta);inventory.setItem(i,item);choices.put(i,choice);}var view=new View(admin,target,inventory,Map.copyOf(choices),clock.instant().plus(timeout),action);views.put(inventory,view);admin.openInventory(inventory);}
private void show(Player admin,Player target,List<Choice> 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<Integer,Choice>();for(int i=0;i<eligible.size()&&i<size;i++){var choice=eligible.get(i);var item=choice.candidate.item.clone();item.setAmount(1);var meta=item.getItemMeta();meta.lore(List.of(Component.text("Character: "+choice.record.name()),Component.text("Level "+choice.record.level()+" · target slot "+choice.candidate.slot),Component.text("Click to restore this exact death")));item.setItemMeta(meta);inventory.setItem(i,item);choices.put(i,choice);}var view=new View(admin,target,inventory,Map.copyOf(choices),clock.instant().plus(timeout),action);views.put(inventory,view);activeViews.add(view);admin.openInventory(inventory);}
@EventHandler(priority=EventPriority.HIGHEST) public void click(org.bukkit.event.inventory.InventoryClickEvent event){var view=views.get(event.getView().getTopInventory());if(view==null)return;event.setCancelled(true);if(event.getWhoClicked()!=view.admin||event.getRawSlot()<0||event.getRawSlot()>=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->slot<view.inventory.getSize()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.MONITOR) public void close(org.bukkit.event.inventory.InventoryCloseEvent event){var view=views.get(event.getView().getTopInventory());if(view!=null&&!view.selecting)view.close();}
@EventHandler(priority=EventPriority.MONITOR) public void quit(org.bukkit.event.player.PlayerQuitEvent event){for(var view:List.copyOf(views.values()))if(view.admin==event.getPlayer()||view.target==event.getPlayer())view.close();}
@EventHandler(priority=EventPriority.MONITOR) public void quit(org.bukkit.event.player.PlayerQuitEvent event){for(var view:List.copyOf(activeViews))if(view.admin==event.getPlayer()||view.target==event.getPlayer())view.close();}
private void confirm(View view,Choice choice){if(!view.current()||!unchanged(view.target,choice.candidate)){outcome(view.action,AdminActionEventBridge.Outcome.REFUSED,"stale",Map.of());view.close();return;}characters.find(choice.record.id()).whenComplete((found,failure)->schedule(()->{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();}
}
@@ -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<org.junit.jupiter.api.function.Executable>();
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<org.junit.jupiter.api.function.Executable>();
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<Void> 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<JsonObject> actions(Path root) throws Exception { return AdminActionEntryNativeTest.rows(root); }
private static List<JsonObject> actions(FantasyStickLifecycleNativeTest.Fixture f, Path root) throws Exception {
return observeExport(f, () -> actions(root), TimeUnit.SECONDS.toNanos(5));
}
private static List<JsonObject> 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> T observeExport(FantasyStickLifecycleNativeTest.Fixture f,
java.util.concurrent.Callable<T> 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<Boolean> 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);
@@ -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);
@@ -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;}
}
@@ -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());
}
@@ -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<ItemStack>();
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);
}
}
}
@@ -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());
@@ -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<String, Object> 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<String, Object> 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);