This commit is contained in:
1 parent
850a04c4a0
commit
b0996b0c6e
6 files changed
+627
-6
No files matched your search
@@ -174,6 +174,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.events.PassiveEventBridge passiveEvents;
|
||||
private games.dmg.fantasystick.events.RecoveryEventBridge recoveryEvents;
|
||||
private games.dmg.fantasystick.events.ReviveEventBridge reviveEvents;
|
||||
private games.dmg.fantasystick.events.AdminRevivalEventBridge adminRevivalEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> adminRevivalAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.PartyEventBridge partyAuditEvents;
|
||||
private games.dmg.fantasystick.events.CombatRewardEventBridge combatRewardEvents;
|
||||
private games.dmg.fantasystick.events.CoinEventBridge coinEvents;
|
||||
@@ -781,6 +783,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
passiveEvents = new games.dmg.fantasystick.events.PassiveEventBridge(eventLog::emit, Clock.systemUTC());books.attachPassiveEvents(passiveEvents);admin.attachPassiveEvents(passiveEvents);
|
||||
recoveryEvents = new games.dmg.fantasystick.events.RecoveryEventBridge(eventLog::emit, Clock.systemUTC());outOfCombatRecovery.attachEvents(recoveryEvents,record->record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationI.ID)?quickerRegeneration.bonus():java.math.BigInteger.ZERO);
|
||||
reviveEvents = new games.dmg.fantasystick.events.ReviveEventBridge(eventLog::emit, Clock.systemUTC());revive.attachEvents(reviveEvents);
|
||||
adminRevivalEvents = new games.dmg.fantasystick.events.AdminRevivalEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
|
||||
failure -> getLogger().log(Level.WARNING, "Admin revival event intent failed", failure));
|
||||
admin.attachAdminRevivalEvents(adminRevivalEvents);
|
||||
revive.attachAdminRevivalEvents(adminRevivalEvents);
|
||||
partyAuditEvents = new games.dmg.fantasystick.events.PartyEventBridge(eventLog::emit, Clock.systemUTC());partyManagement.attachEvents(partyAuditEvents);admin.attachPartyEvents(partyAuditEvents);
|
||||
partyCombat.attachProtectionEvents(new games.dmg.fantasystick.events.PartyProtectionEventBridge(
|
||||
eventLog::emit, Clock.systemUTC()));
|
||||
@@ -823,7 +830,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
id -> presentation.snapshot().get(id),sessionAuditEvents,eventLog::emit,Clock.systemUTC());
|
||||
getServer().getPluginManager().registerEvents(messageAuditEvents,this);
|
||||
} catch (java.io.IOException failure) {
|
||||
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null; nutritionAuditEvents = null; switchAuditEvents = null; identityAuditEvents = null; equipmentAuditEvents = null; sessionRecoveryAuditEvents = null; messageAuditEvents = null;
|
||||
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; reviveEvents = null; adminRevivalEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null; nutritionAuditEvents = null; switchAuditEvents = null; identityAuditEvents = null; equipmentAuditEvents = null; sessionRecoveryAuditEvents = null; messageAuditEvents = null;
|
||||
getLogger().log(Level.WARNING,"Fantasy Stick local events are unavailable; gameplay remains enabled",failure);
|
||||
}
|
||||
getServer().getPluginManager().registerEvents(crafting, this);
|
||||
@@ -914,6 +921,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous switch event intent drain failed", failure);
|
||||
if (replaySwitchEvents != null) replaySwitchEvents.replay();
|
||||
});
|
||||
var replayAdminRevivalEvents = adminRevivalEvents;
|
||||
adminRevivalAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous admin revival event intent drain failed", failure);
|
||||
if (replayAdminRevivalEvents != null) replayAdminRevivalEvents.replay();
|
||||
});
|
||||
var replayIdentityEvents = identityAuditEvents;
|
||||
identityAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous identity event intent drain failed", failure);
|
||||
@@ -970,6 +982,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingNutritionAuditEvents = nutritionAuditEvents; nutritionAuditEvents = null;
|
||||
var closingSwitchAuditEvents = switchAuditEvents; switchAuditEvents = null;
|
||||
var closingIdentityAuditEvents = identityAuditEvents; identityAuditEvents = null;
|
||||
var closingAdminRevivalAuditEvents = adminRevivalEvents; adminRevivalEvents = null;
|
||||
var closingEquipmentAuditEvents = equipmentAuditEvents; equipmentAuditEvents = null;
|
||||
var closingSessionRecoveryAuditEvents = sessionRecoveryAuditEvents; sessionRecoveryAuditEvents = null;
|
||||
var closingEventCapture = eventCapture; eventCapture = null; var closingMessageAuditEvents = messageAuditEvents; messageAuditEvents = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; abilityEvents = null; passiveEvents = null; recoveryEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null;
|
||||
@@ -1363,6 +1376,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
recipeRegistered = false; // Retain ownership for a later retry if removal failed.
|
||||
});
|
||||
if (closingCharacters != null) cleanup("character storage", closingCharacters::close);
|
||||
if (closingAdminRevivalAuditEvents != null) cleanup("admin revival event intent drain", () -> {
|
||||
var charactersDrained = closingCharacters == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
|
||||
adminRevivalAuditDrain = charactersDrained.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Character save drain failed", failure);
|
||||
closingAdminRevivalAuditEvents.close();
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingAdminRevivalAuditEvents.drained());
|
||||
});
|
||||
if (closingIdentityAuditEvents != null) cleanup("character identity event intent drain", () -> {
|
||||
var charactersDrained = closingCharacters == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
|
||||
|
||||
@@ -53,6 +53,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
private games.dmg.fantasystick.session.DownedController downed;
|
||||
private games.dmg.fantasystick.events.DownedEventBridge downedEvents;
|
||||
private games.dmg.fantasystick.events.ReviveEventBridge reviveEvents;
|
||||
private games.dmg.fantasystick.events.AdminRevivalEventBridge adminRevivalEvents;
|
||||
private java.util.function.Supplier<Duration> downedProtection=()->Duration.ofSeconds(5);
|
||||
private final Map<Inventory, View> inventories = new IdentityHashMap<>();
|
||||
private final Map<UUID, View> targets = new LinkedHashMap<>();
|
||||
@@ -93,6 +94,11 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
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);
|
||||
}
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
return new AbilityDefinition("revive", "Revive", Material.ECHO_SHARD,
|
||||
@@ -358,7 +364,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
: 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)) {
|
||||
if(reviveEvents!=null)reviveEvents.recovered(reservation,record,target);reservations.clear(reservation.character(), reservation.id()); return;
|
||||
recoveryEvent(reservation,record,target);reservations.clear(reservation.character(), reservation.id()); return;
|
||||
}
|
||||
if (record.dead() && unchangedTail && !hasSheet) {
|
||||
reservations.clear(reservation.character(), reservation.id()); return;
|
||||
@@ -375,7 +381,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
.whenComplete((restored, failure) -> schedule(() -> {
|
||||
if (failure != null) { report(failure); return; }
|
||||
if (restored.isPresent() && installFinalSheet(target, restored.orElseThrow())) {
|
||||
if(reviveEvents!=null)reviveEvents.recovered(reservation,restored.orElseThrow(),target);reservations.clear(reservation.character(), reservation.id());
|
||||
recoveryEvent(reservation,restored.orElseThrow(),target);reservations.clear(reservation.character(), reservation.id());
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.AdminReviveSettings;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.RevivalStore;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.command.BlockCommandSender;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Real administrator Tail restoration and acknowledged selection-timeout configuration. */
|
||||
public final class AdminRevivalEventBridge implements AutoCloseable {
|
||||
public record Actor(String kind, EventInput.Context context, UUID id) { }
|
||||
public record Selection(Actor actor, UUID targetId, String targetName,
|
||||
EventInput.Location targetLocation,
|
||||
games.dmg.fantasystick.session.EquipmentSessions.Active targetSession) { }
|
||||
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final FeatureEventOutbox outbox;
|
||||
private final Clock clock;
|
||||
private final games.dmg.fantasystick.session.EquipmentSessions sessions;
|
||||
|
||||
public AdminRevivalEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Clock clock, Path data, games.dmg.fantasystick.session.EquipmentSessions sessions,
|
||||
Consumer<Throwable> failures) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
outbox = new FeatureEventOutbox(data, "admin-revival-event-intents",
|
||||
java.util.Set.of("character.revived", "config.changed"), sink, failures);
|
||||
}
|
||||
|
||||
public Actor actor(CommandSender sender) {
|
||||
try {
|
||||
var player = sender instanceof Player value ? value : null;
|
||||
var kind = player != null ? "player" : sender instanceof BlockCommandSender ? "command_block" : "console";
|
||||
var active = player == null ? null : sessions.active(player.getUniqueId()).orElse(null);
|
||||
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
|
||||
return new Actor(kind, player == null ? null : new EventInput.Context(player.getUniqueId(),
|
||||
player.getName(), record == null ? null : record.id(),
|
||||
record == null ? null : record.name(), record == null ? null : active.session(), safeLocation(player)),
|
||||
player == null ? null : player.getUniqueId());
|
||||
} catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
public Selection selection(Player admin, Player target) {
|
||||
try { return new Selection(actor(admin), target.getUniqueId(), target.getName(),
|
||||
safeLocation(target), sessions.active(target.getUniqueId()).orElse(null)); }
|
||||
catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
public void revived(Selection selection, RevivalStore.Reservation reservation,
|
||||
CharacterRecord dead, CharacterRecord restored, Player target) {
|
||||
if (selection == null || selection.actor() == null) return;
|
||||
try {
|
||||
var data = revivalData(reservation, restored, target);
|
||||
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())));
|
||||
data.put("recovery_reconciliation", false);
|
||||
location(data, "target_location", selection.targetLocation());
|
||||
targetSession(data, selection.targetSession());
|
||||
var actor = selection.actor();
|
||||
data.put("admin_player_id", selection.actor().id().toString());
|
||||
data.put("admin_player_name", actor.context().playerName());
|
||||
if (actor.context().location() == null) data.put("actor_location_status", "unavailable");
|
||||
durable("character.revived", reservation.id(), actor.context(), data);
|
||||
} catch (RuntimeException ignored) { /* Audit failure cannot consume or restore a Tail. */ }
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
if (!reservation.administrative()) return;
|
||||
try {
|
||||
var data = revivalData(reservation, restored, target);
|
||||
data.put("actor_kind", "system");
|
||||
data.put("admin_player_id", reservation.casterCharacter().toString());
|
||||
data.put("admin_name_status", "unavailable");
|
||||
data.put("actor_location_status", "unavailable");
|
||||
data.put("revision_before", restored.revision().subtract(BigInteger.ONE).toString());
|
||||
data.put("death_time", restored.diedAt().toString());
|
||||
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));
|
||||
durable("character.revived", reservation.id(), null, data);
|
||||
} catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
private Map<String, Object> revivalData(RevivalStore.Reservation reservation, CharacterRecord restored, Player target) {
|
||||
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("character_id", restored.id().toString());
|
||||
data.put("character_name", restored.name());
|
||||
data.put("death_id", reservation.death().toString());
|
||||
data.put("revision_after", restored.revision().toString());
|
||||
data.put("tail_slot", reservation.sourceSlot());
|
||||
data.put("tail_custody_id", reservation.death().toString());
|
||||
data.put("sheet_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("restored_health", restored.health().toPlainString());
|
||||
data.put("maximum_health", restored.maximumHealth().toPlainString());
|
||||
data.put("cooldowns_retained", true);
|
||||
data.put("native_delivery", "acknowledged");
|
||||
data.put("outcome", "acknowledged");
|
||||
return data;
|
||||
}
|
||||
|
||||
public void read(Actor actor, String effective) {
|
||||
if (actor == null) return;
|
||||
var data = config(actor);
|
||||
data.put("effective", effective);
|
||||
data.put("outcome", "acknowledged_read");
|
||||
emit(UUID.randomUUID(), "config.read", actor.context(), data);
|
||||
}
|
||||
|
||||
public void changed(Actor actor, String before, AdminReviveSettings.Update saved) {
|
||||
if (actor == null) return;
|
||||
var data = config(actor);
|
||||
data.put("before", before);
|
||||
data.put("after", saved.value());
|
||||
data.put("effective", saved.value());
|
||||
data.put("outcome", "acknowledged");
|
||||
durable("config.changed", UUID.randomUUID(), actor.context(), data);
|
||||
}
|
||||
|
||||
private static Map<String, Object> config(Actor actor) {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("actor_kind", actor.kind());
|
||||
data.put("key", AdminReviveSettings.KEY);
|
||||
data.put("applies_to", "later_selections");
|
||||
if (actor.context() == null) data.put("location_status", "not_applicable");
|
||||
else if (actor.context().location() == null) data.put("location_status", "unavailable");
|
||||
return data;
|
||||
}
|
||||
|
||||
private static void targetSession(Map<String, Object> data,
|
||||
games.dmg.fantasystick.session.EquipmentSessions.Active active) {
|
||||
if (active == null) { data.put("target_session_status", "no_active_session"); return; }
|
||||
data.put("target_session_id", active.session().toString());
|
||||
data.put("target_active_character_id", active.character().toString());
|
||||
data.put("target_session_status", "captured");
|
||||
}
|
||||
|
||||
private void location(Map<String, Object> data, String key, EventInput.Location value) {
|
||||
if (value == null) { data.put(key + "_status", "unavailable"); return; }
|
||||
data.put(key, Map.of("world_id", value.worldId().toString(), "world_name", value.worldName(),
|
||||
"x", value.x(), "y", value.y(), "z", value.z(),
|
||||
"captured_at", value.capturedAt().toString(), "quality", value.quality()));
|
||||
}
|
||||
|
||||
private EventInput.Location safeLocation(Player player) {
|
||||
try { return location(player.getLocation()); }
|
||||
catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
private EventInput.Location location(Location value) {
|
||||
try {
|
||||
if (value == null || value.getWorld() == null) return null;
|
||||
return new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(),
|
||||
value.getX(), value.getY(), value.getZ(), clock.instant(), "exact");
|
||||
} catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
private void durable(String type, UUID action, EventInput.Context context, Map<String, Object> data) {
|
||||
try { outbox.accepted(new EventInput(id(action, type), action, clock.instant(), type, context, data)); }
|
||||
catch (RuntimeException ignored) { }
|
||||
}
|
||||
private void emit(UUID action, String type, EventInput.Context context, Map<String, Object> data) {
|
||||
try { sink.apply(new EventInput(id(action, type), action, clock.instant(), type, context, data)); }
|
||||
catch (RuntimeException ignored) { }
|
||||
}
|
||||
private static UUID id(UUID action, String kind) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:admin-revival:" + action + ":" + kind)
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
public void replay() { outbox.replay(); }
|
||||
public CompletionStage<Void> drained() { return outbox.drained(); }
|
||||
@Override public void close() { outbox.close(); }
|
||||
}
|
||||
@@ -36,6 +36,12 @@ public final class AdminRevival implements Listener, AutoCloseable {
|
||||
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 games.dmg.fantasystick.events.AdminRevivalEventBridge auditEvents;
|
||||
|
||||
public void attachEvents(games.dmg.fantasystick.events.AdminRevivalEventBridge events) {
|
||||
if (closed || auditEvents != null) throw new IllegalStateException("Admin revival events already attached");
|
||||
auditEvents = Objects.requireNonNull(events);
|
||||
}
|
||||
|
||||
private final class View implements AutoCloseable {
|
||||
final Player admin,target;final Inventory inventory;final Map<Integer,Choice> choices;final Instant deadline;final AutoCloseable expiry;boolean selecting,finished;
|
||||
@@ -58,9 +64,9 @@ public final class AdminRevival implements Listener, AutoCloseable {
|
||||
@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();}
|
||||
|
||||
private void confirm(View view,Choice choice){if(!view.current()||!unchanged(view.target,choice.candidate)){view.close();return;}characters.find(choice.record.id()).whenComplete((found,failure)->schedule(()->{if(failure!=null){report(failure);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)){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){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);reservations.reserve(reservation).whenComplete((saved,failure)->schedule(()->{if(failure!=null){report(failure);view.admin.sendMessage("Admin revival reservation failed; no item changed.");view.close();return;}if(saved.isEmpty()||!view.current()||!unchanged(view.target,candidate)){view.admin.sendMessage("Admin revival became stale after reservation; no item changed.");view.close();return;}deliver(view,dead,reservation);}));}
|
||||
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){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);view.admin.sendMessage("Admin revival reservation failed; no item changed.");view.close();return;}if(saved.isEmpty()||!view.current()||!unchanged(view.target,candidate)){view.admin.sendMessage("Admin revival became stale after reservation; no item changed.");view.close();return;}deliver(view,dead,reservation,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){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);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);view.admin.sendMessage("The Tail changed, but restoration is pending proof-based recovery.");view.close();return;}if(installFinal(view.target,restored.orElseThrow())){reservations.clear(reservation.character(),reservation.id()).exceptionally(error->{report(error);return false;});view.admin.sendMessage("Administratively restored "+restored.orElseThrow().name()+" for "+view.target.getName()+".");view.target.sendMessage("An administrator restored character sheet: "+restored.orElseThrow().name()+".");}else view.admin.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");view.close();}));}
|
||||
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);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);view.admin.sendMessage("The Tail changed, but restoration is pending proof-based recovery.");view.close();return;}if(installFinal(view.target,restored.orElseThrow())){if(auditEvents!=null)auditEvents.revived(audit,reservation,dead,restored.orElseThrow(),view.target);reservations.clear(reservation.character(),reservation.id()).exceptionally(error->{report(error);return false;});view.admin.sendMessage("Administratively restored "+restored.orElseThrow().name()+" for "+view.target.getName()+".");view.target.sendMessage("An administrator restored character sheet: "+restored.orElseThrow().name()+".");}else 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 true;}
|
||||
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){}}
|
||||
|
||||
@@ -68,6 +68,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private games.dmg.fantasystick.ability.Meteor meteor;
|
||||
private AdminCharacterIssuer characterIssuer;
|
||||
private AdminRevival adminRevival;
|
||||
private games.dmg.fantasystick.events.AdminRevivalEventBridge adminRevivalEvents;
|
||||
private AdminExperienceGrant experienceGrant;
|
||||
private games.dmg.fantasystick.persistence.AdminReviveSettings adminReviveSettings;
|
||||
private games.dmg.fantasystick.persistence.SwitchDelaySettings switchDelaySettings;
|
||||
@@ -398,6 +399,13 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
adminRevival=Objects.requireNonNull(revival);adminReviveSettings=Objects.requireNonNull(settings);
|
||||
}
|
||||
|
||||
public void attachAdminRevivalEvents(games.dmg.fantasystick.events.AdminRevivalEventBridge events) {
|
||||
if (closed || adminRevivalEvents != null || adminRevival == null)
|
||||
throw new IllegalStateException("Admin revival event bridge unavailable");
|
||||
adminRevivalEvents = Objects.requireNonNull(events);
|
||||
adminRevival.attachEvents(events);
|
||||
}
|
||||
|
||||
public void attachCharacterIssuer(AdminCharacterIssuer issuer) {
|
||||
if (closed || characterIssuer != null) throw new IllegalStateException("Character issuer already attached or command closed");
|
||||
characterIssuer = Objects.requireNonNull(issuer);
|
||||
@@ -1864,7 +1872,38 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void switchDelayConfiguration(CommandSender sender,String[] args){if(switchDelaySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY+"="+switchDelaySettings.value()+" (new exits use this delay; existing deadlines are unchanged)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SwitchDelaySettings.Update> operation;try{operation=switchDelaySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Character switch delay is invalid; the effective value is unchanged.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-switch-delay",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure==null)switchDelaySettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Saved "+games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY+"="+saved.value()+"; new exits use it and existing deadlines are unchanged.":"Character switch delay save failed; the effective value is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
|
||||
private void adminReviveConfiguration(CommandSender sender,String[] args){if(adminReviveSettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY+"="+adminReviveSettings.value()+" (applies to later admin revival selections only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.AdminReviveSettings.Update> operation;try{operation=adminReviveSettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Admin revival timeout is invalid; the effective value is unchanged.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-admin-revive",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure==null)adminReviveSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Saved "+games.dmg.fantasystick.persistence.AdminReviveSettings.KEY+"="+saved.value()+" for later selections; open selections retain their deadline.":"Admin revival configuration save failed; the effective setting is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
private void adminReviveConfiguration(CommandSender sender,String[] args){
|
||||
if(adminReviveSettings==null){configurationUsage(sender);return;}
|
||||
var audit = adminRevivalEvents;
|
||||
if(args.length==2){
|
||||
var value=adminReviveSettings.value();
|
||||
if(audit!=null)audit.read(audit.actor(sender),value);
|
||||
sender.sendMessage(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY+"="+value
|
||||
+" (applies to later admin revival selections only)");
|
||||
return;
|
||||
}
|
||||
var actor=audit==null?null:audit.actor(sender);
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.AdminReviveSettings.Update> operation;
|
||||
try{operation=adminReviveSettings.set(args[2]);}
|
||||
catch(IllegalArgumentException invalid){sender.sendMessage("Admin revival timeout is invalid; the effective value is unchanged.");return;}
|
||||
operation.whenComplete((saved,failure)->{
|
||||
if(failure!=null)report(null,"config-admin-revive",failure);
|
||||
if(closed)return;
|
||||
try{nextTick.accept(()->{
|
||||
if(closed)return;
|
||||
if(failure==null){
|
||||
var before=adminReviveSettings.value();
|
||||
adminReviveSettings.publish(saved);
|
||||
if(audit!=null && !before.equals(adminReviveSettings.value()))
|
||||
audit.changed(actor,before,saved);
|
||||
}
|
||||
if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;
|
||||
sender.sendMessage(failure==null?"Saved "+games.dmg.fantasystick.persistence.AdminReviveSettings.KEY+"="+saved.value()
|
||||
+" for later selections; open selections retain their deadline."
|
||||
:"Admin revival configuration save failed; the effective setting is unchanged.");
|
||||
});}catch(RuntimeException rejected){report(null,"config-response",rejected);}
|
||||
});
|
||||
}
|
||||
|
||||
private void adventureConfiguration(CommandSender sender, String[] args) {
|
||||
var action = sessionRecoveryEvents == null ? null : sessionRecoveryEvents.capture(sender, null);
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.bukkit.event.inventory.ClickType;
|
||||
import org.bukkit.event.inventory.InventoryAction;
|
||||
import org.bukkit.event.inventory.InventoryType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class AdminRevivalEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void registeredSelectionPublishesOneAcknowledgedAdminTailRestorationNotMenuOrRequest() throws Exception {
|
||||
UUID character;
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var target = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
doReturn("Administrator").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 active = ((games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"))
|
||||
.active(target.core.id).orElseThrow();
|
||||
var dead = AdminRevivalNativeTest.dead(data, Instant.now().minusSeconds(172800)
|
||||
.truncatedTo(ChronoUnit.SECONDS));
|
||||
character = dead.id();
|
||||
target.core.inventory.setItem(10, new games.dmg.fantasystick.items.TailBooks().create(dead));
|
||||
f.adminCommand.execute(admin.player, "fstickadmin", new String[]{"revive", "Target"});
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
storage.find(dead.id()).get(5, TimeUnit.SECONDS); f.tick();
|
||||
assertEquals(1, admin.inventories.size());
|
||||
assertTrue(revivals().isEmpty(), "Opening the selection is not a revival");
|
||||
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);
|
||||
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());
|
||||
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);
|
||||
var saved = storage.find(dead.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertFalse(saved.dead());
|
||||
assertEquals(dead.id(), new games.dmg.fantasystick.items.CharacterSheets()
|
||||
.identify(target.core.inventory.getItem(10)).orElseThrow());
|
||||
for (int i = 0; i < 200 && revivals().isEmpty(); i++) Thread.sleep(10);
|
||||
var own = revivals(); assertEquals(1, own.size());
|
||||
var event = own.getFirst(); var payload = event.getAsJsonObject("data");
|
||||
assertEquals("admin_tail", payload.get("revival_kind").getAsString());
|
||||
assertEquals(admin.core.id.toString(), payload.get("player_id").getAsString());
|
||||
assertEquals(target.core.id.toString(), payload.get("target_player_id").getAsString());
|
||||
assertEquals(dead.id().toString(), payload.get("character_id").getAsString());
|
||||
assertEquals(dead.deathId().toString(), payload.get("death_id").getAsString());
|
||||
assertEquals(dead.revision().toString(), payload.get("revision_before").getAsString());
|
||||
assertEquals(saved.revision().toString(), payload.get("revision_after").getAsString());
|
||||
assertEquals(10, payload.get("tail_slot").getAsInt());
|
||||
assertEquals(active.session().toString(), payload.get("target_session_id").getAsString());
|
||||
assertEquals(active.character().toString(), payload.get("target_active_character_id").getAsString());
|
||||
assertEquals("acknowledged", payload.get("outcome").getAsString());
|
||||
assertEquals(admin.world.getUID().toString(), payload.getAsJsonObject("location").get("world_id").getAsString());
|
||||
assertEquals(target.world.getUID().toString(), payload.getAsJsonObject("target_location").get("world_id").getAsString());
|
||||
f.dispatcher.accept(click);
|
||||
assertEquals(1, revivals().size(), "Repeated selection cannot create another revival");
|
||||
}
|
||||
assertEquals(1, revivals().stream().filter(event -> event.getAsJsonObject("data")
|
||||
.get("character_id").getAsString().equals(character.toString())).count());
|
||||
}
|
||||
|
||||
@Test void registeredTimeoutReadAndAcknowledgedUpdateReachExportedJsonlOnlyAfterSave() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", "admin-revive-selection-timeout-seconds"});
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", "admin-revive-selection-timeout-seconds", "90"});
|
||||
assertTrue(sender.messages.getLast().contains("=60"), "Queued save cannot claim an update");
|
||||
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
|
||||
assertTrue(sender.messages.getLast().contains("=90"));
|
||||
for (int i = 0; i < 200 && ownConfig().size() < 2; i++) Thread.sleep(10);
|
||||
var config = ownConfig();
|
||||
assertEquals(1, config.stream().filter(event -> event.get("type").getAsString().endsWith("config.read")).count());
|
||||
var changed = config.stream().filter(event -> event.get("type").getAsString().endsWith("config.changed")).toList();
|
||||
assertEquals(1, changed.size());
|
||||
assertEquals("60", changed.getFirst().getAsJsonObject("data").get("before").getAsString());
|
||||
assertEquals("90", changed.getFirst().getAsJsonObject("data").get("after").getAsString());
|
||||
assertEquals("later_selections", changed.getFirst().getAsJsonObject("data").get("applies_to").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void queuedAcknowledgedTimeoutChangesDescribeActualEffectiveTransitions() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{
|
||||
"config", "admin-revive-selection-timeout-seconds", "90"});
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{
|
||||
"config", "admin-revive-selection-timeout-seconds", "120"});
|
||||
for (int i = 0; i < 20 && sender.messages.size() < 2; i++) {
|
||||
var callback = f.tasks.poll(5, TimeUnit.SECONDS);
|
||||
assertNotNull(callback, "Both acknowledged configuration responses need a native callback");
|
||||
callback.run();
|
||||
}
|
||||
assertEquals(2, sender.messages.size(), sender.messages.toString());
|
||||
assertTrue(sender.messages.stream().allMatch(message -> message.startsWith("Saved ")),
|
||||
sender.messages.toString());
|
||||
for (int i = 0; i < 200 && ownConfig().stream().filter(event -> event.get("type")
|
||||
.getAsString().endsWith("config.changed")).count() < 2; i++) Thread.sleep(10);
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var changed = ownConfig().stream().filter(event -> event.get("type").getAsString()
|
||||
.endsWith("config.changed")).map(event -> event.getAsJsonObject("data")).toList();
|
||||
assertEquals(2, changed.size());
|
||||
assertEquals(List.of("60", "90"), changed.stream().map(data -> data.get("before").getAsString()).toList());
|
||||
assertEquals(List.of("90", "120"), changed.stream().map(data -> data.get("after").getAsString()).toList());
|
||||
assertEquals(List.of("90", "120"), changed.stream().map(data -> data.get("effective").getAsString()).toList());
|
||||
}
|
||||
|
||||
@Test void failedAuthoritativeRestorationRetainsProofButExportsNoRevival() 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("Administrator").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 = AdminRevivalNativeTest.dead(data, Instant.now().minusSeconds(172800)
|
||||
.truncatedTo(ChronoUnit.SECONDS));
|
||||
target.core.inventory.setItem(10, new games.dmg.fantasystick.items.TailBooks().create(dead));
|
||||
f.adminCommand.execute(admin.player, "fstickadmin", 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");
|
||||
Files.delete(path); Files.createDirectory(path);
|
||||
} catch (Exception error) { throw new RuntimeException(error); }
|
||||
};
|
||||
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,
|
||||
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);
|
||||
assertTrue(reservations.find(dead.id()).join().isPresent());
|
||||
assertFalse(admin.messages.stream().anyMatch(message -> message.startsWith("Administratively restored")));
|
||||
target.core.mutation = () -> {};
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
assertTrue(revivals().isEmpty(), "A provisional sheet is not authoritative revival");
|
||||
}
|
||||
|
||||
@Test void failedAuditIntentStoreCannotBlockAcknowledgedAdminTailRestoration() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
Files.writeString(data.resolve("admin-revival-event-intents"), "unavailable");
|
||||
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var target = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
doReturn("Administrator").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 = AdminRevivalNativeTest.dead(data, Instant.now().minusSeconds(172800)
|
||||
.truncatedTo(ChronoUnit.SECONDS));
|
||||
target.core.inventory.setItem(10, new games.dmg.fantasystick.items.TailBooks().create(dead));
|
||||
f.adminCommand.execute(admin.player, "fstickadmin", new String[]{"revive", "Target"});
|
||||
PartyBookNativeTest.drain(f);
|
||||
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,
|
||||
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);
|
||||
var record = ((games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"))
|
||||
.find(dead.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertFalse(record.dead());
|
||||
assertEquals(dead.id(), new games.dmg.fantasystick.items.CharacterSheets()
|
||||
.identify(target.core.inventory.getItem(10)).orElseThrow());
|
||||
assertTrue(admin.messages.stream().anyMatch(message -> message.contains("Administratively restored")));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
assertTrue(revivals().isEmpty(), "An unavailable store cannot assert export or undo gameplay");
|
||||
}
|
||||
|
||||
@Test void confirmedInterruptedAdminReservationReconcilesWithStableIdentityAfterRestart() throws Exception {
|
||||
var target = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
doReturn("Target").when(target.player).getName();
|
||||
var dead = AdminRevivalNativeTest.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 adminId = UUID.randomUUID();
|
||||
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);
|
||||
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 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);
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertFalse(storage.find(dead.id()).get(5, TimeUnit.SECONDS).orElseThrow().dead());
|
||||
for (int i = 0; i < 200 && revivals().isEmpty(); i++) Thread.sleep(10);
|
||||
var entries = revivals(); assertEquals(1, entries.size());
|
||||
var event = entries.getFirst(); var values = event.getAsJsonObject("data");
|
||||
assertEquals(operation.toString(), values.get("revival_operation_id").getAsString());
|
||||
assertEquals(adminId.toString(), values.get("admin_player_id").getAsString());
|
||||
assertEquals("system", values.get("actor_kind").getAsString());
|
||||
assertTrue(values.get("recovery_reconciliation").getAsBoolean());
|
||||
assertEquals("unavailable", values.get("actor_location_status").getAsString());
|
||||
assertEquals(dead.id().toString(), values.get("character_id").getAsString());
|
||||
}
|
||||
try (var restarted = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
restarted.players.put(target.core.id, target.player); restarted.plugin.setEnabled(true);
|
||||
for (int i = 0; i < 4; i++) PartyBookNativeTest.drain(restarted);
|
||||
}
|
||||
assertEquals(1, revivals().size(), "Reconciliation and a later restart cannot export duplicate success");
|
||||
}
|
||||
|
||||
@Test void deniedInvalidAndFailedTimeoutUpdatesCannotProduceConfigChanged() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
sender.allowed = false;
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", "admin-revive-selection-timeout-seconds", "90"});
|
||||
sender.allowed = true;
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", "admin-revive-selection-timeout-seconds", "0"});
|
||||
Files.createDirectory(data.resolve("config.yml"));
|
||||
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", "admin-revive-selection-timeout-seconds", "90"});
|
||||
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
|
||||
assertTrue(sender.messages.getLast().contains("save failed"));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
assertTrue(ownConfig().stream().noneMatch(event -> event.get("type").getAsString().endsWith("config.changed")));
|
||||
}
|
||||
|
||||
@Test void cancelledOrStaleTailSelectionCannotExportRevival() throws Exception {
|
||||
for (var route : List.of("cancel", "movement", "permission")) {
|
||||
var dir = data.resolve(route); Files.createDirectories(dir);
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(dir)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var admin = new FantasyStickLifecycleNativeTest.Actor(dir);
|
||||
var target = new FantasyStickLifecycleNativeTest.Actor(dir);
|
||||
doReturn("Administrator").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 = AdminRevivalNativeTest.dead(dir, Instant.now().minusSeconds(172800)
|
||||
.truncatedTo(ChronoUnit.SECONDS));
|
||||
var tail = new games.dmg.fantasystick.items.TailBooks().create(dead);
|
||||
target.core.inventory.setItem(10, tail);
|
||||
f.adminCommand.execute(admin.player, "fstickadmin", new String[]{"revive", "Target"});
|
||||
((games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"))
|
||||
.find(dead.id()).get(5, TimeUnit.SECONDS);
|
||||
f.tick();
|
||||
var popup = admin.inventories.getFirst();
|
||||
if (!route.equals("cancel")) {
|
||||
if (route.equals("movement")) target.core.inventory.setItem(10,
|
||||
new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND));
|
||||
if (route.equals("permission")) doReturn(false).when(admin.player).hasPermission("fantasystick.admin");
|
||||
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,
|
||||
InventoryType.SlotType.CONTAINER, 0, ClickType.LEFT, InventoryAction.PICKUP_ALL));
|
||||
PartyBookNativeTest.drain(f);
|
||||
}
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
assertTrue(events(dir).stream().noneMatch(event -> event.get("type").getAsString()
|
||||
.endsWith("character.revived")), route + " did not restore an acknowledged character");
|
||||
}
|
||||
}
|
||||
|
||||
private List<JsonObject> revivals() throws Exception {
|
||||
return events().stream().filter(event -> event.get("type").getAsString().endsWith("character.revived"))
|
||||
.filter(event -> event.getAsJsonObject("data").has("revival_kind")
|
||||
&& event.getAsJsonObject("data").get("revival_kind").getAsString().equals("admin_tail")).toList();
|
||||
}
|
||||
private List<JsonObject> ownConfig() throws Exception {
|
||||
return events().stream().filter(event -> event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed"))
|
||||
.filter(event -> event.getAsJsonObject("data").has("key")
|
||||
&& event.getAsJsonObject("data").get("key").getAsString()
|
||||
.equals("admin-revive-selection-timeout-seconds")).toList();
|
||||
}
|
||||
private List<JsonObject> events() throws Exception { return events(data); }
|
||||
private List<JsonObject> events(Path root) throws Exception {
|
||||
var result = new ArrayList<JsonObject>();
|
||||
if (!Files.isDirectory(root.resolve("events"))) return result;
|
||||
try (var days = Files.list(root.resolve("events"))) {
|
||||
for (var day : days.filter(path -> path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}"))
|
||||
.toList()) {
|
||||
try (var segments = Files.list(day)) {
|
||||
for (var file : segments.filter(path -> path.getFileName().toString().startsWith("events-")
|
||||
&& path.getFileName().toString().endsWith(".jsonl")).toList()) {
|
||||
for (var line : Files.readAllLines(file)) {
|
||||
if (!line.isBlank()) result.add(JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user