Compare commits

...
26 Commits
Author SHA1 Message Date
dmg 13828054e5 feat(events): audit combat feedback preferences
Release / release (push) Failing after 14m7s
2026-09-25 09:48:27 -04:00
dmg 39c58ef4d9 feat(events): audit account aura preferences
Release / release (push) Successful in 14m40s
2026-09-25 09:37:45 -04:00
dmg ec73018f93 feat(events): audit account particle preferences
Release / release (push) Successful in 14m30s
2026-09-25 09:21:18 -04:00
dmg b86f703d14 feat(events): record Meteor impact outcomes
Release / release (push) Successful in 12m56s
2026-09-25 03:19:10 -04:00
dmg 2231dd058a feat(events): record War Banner benefit outcomes
Release / release (push) Successful in 7m10s
2026-09-25 03:11:25 -04:00
dmg ff28f2e98d feat(events): record Sanctuary area and healing outcomes
Release / release (push) Successful in 7m19s
2026-09-25 03:02:45 -04:00
dmg 21877f6820 feat(events): record Chain Lightning strike outcomes
Release / release (push) Failing after 13m13s
2026-09-25 02:50:24 -04:00
dmg 228de3d08f feat(events): record Frost Nova burst outcomes
Release / release (push) Successful in 7m29s
2026-09-25 02:42:05 -04:00
dmg e3bc854481 feat(events): record Rallying Cry boost outcomes
Release / release (push) Successful in 13m10s
2026-09-25 02:28:10 -04:00
dmg 3163a491a0 feat(events): record Guardian Bond damage outcomes
Release / release (push) Successful in 13m31s
2026-09-25 02:18:00 -04:00
dmg 08d13d2192 feat(events): record Purify cleanse outcomes
Release / release (push) Successful in 13m17s
2026-09-25 02:01:08 -04:00
dmg 1d0cdc8f0f feat(events): record Mending Light healing outcomes
Release / release (push) Successful in 8m27s
2026-09-25 01:51:17 -04:00
dmg 9674a8e5e6 feat(events): record Fireball and configuration outcomes
Release / release (push) Successful in 14m20s
2026-09-25 01:41:25 -04:00
dmg dd75e6a76f feat(events): add opt-in ability cast event hooks
Release / release (push) Successful in 14m37s
2026-09-25 01:16:43 -04:00
dmg 22d92b5bbf feat(events): record character session and message outcomes
Release / release (push) Successful in 14m5s
2026-09-25 00:18:41 -04:00
dmg 26ccc95cdd feat(events): record character coin outcomes
Release / release (push) Successful in 14m24s
2026-09-24 23:57:29 -04:00
dmg 8ae8cdb603 feat(events): retain shared combat XP evidence
Release / release (push) Successful in 13m53s
2026-09-24 23:40:28 -04:00
dmg d18a41af6c feat(events): record combat reward resolutions
Release / release (push) Successful in 13m58s
2026-09-24 23:31:16 -04:00
dmg 6428ab999b feat(events): record persistent party outcomes
Release / release (push) Successful in 14m5s
2026-09-24 23:17:39 -04:00
dmg 6bb58cd52d feat(events): record complete Revive outcomes
Release / release (push) Successful in 13m59s
2026-09-24 22:44:16 -04:00
dmg 25eb6e3a6b feat(events): record passive recovery outcomes
Release / release (push) Successful in 13m53s
2026-09-24 22:26:45 -04:00
dmg 51b055da12 feat(events): record exact ability refunds
Release / release (push) Successful in 13m52s
2026-09-24 22:11:35 -04:00
dmg 60ccb34ab6 feat(events): record permanent stat mutations
Release / release (push) Successful in 14m32s
2026-09-24 22:00:02 -04:00
dmg 0bdb43b2bb feat(events): record acknowledged character book views
Release / release (push) Failing after 13m19s
2026-09-24 21:47:58 -04:00
dmg ae741496b3 feat(events): record inventory safeguard outcomes
Release / release (push) Successful in 13m50s
2026-09-24 21:30:33 -04:00
dmg fa917e57ba feat(rare-items): add explicit Phoenix Down rescue
Release / release (push) Successful in 13m18s
2026-09-24 21:05:41 -04:00
112 changed files with 5010 additions and 427 deletions

No files matched your search

+2 -2
View File
@@ -22,7 +22,7 @@ Build output is `build/libs/purpur-fantasy-stick-<version>.jar`. Server APIs, te
## Local event files
Fantasy Stick independently writes compact CloudEvents 1.0 JSON objects as UTF-8 JSONL under `<plugin data directory>/events/YYYY-MM-DD/events-<boot UUID>-<segment>.jsonl`. The initial foundation records plugin lifecycle/recovery and correlated observed/resolved native interactions for active characters: block/entity/item/inventory/vehicle/control use, item pickup/drop, held-slot changes and discrete teleports. The downed feature additionally records durable downing/resolution facts, learned combat-Revive casts and results, accepted final-death/Tail transitions, prevented waiting/protection damage, protection expiry, and authorized downed-policy reads/changes. Stable episode/action identities and original deadlines, attribution, custody, locations, frozen rewards, caster cooldown/range/line-of-sight context survive recovery without inferring Phoenix Down use. It intentionally excludes movement/look ticks, rendering, particles and unrelated ordinary-player activity. It has no dependency on Event Producer, no network endpoint and no player-facing log reader.
Fantasy Stick independently writes compact CloudEvents 1.0 JSON objects as UTF-8 JSONL under `<plugin data directory>/events/YYYY-MM-DD/events-<boot UUID>-<segment>.jsonl`. The initial foundation records plugin lifecycle/recovery and correlated observed/resolved native interactions for active characters: block/entity/item/inventory/vehicle/control use, item pickup/drop, held-slot changes and discrete teleports. The downed feature additionally records durable downing/resolution facts, learned combat-Revive casts and results, accepted final-death/Tail transitions, prevented waiting/protection damage, protection expiry, and authorized downed-policy reads/changes. Stable episode/action identities and original deadlines, attribution, custody, locations, frozen rewards, caster cooldown/range/line-of-sight context survive recovery. Phoenix Down purchase, explicit downed-menu use, exact item/health proof, protection/prevention and expiry records are emitted only from their acknowledged feature boundaries. It intentionally excludes movement/look ticks, rendering, particles and unrelated ordinary-player activity. It has no dependency on Event Producer, no network endpoint and no player-facing log reader.
A stable plugin-owned server UUID and per-lifetime boot/sequence identity distinguish records. Events rotate at UTC midnight, on each writer lifetime and before 128 MiB; an oversized record stays whole in its own segment. Accepted work is first retained in internal pending files and exported at least once with unchanged IDs, so consumers must deduplicate by `(source, id)`. Startup recovers pending work, quarantines malformed/interrupted tails outside `*.jsonl`, and reports incomplete prior lifetimes without inventing a shutdown. Queue overload and pre-durable native-observation crashes remain truthful loss windows; logging never rolls back or replays gameplay.
@@ -183,7 +183,7 @@ Offhand entry applies actual native maximum health `min(10 + CON, native health
Ordinary health, food, saturation, exhaustion and health-display state belong to the holder/session, not the character sheet. The session receipt acknowledges this snapshot before native mutation; versioned player PDC records its application/restoration phase. Voluntary exit restores ordinary gear/vitals at the current location, with ordinary health capped by the current legitimate maximum. Character damage is saved on the worker, and the character claim/receipt cannot complete ahead of that acknowledgment. Quit and disable perform the same best-effort cleanup. Failed or uncertain saves retain the receipt and acknowledged character record instead of permitting a stale-health reentry. Missing/ambiguous native proof never authorizes a snapshot heal; process-owned maximum overrides are still retired independently of equipment/client-write failures.
An intercepted active-character lethal outcome now creates one durable downed episode rather than immediately recording death. The episode captures its original session, holder, lethal attribution, downed anchor, entry-return location and absolute deadline. While it remains pending, native damage, healing, movement, displacement, inventory/control changes and ordinary gameplay actions are contained; logout and shutdown restore only safe ordinary-holder state while retaining the original countdown. Reconnect rebinds the same proved session at its saved anchor without resetting time. The bounded death menu can accept death or wait for the learned, aimed, any-party Revive; Phoenix Down remains unavailable until its separate complete capability is delivered.
An intercepted active-character lethal outcome now creates one durable downed episode rather than immediately recording death. The episode captures its original session, holder, lethal attribution, downed anchor, entry-return location and absolute deadline. While it remains pending, native damage, healing, movement, displacement, inventory/control changes and ordinary gameplay actions are contained; logout and shutdown restore only safe ordinary-holder state while retaining the original countdown. Reconnect rebinds the same proved session at its saved anchor without resetting time. The bounded death menu can accept death, explicitly consume one character-owned Phoenix Down, or wait for the learned, aimed, any-party Revive. Phoenix Down is a persistent Rare Items product with global midnight-UTC stock 0–2, base price 1000 and the shared ±10% pricing policy; a newly delivered product appears only at the next scheduled or authorized restock. Purchase has no immediate effect and the character Inventory instructs use only while downed. An enabled exact-menu choice reserves one item against that episode, competes through the same single-outcome authority, restores full current fantasy health in place and applies the captured `rare-phoenix-down-invulnerability-seconds` deadline (default 5, zero allowed). Logout, restart, waiting, menu reopen, Accept Death, timeout and ordinary Inventory use never consume one automatically. A learned Revive or final death that wins leaves the item intact.
Accept Death and timeout compete with Revive for one persisted outcome. Successful Revive saves full current fantasy health, resumes the same active character in place and uses one absolute configurable protection deadline. Final death records the decision time—or the original expiry for delayed offline reconciliation—then applies frozen PvP reward/coin shares and party consequences exactly once before guarded Tail conversion. Ordinary health is restored before health-scale setters can send or queue client health packets, and return uses only the captured session-entry location. Refused or unavailable destinations retain explicit recovery evidence. New Tails retain the existing signed, authoritative death identity and readable static character history; stale or ambiguous conversion never overwrites another item or automatically reissues a book.
@@ -61,6 +61,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.shop.RarePotionService rarePotions;
private games.dmg.fantasystick.shop.RareReskillService rareReskills;
private games.dmg.fantasystick.shop.RareMagicCoreService rareMagicCores;
private games.dmg.fantasystick.shop.PhoenixDownService phoenixDowns;
private org.bukkit.scheduler.BukkitTask rareMarketPulse,rareMenuPulse;
private games.dmg.fantasystick.party.PartyCombat partyCombat;
private EquipmentAdminCommand admin;
@@ -162,8 +163,21 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.passive.PassiveRegistry passiveRegistry;
private games.dmg.fantasystick.passive.PassiveEngine passiveEngine;
private games.dmg.fantasystick.events.LocalEventLog eventLog;
private games.dmg.fantasystick.events.PreferenceEventBridge preferenceEvents;
private games.dmg.fantasystick.events.EventCaptureListener eventCapture;
private games.dmg.fantasystick.events.DownedEventBridge downedEvents;
private games.dmg.fantasystick.events.RareEventBridge rareEvents;
private games.dmg.fantasystick.events.BookEventBridge bookEvents;
private games.dmg.fantasystick.events.ProgressionEventBridge progressionEvents;
private games.dmg.fantasystick.events.AbilityEventBridge abilityEvents;
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.PartyEventBridge partyAuditEvents;
private games.dmg.fantasystick.events.CombatRewardEventBridge combatRewardEvents;
private games.dmg.fantasystick.events.CoinEventBridge coinEvents;
private games.dmg.fantasystick.events.SessionEventBridge sessionAuditEvents;
private games.dmg.fantasystick.events.MessageEventBridge messageAuditEvents;
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
@Override public void onEnable() {
@@ -367,6 +381,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
sessions.attachSwitchDelay(switchDelaySettings::delay);
sessions.attachRoleplayDisabled(roleplaySettings::disabled);
downedController=new games.dmg.fantasystick.session.DownedController(this,sessions,downed,Clock.systemUTC(),failure->getLogger().log(Level.WARNING,"Downed transition failed",failure));
phoenixDowns=new games.dmg.fantasystick.shop.PhoenixDownService(rareInventory,rareSettings,downed,sessions,Clock.systemUTC(),nextTick,downedController::revive,failure->getLogger().log(Level.WARNING,"Phoenix Down use failed",failure));
downedController.attachPhoenix(phoenixDowns);phoenixDowns.install();
sessions.attachDowned(downed,downedSettings::timeout,downedController::open);
combatFeedback = new games.dmg.fantasystick.presentation.CombatFeedback(feedbackPreferences, sessions);
trainingDummyController = new games.dmg.fantasystick.training.TrainingDummyController(this, trainingDummies);
@@ -380,7 +396,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
net.kyori.adventure.text.Component.text(message), net.kyori.adventure.text.Component.empty()));
}
}));
sessions.attachPartyDefeat(characterParties::defeat);
sessions.attachPartyDefeat(character->{var before=characterParties.partyOf(character).orElse(null);return characterParties.defeat(character).thenApply(outcome->{var bridge=partyAuditEvents;if(bridge!=null)bridge.finalDefeat(character,before,outcome);return outcome;});});
var reconcilingParties = characterParties;
var partyCharacters = reconcilingParties.all().stream().flatMap(party -> java.util.stream.Stream.concat(
party.members().stream(), party.invitations().stream().map(games.dmg.fantasystick.party.CharacterParty.Invitation::character))).distinct().toList();
@@ -590,9 +606,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
if(lifetime==generation)localArmorMenus.refreshOpen();});}
catch(RuntimeException failure){if(lifetime==generation)getLogger().log(Level.WARNING,"Fantasy Armorer menu refresh failed",failure);}});
armorerController=new games.dmg.fantasystick.shop.FantasyArmorerController(this,armorers,armorShopMenus::open);
rareShop=new games.dmg.fantasystick.shop.RareShop(rareMerchants,sessions,rareMarket,nextTick,failure->getLogger().log(Level.WARNING,"Rare Items purchase failed",failure));
rareShop=new games.dmg.fantasystick.shop.RareShop(rareMerchants,sessions,rareMarket,rareInventory,nextTick,failure->getLogger().log(Level.WARNING,"Rare Items purchase failed",failure));
rareShopMenus=new games.dmg.fantasystick.shop.RareShopMenus(this,rareShop);var localRareMenus=rareShopMenus;
rareMarket.attachListener(catalog->{if(lifetime!=generation)return;try{nextTick.accept(()->{if(lifetime==generation)localRareMenus.refreshOpen();});}catch(RuntimeException failure){if(lifetime==generation)getLogger().log(Level.WARNING,"Rare Items menu refresh failed",failure);}});
rareMarket.attachListener(catalog->{if(lifetime!=generation)return;if(rareEvents!=null)rareEvents.catalogRefreshed(catalog);try{nextTick.accept(()->{if(lifetime==generation)localRareMenus.refreshOpen();});}catch(RuntimeException failure){if(lifetime==generation)getLogger().log(Level.WARNING,"Rare Items menu refresh failed",failure);}});
rareMerchantController=new games.dmg.fantasystick.shop.RareMerchantController(this,rareMerchants,rareShopMenus::open);
rarePotions=new games.dmg.fantasystick.shop.RarePotionService(rareInventory,rareSettings,characters,sessions,event->getServer().getPluginManager().callEvent(event),nextTick,failure->getLogger().log(Level.WARNING,"Rare potion use failed",failure));
rareReskills=new games.dmg.fantasystick.shop.RareReskillService(rareInventory,characters,sessions,nextTick,record->{for(String ability:record.learned())abilityEngine.remove(record.id(),ability);speedI.sweep();lowLightVision.sweep();},failure->getLogger().log(Level.WARNING,"Rare Reskill use failed",failure));
@@ -697,14 +713,50 @@ public final class FantasyStickPlugin extends JavaPlugin {
getServer().getVersion(), getServer().getMinecraftVersion(), getDescription().getVersion()), 1024, 134_217_728L,
message -> getLogger().warning(message));
eventCapture = new games.dmg.fantasystick.events.EventCaptureListener(eventLog, sessions, Clock.systemUTC());
preferenceEvents = new games.dmg.fantasystick.events.PreferenceEventBridge(
eventLog::emit, Clock.systemUTC(), sessions, feedbackPreferences,
failure -> getLogger().log(Level.WARNING, "Preference event publication proof failed", failure));
particlesCommand.attachEvents(preferenceEvents);
feedbackCommand.attachEvents(preferenceEvents);
eventCapture.excludeOwnedDamage(fireball::ownsAcceptedDamage);
eventCapture.excludeOwnedDamage(guardianBond::ownsAcceptedRedirect);
eventCapture.excludeOwnedDamage(frostNova::ownsAcceptedDamage);
eventCapture.excludeOwnedDamage(chainLightning::ownsAcceptedDamage);
eventCapture.excludeOwnedDamage(meteor::ownsAcceptedDamage);
downedEvents = new games.dmg.fantasystick.events.DownedEventBridge(eventLog::emit, Clock.systemUTC());
rareEvents = new games.dmg.fantasystick.events.RareEventBridge(eventLog::emit, Clock.systemUTC());rareShop.attachEvents(rareEvents);
bookEvents = new games.dmg.fantasystick.events.BookEventBridge(eventLog::emit, Clock.systemUTC());books.attachEvents(bookEvents);
progressionEvents = new games.dmg.fantasystick.events.ProgressionEventBridge(eventLog::emit, Clock.systemUTC());books.attachProgressionEvents(progressionEvents);
abilityEvents = new games.dmg.fantasystick.events.AbilityEventBridge(eventLog::emit, Clock.systemUTC());books.attachAbilityEvents(abilityEvents);
abilityEngine.attachCastEvents(new games.dmg.fantasystick.events.AbilityCastEventBridge(eventLog::emit, Clock.systemUTC()));
abilityEngine.enableCastEventsFor("fireball");
abilityEngine.enableCastEventsFor("mending_light");
abilityEngine.enableCastEventsFor("purify");
abilityEngine.enableCastEventsFor("guardian_bond");
abilityEngine.enableCastEventsFor("rallying_cry");
abilityEngine.enableCastEventsFor("frost_nova");
abilityEngine.enableCastEventsFor("chain_lightning");
abilityEngine.enableCastEventsFor("sanctuary");
abilityEngine.enableCastEventsFor("war_banner");
abilityEngine.enableCastEventsFor("meteor");
admin.attachAbilityConfigEvents(new games.dmg.fantasystick.events.AbilityConfigEventBridge(eventLog::emit, Clock.systemUTC()));
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);
partyAuditEvents = new games.dmg.fantasystick.events.PartyEventBridge(eventLog::emit, Clock.systemUTC());partyManagement.attachEvents(partyAuditEvents);admin.attachPartyEvents(partyAuditEvents);
combatRewardEvents = new games.dmg.fantasystick.events.CombatRewardEventBridge(eventLog::emit,Clock.systemUTC());combatExperience.attachEvents(combatRewardEvents);
coinEvents = new games.dmg.fantasystick.events.CoinEventBridge(eventLog::emit,Clock.systemUTC());characters.attachCoinEvents(coinEvents);admin.attachCoinEvents(coinEvents);
sessionAuditEvents = new games.dmg.fantasystick.events.SessionEventBridge(eventLog::emit,Clock.systemUTC());sessions.attachSessionEvents(sessionAuditEvents);
sessions.attachDownedEvents(downedEvents);
downedController.attachEvents(downedEvents);
revive.attachDownedEvents(downedEvents);
admin.attachDownedEvents(downedEvents);
getServer().getPluginManager().registerEvents(eventCapture, this);
messageAuditEvents = new games.dmg.fantasystick.events.MessageEventBridge(
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;
eventLog = null; eventCapture = 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; messageAuditEvents = null;
getLogger().log(Level.WARNING,"Fantasy Stick local events are unavailable; gameplay remains enabled",failure);
}
getServer().getPluginManager().registerEvents(crafting, this);
@@ -784,7 +836,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
recipeDiscovery.discoverAll(onlinePlayers);
for (var player : onlinePlayers) { sessions.arrive(player); revive.reconcile(player); }
sheetResourcePack.deliverAll(onlinePlayers);
if(eventLog!=null)eventLog.ready();
if (eventLog != null) {
var preferenceReplay = preferenceEvents;
var savedPreferences = feedbackPreferences;
eventLog.ready().whenComplete((delivery, failure) -> {
if (failure == null && delivery != games.dmg.fantasystick.events.LocalEventLog.Delivery.REJECTED
&& preferenceReplay != null && savedPreferences != null)
for (var pendingPreference : savedPreferences.pendingEvents()) preferenceReplay.replay(pendingPreference);
});
}
} catch (RuntimeException | LinkageError failure) {
try { onDisable(); }
catch (RuntimeException | LinkageError cleanupFailure) { failure.addSuppressed(cleanupFailure); }
@@ -806,8 +866,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
particleDelivery = null;
combatFeedback = null;
var closingEvents = sessionEvents; sessionEvents = null;
var closingEventLog = eventLog; eventLog = null;
var closingEventCapture = eventCapture; eventCapture = null; downedEvents = null;
var closingEventLog = eventLog; eventLog = null; preferenceEvents = 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;
var closingBooks = books; books = null;
var closingPartyEvents = partyEvents; partyEvents = null;
var closingPartyCombat = partyCombat; partyCombat = null;
@@ -821,7 +881,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingAdventureQuestController = adventureQuestController; adventureQuestController = null;
adventureQuestCommands = null;
var closingFishing=fishing;fishing=null;var closingFishingController=fishingController;fishingController=null;
var closingDungeons=dungeons;dungeons=null;var closingDowned=downed;downed=null;var closingDownedController=downedController;downedController=null;var closingDungeonController=dungeonController;dungeonController=null;dungeonCommands=null;
var closingDungeons=dungeons;dungeons=null;var closingDowned=downed;downed=null;var closingDownedController=downedController;downedController=null;phoenixDowns=null;var closingDungeonController=dungeonController;dungeonController=null;dungeonCommands=null;
var closingPartyRegistrarMenus = partyRegistrarMenus; partyRegistrarMenus = null;
var closingPartyManagement = partyManagement; partyManagement = null;
var closingArmorers=armorers;armorers=null;var closingArmorerController=armorerController;armorerController=null;
@@ -1115,6 +1175,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
unregister(closingModes);
unregister(closingChat);
unregister(closingEventCapture);
unregister(closingMessageAuditEvents);
unregister(closingPartyEvents);
unregister(closingPartyCombat);
unregister(closingPartyRegistrarController);
@@ -43,11 +43,32 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
private final games.dmg.fantasystick.training.TrainingDamage trainingDamage;
private final Consumer<Failure> failures;
private final Map<Key, Object> pending = new HashMap<>();
private final Set<String> eventOwnedAbilities = new HashSet<>();
private final Map<Key, Long> generations = new HashMap<>();
private final Map<Key, Set<Runtime>> active = new HashMap<>();
private org.bukkit.scheduler.BukkitTask sweepTask;
private games.dmg.fantasystick.events.AbilityCastEventBridge castEvents;
private boolean closed;
public void attachCastEvents(games.dmg.fantasystick.events.AbilityCastEventBridge events) {
if (closed || castEvents != null) throw new IllegalStateException("Cast events already attached or engine closed");
castEvents = Objects.requireNonNull(events);
}
/** An ability owner opts in only after its actual effect-specific event path is delivered. */
public void enableCastEventsFor(String abilityId) {
if (closed || registry.find(Objects.requireNonNull(abilityId)).isEmpty())
throw new IllegalArgumentException("Unknown or closed ability event owner");
eventOwnedAbilities.add(abilityId);
}
public boolean eventsEnabledFor(String abilityId) { return eventOwnedAbilities.contains(abilityId); }
private void resolve(games.dmg.fantasystick.events.AbilityCastEventBridge.Cast audit,
String outcome, String reason) {
if (castEvents != null) castEvents.resolved(audit, outcome, reason);
}
public AbilityEngine(AbilityRegistry registry, CharacterStorage characters, EquipmentSessions sessions,
PartyRoster parties, Clock clock, Consumer<Runnable> nextTick, AbilityScheduler scheduler,
Consumer<AbilityReport> reports, Consumer<Failure> failures) {
@@ -176,22 +197,31 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
pending.remove(key, token); report(key.character(), key.ability(), invalid);
input.player().sendMessage("That ability's cooldown is invalid."); return;
}
characters.commitAbilityCooldown(key.character(), record.revision(), key.ability(), readyAt)
.whenComplete((mutation, failure) -> {
try {
nextTick.accept(() -> completeCast(input, definition, record.revision(), readyAt,
key, token, generation, mutation, failure));
} catch (RuntimeException rejected) {
pending.remove(key, token); report(key.character(), key.ability(), rejected);
}
});
var audit = castEvents == null || !eventOwnedAbilities.contains(definition.id())
? null : castEvents.begin(input, record, definition);
try {
characters.commitAbilityCooldown(key.character(), record.revision(), key.ability(), readyAt)
.whenComplete((mutation, failure) -> {
try {
nextTick.accept(() -> completeCast(input, definition, record.revision(), readyAt,
key, token, generation, audit, mutation, failure));
} catch (RuntimeException rejected) {
pending.remove(key, token); resolve(audit, "failed", "scheduler_rejected");
report(key.character(), key.ability(), rejected);
}
});
} catch (RuntimeException failure) {
pending.remove(key, token); resolve(audit, "failed", "save_rejected");
report(key.character(), key.ability(), failure);
}
}
private void completeCast(ControlInput input, AbilityDefinition definition, BigInteger expectedRevision, Instant readyAt,
Key key, Object token, long generation, CharacterStorage.AbilityMutation mutation, Throwable failure) {
if (!pending.remove(key, token)) return;
if (closed) return;
Key key, Object token, long generation, games.dmg.fantasystick.events.AbilityCastEventBridge.Cast audit,
CharacterStorage.AbilityMutation mutation, Throwable failure) {
if (!pending.remove(key, token) || closed) { resolve(audit, "interrupted", "session_or_engine_closed"); return; }
if (failure != null) {
resolve(audit, "failed", "save_failed");
report(key.character(), key.ability(), failure);
input.player().sendMessage("The cast could not be saved; no effect was applied."); return;
}
@@ -199,6 +229,7 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
&& mutation.record().map(record -> readyAt.equals(record.cooldowns().get(key.ability()))).orElse(false);
if ((mutation.status() != CharacterStorage.AbilityMutationStatus.APPLIED && !unchangedDeadline)
|| mutation.record().isEmpty()) {
resolve(audit, "refused", mutation.status().name().toLowerCase(java.util.Locale.ROOT));
input.player().sendMessage(switch (mutation.status()) {
case COOLDOWN -> "That ability is still on cooldown.";
case STALE -> "That character changed; retry the cast.";
@@ -208,23 +239,26 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
return;
}
CharacterRecord saved = mutation.record().orElseThrow();
if (castEvents != null) castEvents.cast(audit, saved, readyAt, definition);
boolean synchronizedProgress = unchangedDeadline
? sessions.currentRecord(input.session()).filter(saved::equals).isPresent()
: sessions.acknowledgeProgress(input.session(), expectedRevision, saved);
if (!synchronizedProgress) {
resolve(audit, "interrupted", "session_publication_changed");
input.player().sendMessage("The cooldown was saved, but the session changed; no effect was applied."); return;
}
if (!input.stillCurrent() || generations.getOrDefault(key, 0L) != generation
|| !eligible(definition.targetPolicy(), input, input.target())) {
resolve(audit, "interrupted", "eligibility_changed");
input.player().sendMessage("The cooldown was saved, but eligibility changed; no effect was applied."); return;
}
var runtime = new Runtime(key, input, definition, generation);
var runtime = new Runtime(key, input, definition, generation, audit);
active.computeIfAbsent(key, ignored -> new HashSet<>()).add(runtime);
try {
definition.effect().activate(runtime);
if (definition.activation() == AbilityDefinition.Activation.IMMEDIATE) runtime.finish();
} catch (RuntimeException effectFailure) {
runtime.close(); report(key.character(), key.ability(), effectFailure);
runtime.resolve("failed"); runtime.close(); report(key.character(), key.ability(), effectFailure);
input.player().sendMessage("The cooldown was saved, but the ability effect failed safely.");
}
}
@@ -293,12 +327,18 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
private final ControlInput input;
private final AbilityDefinition definition;
private final long generation;
private final games.dmg.fantasystick.events.AbilityCastEventBridge.Cast audit;
private final Map<UUID, String> observedEffects = new HashMap<>();
private final Set<AutoCloseable> owned = new HashSet<>();
private String outcome;
private Map<String, Object> resolutionEvidence = Map.of();
private boolean finished;
private int scheduled;
Runtime(Key key, ControlInput input, AbilityDefinition definition, long generation) {
Runtime(Key key, ControlInput input, AbilityDefinition definition, long generation,
games.dmg.fantasystick.events.AbilityCastEventBridge.Cast audit) {
this.key = key; this.input = input; this.definition = definition; this.generation = generation;
this.audit = audit;
}
@Override public AbilityDefinition definition() { return definition; }
@Override public ControlInput input() { return input; }
@@ -361,12 +401,75 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
try { feedback.report(definition, input, type, target, report.amount()); }
catch (RuntimeException failure) { AbilityEngine.this.report(key.character(), key.ability(), failure); }
}
@Override public void finish() { close(); }
@Override public UUID effectApplied(String effectId, Entity target, Map<String, Object> evidence) {
if (!stillCurrent()) throw new IllegalStateException("Ability execution ended");
Objects.requireNonNull(effectId); Objects.requireNonNull(evidence);
UUID application = castEvents == null ? UUID.randomUUID() : castEvents.effectApplied(audit, effectId, target, evidence);
observedEffects.put(application, effectId);
return application;
}
@Override public void acceptedDamage(LivingEntity target, double requested, double actual,
Entity direct, Map<String, Object> evidence) {
if (castEvents == null || audit == null) return;
try {
var details = new java.util.LinkedHashMap<String, Object>(evidence);
if (target instanceof Player player)
sessions.active(player.getUniqueId()).filter(sessions::current).ifPresent(active -> {
details.put("target_character_id", active.character().toString());
details.put("target_session_id", active.session().toString());
});
castEvents.acceptedDamage(audit, target, requested, actual, direct, details);
} catch (RuntimeException ignored) { /* Recording cannot change accepted native damage. */ }
}
@Override public void acceptedHeal(EquipmentSessions.Active aided, Player target,
CharacterRecord saved, double requested, double actual,
double before, double after, double maximum) {
if (castEvents != null && audit != null)
castEvents.healed(audit, target, aided.character(), aided.session(), saved,
requested, actual, before, after, maximum);
}
@Override public void acceptedCleanse(EquipmentSessions.Active aided, Player target, List<String> removed) {
if (castEvents != null && audit != null)
castEvents.cleansed(audit, target, aided.character(), aided.session(), removed);
}
@Override public void acceptedPrevention(EquipmentSessions.Active aided, Player ally, Entity attacker,
double incoming, double actual, double remaining,
UUID bondApplication) {
if (castEvents != null && audit != null)
castEvents.prevented(audit, ally, aided.character(), aided.session(), attacker,
incoming, actual, remaining, bondApplication);
}
@Override public void acceptedBoost(EquipmentSessions.Active attacking, Player attacker,
Entity target, double base, double bonus, double finalDamage,
java.math.BigDecimal percent) {
if (castEvents != null && audit != null)
castEvents.boosted(audit, attacking, attacker, target, base, bonus, finalDamage, percent);
}
@Override public void effectEnded(UUID application, String reason) {
Objects.requireNonNull(application); Objects.requireNonNull(reason);
var effectId = observedEffects.remove(application);
if (effectId != null && castEvents != null) castEvents.effectEnded(audit, application, effectId, reason);
}
@Override public void resolve(String result) { resolve(result, Map.of()); }
@Override public void resolve(String result, Map<String, Object> evidence) {
if (!Set.of("completed", "hit", "miss", "expired", "interrupted", "failed", "cancelled",
"healed", "cleansed", "protected", "boosted").contains(result))
throw new IllegalArgumentException("Unsupported ability resolution");
if (!finished) { outcome = result; resolutionEvidence = Map.copyOf(Objects.requireNonNull(evidence)); }
}
@Override public void finish() {
if (outcome == null) outcome = "completed";
close();
}
@Override public void close() {
if (finished) return;
finished = true;
if (outcome == null) outcome = "interrupted";
var resources = List.copyOf(owned); owned.clear();
for (var resource : resources) try { resource.close(); } catch (Exception failure) { AbilityEngine.this.report(key.character(), key.ability(), failure); }
for (var application : List.copyOf(observedEffects.keySet())) effectEnded(application, outcome);
if (castEvents != null) castEvents.resolved(audit, outcome,
outcome.equals("completed") ? "effect_finished_without_hit_claim" : "effect_ended", resolutionEvidence);
var runtimes = active.get(key);
if (runtimes != null) { runtimes.remove(this); if (runtimes.isEmpty()) active.remove(key); }
}
@@ -5,6 +5,8 @@ import java.math.BigDecimal;
import java.time.Duration;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.bukkit.damage.DamageSource;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
@@ -21,6 +23,32 @@ public interface AbilityExecution extends AutoCloseable {
boolean trainingDummy(Entity target);
void damage(LivingEntity target, double amount, DamageSource source);
void report(AbilityReport.Type type, Entity target, BigDecimal amount);
/** An owner reports an effect only after its actual native application. */
UUID effectApplied(String effectId, Entity target, Map<String, Object> evidence);
/** Only the effect owner calls this after positive, uncancelled native damage is accepted. */
void acceptedDamage(LivingEntity target, double requested, double actual, Entity direct,
Map<String, Object> evidence);
/** Only after the aided record and native health have both been acknowledged. */
void acceptedHeal(games.dmg.fantasystick.session.EquipmentSessions.Active aided,
org.bukkit.entity.Player target,
games.dmg.fantasystick.persistence.CharacterRecord saved,
double requested, double actual, double before, double after, double maximum);
/** Record only harmful native effects actually removed from this target. */
void acceptedCleanse(games.dmg.fantasystick.session.EquipmentSessions.Active aided,
org.bukkit.entity.Player target, List<String> removedEffects);
/** Report only the final uncancelled native ally reduction, not a proposed redirect. */
void acceptedPrevention(games.dmg.fantasystick.session.EquipmentSessions.Active aided,
org.bukkit.entity.Player ally, Entity attacker, double incoming,
double actual, double remaining, UUID bondApplication);
/** Record a winning outgoing bonus only after a positive uncancelled native hit. */
void acceptedBoost(games.dmg.fantasystick.session.EquipmentSessions.Active attacking,
org.bukkit.entity.Player attacker, Entity target,
double base, double bonus, double finalDamage, java.math.BigDecimal percent);
/** End an owned observed effect once; ordinary close ends any remaining observed effects. */
void effectEnded(UUID applicationId, String reason);
/** An owner may specify a truthful terminal outcome before finishing. */
void resolve(String outcome);
void resolve(String outcome, Map<String, Object> evidence);
void finish();
@Override void close();
}
@@ -10,6 +10,7 @@ import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -48,8 +49,17 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
}
}
private static final class Attempt {
final AbilityExecution execution; final LivingEntity target; boolean accepted;
Attempt(AbilityExecution execution, LivingEntity target) { this.execution = execution; this.target = target; }
final AbilityExecution execution; final LivingEntity target; final double requested;
final BigDecimal defined; final UUID chainId; final int ordinal;
final UUID previousId; final Map<String, Object> previousLocation;
boolean accepted;
Attempt(AbilityExecution execution, LivingEntity target, double requested, BigDecimal defined,
UUID chainId, int ordinal, LivingEntity previous) {
this.execution = execution; this.target = target; this.requested = requested;
this.defined = defined; this.chainId = chainId; this.ordinal = ordinal;
previousId = previous == null ? null : previous.getUniqueId();
previousLocation = previous == null ? Map.of() : locationData(previous.getLocation());
}
}
private volatile Settings settings;
@@ -103,27 +113,41 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
if (closed || !execution.stillCurrent()) return;
var captured = from(execution.definition()); var caster = execution.input().player();
var initial = selectInitial(execution.input(), captured.initialRange());
if (initial == null) return;
if (initial == null) { execution.resolve("miss", Map.of("reason", "initial_target_unavailable")); return; }
BigDecimal defined = new BigDecimal(initialDamage(captured.baseDamage(), execution.character().stats().get(Stat.INT)));
var visited = new HashSet<UUID>(); LivingEntity current = initial;
var visited = new HashSet<UUID>(); LivingEntity current = initial, previous = null;
var chainId = UUID.randomUUID(); int attempted = 0, accepted = 0;
int maximum = Math.min(64, 1 + captured.additionalTargets());
for (int strike = 0; strike < maximum && current != null && execution.stillCurrent(); strike++) {
for (int hop = 0; hop < maximum && current != null && execution.stillCurrent(); hop++) {
if (!visited.add(current.getUniqueId()) || !eligible(caster, current)) break;
strike(execution, caster, current, defined); visual(caster, current);
if (strike + 1 == maximum) break;
var attempt = strike(execution, caster, current, defined, chainId, hop + 1, previous);
attempted++; if (attempt.accepted) accepted++;
visual(caster, current);
if (hop + 1 == maximum) break;
defined = defined.multiply(captured.jumpMultiplier());
previous = current;
current = next(caster, current, visited, captured.jumpRange());
}
var outcome = new LinkedHashMap<String, Object>();
outcome.put("chain_id", chainId.toString());
outcome.put("initial_target_id", initial.getUniqueId().toString());
outcome.put("initial_target_location", locationData(initial.getLocation()));
outcome.put("attempted_hops", attempted); outcome.put("accepted_hops", accepted);
outcome.put("terminal_reason", !execution.stillCurrent() ? "source_interrupted"
: attempted == maximum ? "target_limit" : "no_eligible_next_target");
execution.resolve(!execution.stillCurrent() ? "interrupted" : accepted > 0 ? "hit" : "miss", outcome);
}
private void strike(AbilityExecution execution, Player caster, LivingEntity target, BigDecimal defined) {
private Attempt strike(AbilityExecution execution, Player caster, LivingEntity target,
BigDecimal defined, UUID chainId, int ordinal, LivingEntity previous) {
double amount = finite(definiteNonnegative(defined));
var origin = caster.getEyeLocation().clone();
var source = DamageSource.builder(DamageType.LIGHTNING_BOLT).withCausingEntity(caster)
.withDirectEntity(caster).withDamageLocation(origin).build();
var attempt = new Attempt(execution, target); damaging = attempt;
var attempt = new Attempt(execution, target, amount, defined, chainId, ordinal, previous); damaging = attempt;
try { execution.damage(target, amount, source); }
finally { if (damaging == attempt) damaging = null; }
return attempt;
}
private LivingEntity selectInitial(ControlInput input, double range) {
@@ -179,13 +203,42 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageEvent event) {
var attempt = damaging;
if (attempt == null || attempt.accepted || event.getEntity() != attempt.target
|| event.getCause() != EntityDamageEvent.DamageCause.LIGHTNING
if (!ownsAcceptedDamage(event) || attempt.accepted
|| event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
attempt.accepted = true;
var evidence = new LinkedHashMap<String, Object>();
evidence.put("damage_kind", "owned_chain_strike");
evidence.put("chain_id", attempt.chainId.toString());
evidence.put("hop_ordinal", attempt.ordinal);
evidence.put("defined_amount", attempt.defined.stripTrailingZeros().toPlainString());
if (attempt.previousId != null) {
evidence.put("previous_target_id", attempt.previousId.toString());
evidence.put("previous_target_location", attempt.previousLocation);
}
attempt.execution.acceptedDamage(attempt.target, attempt.requested, event.getFinalDamage(),
attempt.execution.input().player(), evidence);
attempt.execution.report(AbilityReport.Type.DAMAGE, attempt.target, BigDecimal.valueOf(event.getFinalDamage()));
}
/** Exclude only this in-flight native hop from the generic damage observer. */
public boolean ownsAcceptedDamage(EntityDamageEvent event) {
var attempt = damaging;
if (attempt == null || event.getEntity() != attempt.target
|| event.getCause() != EntityDamageEvent.DamageCause.LIGHTNING) return false;
var source = attempt.execution.input().player();
var causing = event.getDamageSource().getCausingEntity();
var direct = event.getDamageSource().getDirectEntity();
return (causing == null || causing == source) && (direct == null || direct == source);
}
private static Map<String, Object> locationData(Location location) {
var world = location == null ? null : location.getWorld();
if (world == null || world.getUID() == null || world.getName() == null)
return Map.of("location_status", "unavailable");
return Map.of("world_id", world.getUID().toString(), "world_name", world.getName(),
"x", location.getX(), "y", location.getY(), "z", location.getZ());
}
private void visual(Player caster, LivingEntity target) {
try {
var location = target.getLocation().clone().add(0, target.getHeight() / 2, 0);
@@ -18,9 +18,12 @@ import org.bukkit.event.entity.EntityDamageEvent;
/** Internal strongest-reduction stage shared by Fantasy Stick protection effects. */
public final class CombatMitigation implements Listener, AutoCloseable {
private final class Grant implements AutoCloseable {
final AbilityExecution execution; final Player player; final BigDecimal percent; final BooleanSupplier current; boolean closed;
Grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current) {
final AbilityExecution execution; final Player player; final BigDecimal percent; final BooleanSupplier current;
final java.util.function.BiConsumer<EntityDamageEvent, Double> acceptedObservation; boolean closed;
Grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current,
java.util.function.BiConsumer<EntityDamageEvent, Double> acceptedObservation) {
this.execution = execution; this.player = player; this.percent = percent; this.current = current;
this.acceptedObservation = acceptedObservation;
}
boolean valid() {
try { return !closed && execution.stillCurrent() && current.getAsBoolean(); }
@@ -42,10 +45,16 @@ public final class CombatMitigation implements Listener, AutoCloseable {
}
public AutoCloseable grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current) {
return grant(execution, player, percent, current, null);
}
/** An owner may observe only the selected, positive native prevention after final cancellation. */
public AutoCloseable grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current,
java.util.function.BiConsumer<EntityDamageEvent, Double> acceptedObservation) {
Objects.requireNonNull(execution); Objects.requireNonNull(player); Objects.requireNonNull(percent); Objects.requireNonNull(current);
if (closed || percent.signum() < 0 || percent.compareTo(BigDecimal.valueOf(100)) > 0)
throw new IllegalArgumentException("Invalid combat mitigation grant");
var grant = new Grant(execution, player, percent, current);
var grant = new Grant(execution, player, percent, current, acceptedObservation);
grants.computeIfAbsent(player.getUniqueId(), ignored -> new ArrayList<>()).add(grant);
return grant;
}
@@ -76,6 +85,9 @@ public final class CombatMitigation implements Listener, AutoCloseable {
if (prevention == null || event.isCancelled() || event.getFinalDamage() <= 0
|| !prevention.grant.valid()) return;
prevention.grant.execution.report(AbilityReport.Type.PREVENTION, event.getEntity(), prevention.amount);
if (prevention.grant.acceptedObservation != null) try {
prevention.grant.acceptedObservation.accept(event, prevention.amount.doubleValue());
} catch (RuntimeException ignored) { /* Audit cannot alter the accepted native hit. */ }
}
@Override public void close() {
@@ -56,6 +56,8 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
final BigInteger intelligence;
final Settings settings;
LivingEntity target;
double requestedDamage;
boolean accepted;
boolean finished;
State(Arrow projectile, AbilityExecution execution, Location origin, BigInteger intelligence, Settings settings) {
this.projectile = projectile; this.execution = execution; this.origin = origin;
@@ -123,8 +125,14 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
}
execution.own(state);
long rangeTicks = Math.max(1, (long) Math.ceil(current.range() / current.speedPerTick()));
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), execution::finish);
execution.later(current.lifetime(), execution::finish);
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), () -> {
execution.resolve("expired", Map.of("projectile_id", projectile.getUniqueId().toString(),
"limit", "range", "effective_settings", settingsData(current))); execution.finish();
});
execution.later(current.lifetime(), () -> {
execution.resolve("expired", Map.of("projectile_id", projectile.getUniqueId().toString(),
"limit", "lifetime", "effective_settings", settingsData(current))); execution.finish();
});
particles.world(Particle.FLAME, origin, 8, 0.15, 0.15, 0.15, 0.01);
}
@@ -141,14 +149,44 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
var source = DamageSource.builder(DamageType.FIREBALL).withCausingEntity(state.execution.input().player())
.withDirectEntity(state.projectile).withDamageLocation(state.projectile.getLocation()).build();
state.target = target;
state.requestedDamage = nativeDamage;
damaging = state;
try { state.execution.damage(target, nativeDamage, source); }
finally { if (damaging == state) damaging = null; }
} finally {
state.execution.finish();
String outcome = state.accepted ? "hit" : event.isCancelled() ? "cancelled" : "miss";
try {
if (outcome.equals("miss") && (closed || !state.execution.stillCurrent())) outcome = "interrupted";
var evidence = new java.util.LinkedHashMap<String, Object>();
evidence.put("projectile_id", state.projectile.getUniqueId().toString());
evidence.put("direct_entity_id", state.projectile.getUniqueId().toString());
evidence.put("effective_settings", settingsData(state.settings));
var impact = event.getEntity().getLocation();
if (impact != null && impact.getWorld() != null) evidence.put("target_location", locationData(impact));
if (event.getHitEntity() != null) evidence.put("target_entity_id", event.getHitEntity().getUniqueId().toString());
state.execution.resolve(outcome, evidence);
} catch (RuntimeException auditFailure) {
state.execution.resolve(outcome);
} finally {
state.execution.finish();
}
}
}
private static Map<String, Object> settingsData(Settings settings) {
return Map.of("base-damage", settings.baseDamage().toString(),
"cooldown-seconds", seconds(settings.cooldown()).toPlainString(),
"range", Double.toString(settings.range()),
"lifetime", seconds(settings.lifetime()).toPlainString(),
"projectile-speed", Double.toString(settings.speedPerTick()));
}
private static Map<String, Object> locationData(Location location) {
return Map.of("world_id", location.getWorld().getUID().toString(),
"world_name", location.getWorld().getName(), "x", location.getX(),
"y", location.getY(), "z", location.getZ());
}
private boolean inRange(State state, Location current) {
return current.getWorld() != null && state.origin.getWorld() != null
&& current.getWorld().equals(state.origin.getWorld())
@@ -162,16 +200,25 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
if (state != null && damaging != state) event.setCancelled(true);
}
/** One native application belongs to this projectile, including a fantasy-player target. */
public boolean ownsAcceptedDamage(EntityDamageEvent event) {
var state = damaging;
if (state == null || event.getEntity() != state.target) return false;
var direct = event.getDamageSource().getDirectEntity();
if (direct != state.projectile && (!(event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity)
|| byEntity.getDamager() != state.projectile)) return false;
var causing = event.getDamageSource().getCausingEntity();
return causing == null || causing == state.execution.input().player();
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageEvent event) {
var state = damaging;
if (state == null || event.getEntity() != state.target || event.getFinalDamage() <= 0
|| !Double.isFinite(event.getFinalDamage())) return;
var direct = event.getDamageSource().getDirectEntity();
if (direct != state.projectile && (!(event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity)
|| byEntity.getDamager() != state.projectile)) return;
var causing = event.getDamageSource().getCausingEntity();
if (causing != null && causing != state.execution.input().player()) return;
if (state == null || state.accepted || event.getFinalDamage() <= 0
|| !Double.isFinite(event.getFinalDamage()) || !ownsAcceptedDamage(event)) return;
state.accepted = true;
state.execution.acceptedDamage(state.target, state.requestedDamage, event.getFinalDamage(),
state.projectile, Map.of("damage_kind", "owned_direct_projectile"));
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
}
@@ -182,7 +229,9 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
@Override public void close() {
if (closed) return;
closed = true;
for (var state : List.copyOf(projectiles.values())) state.execution.finish();
for (var state : List.copyOf(projectiles.values())) {
state.execution.resolve("interrupted"); state.execution.finish();
}
projectiles.clear(); damaging = null;
}
}
@@ -50,36 +50,61 @@ public final class FrostNova implements AbilityEffect, Listener, AutoCloseable {
private static final class SlowLease implements AutoCloseable {
final LivingEntity target; final PotionEffect previous; final PotionEffect applied;
final AbilityExecution execution; UUID application;
final long started = System.nanoTime(); boolean owned; boolean closed;
SlowLease(LivingEntity target, PotionEffect applied) {
SlowLease(LivingEntity target, PotionEffect applied, AbilityExecution execution) {
this.target = target; previous = target.getPotionEffect(PotionEffectType.SLOWNESS); this.applied = applied;
this.execution = execution;
}
boolean apply() { owned = target.addPotionEffect(applied); return owned; }
@Override public void close() {
boolean apply() {
owned = target.addPotionEffect(applied);
if (owned) try {
var evidence = new LinkedHashMap<String, Object>();
evidence.put("amplifier", applied.getAmplifier());
evidence.put("duration_ticks", applied.getDuration());
evidence.put("deadline", java.time.Instant.now().plusMillis(applied.getDuration() * 50L).toString());
if (previous != null) {
evidence.put("replaced_amplifier", previous.getAmplifier());
evidence.put("replaced_duration_ticks", previous.getDuration());
}
application = execution.effectApplied("frost_nova_slowness", target, evidence);
} catch (RuntimeException ignored) { /* Auditing cannot refuse a native effect. */ }
return owned;
}
@Override public void close() { close("interrupted"); }
void close(String reason) {
if (closed) return; closed = true; if (!owned) return;
var current = target.getPotionEffect(PotionEffectType.SLOWNESS);
long elapsed = Math.max(0, (System.nanoTime() - started) / 50_000_000L);
int expected = (int) Math.max(0, (long) applied.getDuration() - elapsed);
if (current == null || current.getAmplifier() != applied.getAmplifier()
|| current.getDuration() > expected + 2) return;
|| current.getDuration() > expected + 2) {
if (application != null) execution.effectEnded(application, "superseded");
return;
}
target.removePotionEffect(PotionEffectType.SLOWNESS);
if (previous != null) {
int remaining = (int) Math.max(0, (long) previous.getDuration() - elapsed);
if (remaining > 0) target.addPotionEffect(previous.withDuration(remaining));
}
if (application != null) execution.effectEnded(application, reason);
}
}
private final class LeaseGroup implements AutoCloseable {
final AbilityExecution execution; final List<SlowLease> leases = new ArrayList<>(); boolean closed;
final AbilityExecution execution; final List<SlowLease> leases = new ArrayList<>();
String endReason = "interrupted"; boolean closed;
LeaseGroup(AbilityExecution execution) { this.execution = execution; }
@Override public void close() {
if (closed) return; closed = true; active.remove(execution, this);
for (var lease : List.copyOf(leases)) lease.close(); leases.clear();
for (var lease : List.copyOf(leases)) lease.close(endReason); leases.clear();
}
}
private static final class Attempt {
final AbilityExecution execution; final LivingEntity target; boolean accepted;
Attempt(AbilityExecution execution, LivingEntity target) { this.execution = execution; this.target = target; }
final AbilityExecution execution; final LivingEntity target; final double requested; final int ordinal;
boolean accepted;
Attempt(AbilityExecution execution, LivingEntity target, double requested, int ordinal) {
this.execution = execution; this.target = target; this.requested = requested; this.ordinal = ordinal;
}
}
private volatile Settings settings;
@@ -133,28 +158,39 @@ public final class FrostNova implements AbilityEffect, Listener, AutoCloseable {
var group = new LeaseGroup(execution); active.put(execution, group); execution.own(group);
BigInteger exact = damage(captured.baseDamage(), execution.character().stats().get(Stat.INT));
double amount = exact.bitLength() > 1023 ? Double.MAX_VALUE : exact.doubleValue();
int affected = 0;
for (var target : candidates(source, origin, captured.radius())) {
int affected = 0, accepted = 0;
var candidates = candidates(source, origin, captured.radius());
for (var target : candidates) {
if (affected == 64 || !execution.stillCurrent()) break;
if (!eligible(execution, target, origin, captured.radius())) continue;
var sourceDamage = DamageSource.builder(DamageType.FREEZE).withCausingEntity(source)
.withDirectEntity(source).withDamageLocation(origin).build();
var attempt = new Attempt(execution, target); damaging = attempt;
var attempt = new Attempt(execution, target, amount, affected + 1); damaging = attempt;
try { execution.damage(target, amount, sourceDamage); }
finally { if (damaging == attempt) damaging = null; }
affected++;
if (!attempt.accepted || !execution.stillCurrent() || !eligible(execution, target, origin, captured.radius())) continue;
if (!attempt.accepted) continue;
accepted++;
if (!execution.stillCurrent() || !eligible(execution, target, origin, captured.radius())) continue;
int ticks = Math.toIntExact(Math.max(1,
(captured.slownessDuration().toNanos() + 49_999_999L) / 50_000_000L));
var lease = new SlowLease(target, new PotionEffect(PotionEffectType.SLOWNESS, ticks,
captured.slownessAmplifier(), false, true, true));
captured.slownessAmplifier(), false, true, true), execution);
if (lease.apply()) group.leases.add(lease);
}
try { particles.world(Particle.SNOWFLAKE, origin, 32,
captured.radius() / 2, captured.radius() / 2, captured.radius() / 2, 0.01); }
catch (RuntimeException ignored) { }
var result = new LinkedHashMap<String, Object>();
result.put("candidate_count", candidates.size()); result.put("attempt_count", affected);
result.put("accepted_count", accepted); result.put("slowness_count", group.leases.size());
result.put("radius", captured.radius()); result.put("requested_amount", amount);
result.put("burst_location", locationData(origin));
execution.resolve(accepted > 0 ? "hit" : "miss", result);
if (group.leases.isEmpty()) execution.finish();
else execution.later(captured.slownessDuration(), execution::finish);
else execution.later(captured.slownessDuration(), () -> {
group.endReason = "expired"; execution.finish();
});
}
private List<LivingEntity> candidates(Player source, Location origin, double radius) {
@@ -188,13 +224,33 @@ public final class FrostNova implements AbilityEffect, Listener, AutoCloseable {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageEvent event) {
var attempt = damaging;
if (attempt == null || attempt.accepted || event.getEntity() != attempt.target
|| event.getCause() != EntityDamageEvent.DamageCause.FREEZE
if (!ownsAcceptedDamage(event) || attempt.accepted
|| event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
attempt.accepted = true;
attempt.execution.acceptedDamage(attempt.target, attempt.requested, event.getFinalDamage(),
attempt.execution.input().player(), Map.of("damage_kind", "owned_area_burst", "ordinal", attempt.ordinal));
attempt.execution.report(AbilityReport.Type.DAMAGE, attempt.target, BigDecimal.valueOf(event.getFinalDamage()));
}
/** The per-target native hit belongs to this cast, not the generic damage observer. */
public boolean ownsAcceptedDamage(EntityDamageEvent event) {
var attempt = damaging;
if (attempt == null || event.getEntity() != attempt.target
|| event.getCause() != EntityDamageEvent.DamageCause.FREEZE) return false;
var source = attempt.execution.input().player();
var causing = event.getDamageSource().getCausingEntity();
var direct = event.getDamageSource().getDirectEntity();
return (causing == null || causing == source) && (direct == null || direct == source);
}
private static Map<String, Object> locationData(Location origin) {
var world = origin.getWorld();
if (world == null || world.getUID() == null || world.getName() == null)
return Map.of("location_status", "unavailable");
return Map.of("world_id", world.getUID().toString(), "world_name", world.getName(),
"x", origin.getX(), "y", origin.getY(), "z", origin.getZ());
}
private static boolean unobstructed(Location origin, LivingEntity target) {
var world = origin.getWorld(); if (world == null) return false;
var location = target.getLocation(); if (location.getWorld() != world) return false;
@@ -56,6 +56,8 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
final Player ally;
final EquipmentSessions.Active aided;
final Settings settings;
UUID application;
String endReason = "interrupted";
boolean closed;
State(AbilityExecution execution, Player guardian, Player ally,
EquipmentSessions.Active aided, Settings settings) {
@@ -67,14 +69,16 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
closed = true;
byGuardian.remove(guardian.getUniqueId(), this);
byAlly.remove(ally.getUniqueId(), this);
if (application != null) execution.effectEnded(application, endReason);
}
}
private static final class NativeTransfer {
final Player guardian; final Entity attacker; final BigDecimal requested; final BigDecimal available;
final State owner; final Player guardian; final Entity attacker; final BigDecimal requested; final BigDecimal available;
BigDecimal accepted = BigDecimal.ZERO;
NativeTransfer(Player guardian, Entity attacker, BigDecimal requested, BigDecimal available) {
this.guardian = guardian; this.attacker = attacker; this.requested = requested; this.available = available;
NativeTransfer(State owner, Player guardian, Entity attacker, BigDecimal requested, BigDecimal available) {
this.owner = owner; this.guardian = guardian; this.attacker = attacker;
this.requested = requested; this.available = available;
}
}
@@ -84,11 +88,14 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
private final EquipmentSessions sessions;
private final PartyRoster parties;
private final Damage damage;
private final boolean nativeDamageMode;
private final Consumer<Support> supports;
private final Map<UUID, State> byGuardian = new java.util.HashMap<>();
private final Map<UUID, State> byAlly = new java.util.HashMap<>();
private int transferring;
private NativeTransfer nativeTransfer;
private record Reduction(State state, Entity attacker, double incoming, double accepted) { }
private final Map<EntityDamageByEntityEvent, Reduction> pendingReductions = new java.util.IdentityHashMap<>();
private org.bukkit.scheduler.BukkitTask sweepTask;
private boolean closed;
@@ -102,7 +109,8 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
this.settings = Objects.requireNonNull(settings);
this.sessions = Objects.requireNonNull(sessions);
this.parties = Objects.requireNonNull(parties);
this.damage = damage == null ? this::nativeDamage : damage;
this.nativeDamageMode = damage == null;
this.damage = nativeDamageMode ? this::nativeDamage : damage;
this.supports = Objects.requireNonNull(supports);
}
@@ -153,7 +161,14 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
}
var state = new State(execution, guardian, ally, aided, captured);
byGuardian.put(guardian.getUniqueId(), state); byAlly.put(ally.getUniqueId(), state);
execution.own(state); execution.later(captured.duration(), execution::finish);
execution.own(state);
state.application = execution.effectApplied("guardian_bond", ally,
Map.of("redirect_percent", captured.redirectPercent().toPlainString(),
"range", Double.toString(captured.range()),
"duration_seconds", seconds(captured.duration())));
execution.later(captured.duration(), () -> {
state.endReason = "expired"; execution.resolve("expired"); execution.finish();
});
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
@@ -184,8 +199,30 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
double remaining = BigDecimal.valueOf(incoming).subtract(accepted).max(BigDecimal.ZERO).doubleValue();
if (!Double.isFinite(remaining)) return;
event.setDamage(remaining);
state.execution.report(AbilityReport.Type.PREVENTION, ally, accepted);
supports.accept(new Support(attacker.getUniqueId(), state.execution.input().session().character(), state.aided, accepted));
pendingReductions.put(event, new Reduction(state, attacker, incoming, accepted.doubleValue()));
if (!nativeDamageMode) {
// Injected test adapters return their final accepted result directly.
state.execution.report(AbilityReport.Type.PREVENTION, ally, accepted);
supports.accept(new Support(attacker.getUniqueId(), state.execution.input().session().character(), state.aided, accepted));
}
}
/** Only an uncancelled original hit can claim actual prevention. */
@EventHandler(priority = EventPriority.MONITOR)
public void acceptedPrevention(EntityDamageByEntityEvent event) {
var reduction = pendingReductions.remove(event);
if (reduction == null || event.isCancelled() || !Double.isFinite(event.getFinalDamage())) return;
double actual = Math.min(reduction.accepted(), Math.max(0, reduction.incoming() - event.getFinalDamage()));
if (actual <= 0) return;
var state = reduction.state();
state.execution.acceptedPrevention(state.aided, state.ally, reduction.attacker(),
reduction.incoming(), actual, event.getFinalDamage(), state.application);
if (nativeDamageMode) {
var amount = BigDecimal.valueOf(actual);
state.execution.report(AbilityReport.Type.PREVENTION, state.ally, amount);
supports.accept(new Support(reduction.attacker().getUniqueId(),
state.execution.input().session().character(), state.aided, amount));
}
}
/** Capture accepted marked damage before synchronous fantasy-defeat restoration can replace native health. */
@@ -193,7 +230,8 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
Objects.requireNonNull(guardian); Objects.requireNonNull(attacker); Objects.requireNonNull(requested);
double amount = requested.doubleValue(); double health = effectiveHealth(guardian);
if (!Double.isFinite(amount) || amount <= 0 || !Double.isFinite(health) || health <= 0) return BigDecimal.ZERO;
var transfer = new NativeTransfer(guardian, attacker, requested, BigDecimal.valueOf(health));
var transfer = new NativeTransfer(byGuardian.get(guardian.getUniqueId()), guardian, attacker,
requested, BigDecimal.valueOf(health));
nativeTransfer = transfer;
try { guardian.damage(amount, attacker); }
finally { nativeTransfer = null; }
@@ -206,6 +244,17 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
if (transfer == null || event.getEntity() != transfer.guardian || attacker(event) != transfer.attacker
|| event.isCancelled() || !Double.isFinite(event.getFinalDamage()) || event.getFinalDamage() <= 0) return;
transfer.accepted = BigDecimal.valueOf(event.getFinalDamage()).min(transfer.requested).min(transfer.available);
if (transfer.owner != null && transfer.accepted.signum() > 0)
transfer.owner.execution.acceptedDamage(transfer.guardian, transfer.requested.doubleValue(),
transfer.accepted.doubleValue(), transfer.attacker,
Map.of("damage_kind", "guardian_bond_redirection"));
}
/** The guardian's accepted native hit has one richer bond-owned damage record. */
public boolean ownsAcceptedRedirect(org.bukkit.event.entity.EntityDamageEvent event) {
var transfer = nativeTransfer;
return transfer != null && event instanceof EntityDamageByEntityEvent hit
&& hit.getEntity() == transfer.guardian && attacker(hit) == transfer.attacker;
}
/** True only during synchronous dispatch of Guardian Bond's marked redirected damage. */
@@ -230,7 +279,8 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
}
private void breakBond(State state) {
state.close(); state.execution.finish();
state.endReason = "eligibility_changed";
state.close(); state.execution.resolve("interrupted"); state.execution.finish();
}
private static boolean eligible(Player guardian, Player ally, double range) {
@@ -268,7 +318,9 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
@Override public void close() {
if (closed) return; closed = true;
if (sweepTask != null) sweepTask.cancel();
for (var state : List.copyOf(byGuardian.values())) state.execution.finish();
byGuardian.clear(); byAlly.clear();
for (var state : List.copyOf(byGuardian.values())) {
state.endReason = "shutdown"; state.execution.resolve("interrupted"); state.execution.finish();
}
byGuardian.clear(); byAlly.clear(); pendingReductions.clear();
}
}
@@ -101,25 +101,31 @@ public final class MendingLight implements AbilityEffect {
if (!execution.stillCurrent()) return;
var configured = from(execution.definition());
var target = target(execution.input(), configured.range());
if (target == null) { execution.finish(); return; }
if (target == null) { execution.resolve("miss"); execution.finish(); return; }
var attribute = target.player().getAttribute(Attribute.MAX_HEALTH);
if (attribute == null) { execution.finish(); return; }
if (attribute == null) { execution.resolve("miss"); execution.finish(); return; }
double current = target.player().getHealth(); double maximum = attribute.getValue(); double missing = maximum - current;
if (!Double.isFinite(current) || !Double.isFinite(maximum) || missing <= 0) { execution.finish(); return; }
if (!Double.isFinite(current) || !Double.isFinite(maximum) || missing <= 0) {
execution.resolve("miss"); execution.finish(); return;
}
BigInteger exact = healing(configured.baseHealing(), execution.character().stats().get(Stat.WIS));
double proposed = exact.bitLength() > 1023 ? missing : Math.min(missing, exact.doubleValue());
if (!Double.isFinite(proposed) || proposed <= 0) { execution.finish(); return; }
if (!Double.isFinite(proposed) || proposed <= 0) {
execution.resolve("miss"); execution.finish(); return;
}
var event = new EntityRegainHealthEvent(target.player(), proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
healingEvents.accept(event);
double accepted = Math.min(proposed, Math.min(missing, event.getAmount()));
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) { execution.finish(); return; }
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) {
execution.resolve(event.isCancelled() ? "cancelled" : "miss"); execution.finish(); return;
}
var expected = target.record().revision();
BigDecimal persistedHealth = BigDecimal.valueOf(current + accepted);
BigDecimal limit = nativeLimit.get();
characters.saveHealth(target.record().id(), expected, persistedHealth, limit).whenComplete((saved, failure) -> {
try {
nextTick.accept(() -> complete(execution, target, expected, accepted, saved, failure));
} catch (RuntimeException rejected) { execution.finish(); }
} catch (RuntimeException rejected) { execution.resolve("failed"); execution.finish(); }
});
}
@@ -127,20 +133,34 @@ public final class MendingLight implements AbilityEffect {
java.util.Optional<CharacterRecord> saved, Throwable failure) {
try {
if (failure != null || saved == null || saved.isEmpty()) {
execution.resolve("failed");
execution.input().player().sendMessage("Mending Light health could not be saved; no healing was applied.");
return;
}
if (!execution.stillCurrent() || !current(execution.input().player(), target, from(execution.definition()).range())) return;
if (!execution.stillCurrent() || !current(execution.input().player(), target, from(execution.definition()).range())) {
execution.resolve("interrupted"); return;
}
var persisted = saved.orElseThrow();
if (!sessions.acknowledgeProgress(target.active(), expected, persisted)) return;
if (!sessions.acknowledgeProgress(target.active(), expected, persisted)) {
execution.resolve("interrupted"); return;
}
var attribute = target.player().getAttribute(Attribute.MAX_HEALTH);
if (attribute == null) return;
double current = target.player().getHealth(); double maximum = attribute.getValue();
double desired = Math.min(maximum, persisted.health().doubleValue());
double restored = Math.min(accepted, desired - current);
if (!Double.isFinite(current) || !Double.isFinite(maximum) || !Double.isFinite(restored) || restored <= 0) return;
if (!Double.isFinite(current) || !Double.isFinite(maximum) || !Double.isFinite(restored) || restored <= 0) {
execution.resolve("miss"); return;
}
target.player().setHealth(Math.min(maximum, current + restored));
var amount = BigDecimal.valueOf(restored).stripTrailingZeros();
double after = target.player().getHealth();
double actual = after - current;
if (!Double.isFinite(actual) || actual <= 0) { execution.resolve("miss"); return; }
execution.acceptedHeal(target.active(), target.player(), persisted, accepted, actual, current, after, maximum);
execution.resolve("healed", Map.of("target_player_id", target.player().getUniqueId().toString(),
"target_character_id", target.active().character().toString(),
"actual_amount", BigDecimal.valueOf(actual).stripTrailingZeros().toPlainString()));
var amount = BigDecimal.valueOf(actual).stripTrailingZeros();
supports.accept(new Support(execution.input().session().character(), target.active(), amount));
execution.report(AbilityReport.Type.HEALING, target.player(), amount);
} finally { execution.finish(); }
@@ -39,14 +39,54 @@ public final class Meteor implements AbilityEffect, Listener, AutoCloseable {
Duration.ofSeconds(2),5,4);}
}
private record Ground(Block block,Location origin){ }
private static final class Attempt {final AbilityExecution execution;final LivingEntity target;boolean accepted;Attempt(AbilityExecution e,LivingEntity t){execution=e;target=t;}}
private static final class Attempt {
final AbilityExecution execution;final LivingEntity target;final double requested;final int ordinal;
boolean accepted;
Attempt(AbilityExecution execution,LivingEntity target,double requested,int ordinal){
this.execution=execution;this.target=target;this.requested=requested;this.ordinal=ordinal;
}
}
private final class Warning implements AutoCloseable {
final AbilityExecution execution;final Player source;final Ground ground;final Settings captured;final BigInteger intelligence;
final int maximumTicks;int elapsed;boolean impacted;boolean closed;
Warning(AbilityExecution execution,Ground ground,Settings captured){this.execution=execution;source=execution.input().player();this.ground=ground;this.captured=captured;intelligence=execution.character().stats().get(Stat.INT);maximumTicks=(int)Math.max(1,(captured.warning().toNanos()+49_999_999L)/50_000_000L);}
void start(){warnings.put(execution,this);execution.own(this);warningVisual();execution.later(Duration.ofMillis(50),this::step);}
void step(){if(closed||impacted)return;if(!execution.stillCurrent()){execution.finish();return;}elapsed++;if(elapsed>=maximumTicks){impact();execution.finish();return;}warningVisual();execution.later(Duration.ofMillis(50),this::step);}
void impact(){if(closed||impacted)return;impacted=true;if(!groundCurrent()||!execution.stillCurrent())return;impactVisual();double amount=finite(damage(captured.baseDamage(),intelligence));int affected=0;for(var target:candidates(ground.origin(),captured.radius(),captured.verticalRange())){if(affected==64||!execution.stillCurrent())break;if(!eligible(execution,target,ground.origin(),captured.radius(),captured.verticalRange()))continue;var sourceDamage=DamageSource.builder(DamageType.MAGIC).withCausingEntity(source).withDirectEntity(source).withDamageLocation(ground.origin()).build();var attempt=new Attempt(execution,target);damaging=attempt;try{execution.damage(target,amount,sourceDamage);}finally{if(damaging==attempt)damaging=null;}affected++;}}
void step(){
if(closed||impacted)return;
if(!execution.stillCurrent()){
execution.resolve("interrupted",Map.of("stage","warning","warning_ticks_elapsed",elapsed));
execution.finish();return;
}
elapsed++;
if(elapsed>=maximumTicks){impact();execution.finish();return;}
warningVisual();execution.later(Duration.ofMillis(50),this::step);
}
void impact(){
if(closed||impacted)return;impacted=true;
if(!groundCurrent()||!execution.stillCurrent()){
execution.resolve("interrupted",Map.of("stage","warning","reason","ground_or_source_unavailable"));
return;
}
impactVisual();double amount=finite(damage(captured.baseDamage(),intelligence));
int attempted=0,accepted=0;var candidates=candidates(ground.origin(),captured.radius(),captured.verticalRange());
for(var target:candidates){
if(attempted==64||!execution.stillCurrent())break;
if(!eligible(execution,target,ground.origin(),captured.radius(),captured.verticalRange()))continue;
var sourceDamage=DamageSource.builder(DamageType.MAGIC).withCausingEntity(source)
.withDirectEntity(source).withDamageLocation(ground.origin()).build();
var attempt=new Attempt(execution,target,amount,attempted+1);damaging=attempt;
try{execution.damage(target,amount,sourceDamage);}
finally{if(damaging==attempt)damaging=null;}
attempted++;if(attempt.accepted)accepted++;
}
var evidence=new LinkedHashMap<String,Object>();
evidence.put("stage","impact");evidence.put("warning_ticks_elapsed",elapsed);
evidence.put("impact_location",locationData(ground.origin()));
evidence.put("candidate_count",candidates.size());evidence.put("attempted_count",attempted);
evidence.put("accepted_count",accepted);evidence.put("requested_amount",amount);
evidence.put("terminal_reason",!execution.stillCurrent()?"source_interrupted":accepted>0?"accepted_hits":"no_accepted_targets");
execution.resolve(!execution.stillCurrent()?"interrupted":accepted>0?"hit":"miss",evidence);
}
boolean groundCurrent(){try{return ground.origin().getWorld()!=null&&ground.block().getWorld()==ground.origin().getWorld()&&ground.block().getType().isSolid();}catch(RuntimeException invalid){return false;}}
void warningVisual(){try{var dust=new Particle.DustOptions(Color.fromRGB(255,90,20),1.6f);particles.world(Particle.DUST,ground.origin().clone().add(0,.15,0),24,captured.radius()/2,.05,captured.radius()/2,0,dust);}catch(RuntimeException ignored){}}
void impactVisual(){try{var world=ground.origin().getWorld();particles.world(Particle.EXPLOSION_EMITTER,ground.origin(),2,0,0,0,0);particles.world(Particle.FLAME,ground.origin(),48,captured.radius()/2,1,captured.radius()/2,.04);world.playSound(ground.origin(),Sound.ENTITY_GENERIC_EXPLODE,1f,.7f);}catch(RuntimeException ignored){}}
@@ -66,11 +106,39 @@ public final class Meteor implements AbilityEffect, Listener, AutoCloseable {
public static BigInteger damage(BigInteger intelligence){return damage(BigInteger.valueOf(14),intelligence);}
static BigInteger damage(BigInteger base,BigInteger intelligence){Objects.requireNonNull(base);Objects.requireNonNull(intelligence);if(base.signum()<0||intelligence.signum()<0)throw new IllegalArgumentException("Invalid Meteor damage input");var delta=intelligence.subtract(BigInteger.TEN);var divided=delta.divideAndRemainder(BigInteger.TWO);var modifier=divided[0];if(delta.signum()<0&&divided[1].signum()!=0)modifier=modifier.subtract(BigInteger.ONE);return base.add(modifier);}
@Override public boolean accepts(ControlInput input){return !closed&&input.target()==null&&ground(input.player(),settings.targetRange())!=null;}
@Override public void activate(AbilityExecution execution){if(closed||!execution.stillCurrent())return;var captured=from(execution.definition());var target=ground(execution.input().player(),captured.targetRange());if(target==null){execution.finish();return;}new Warning(execution,target,captured).start();}
@Override public void activate(AbilityExecution execution){
if(closed||!execution.stillCurrent())return;
var captured=from(execution.definition());var target=ground(execution.input().player(),captured.targetRange());
if(target==null){execution.resolve("miss",Map.of("stage","targeting","reason","ground_unavailable"));execution.finish();return;}
new Warning(execution,target,captured).start();
}
private static Ground ground(Player player,double range){try{var eye=player.getEyeLocation();var hit=player.getWorld().rayTraceBlocks(eye,eye.getDirection(),range,FluidCollisionMode.NEVER,true);if(hit==null||hit.getHitBlock()==null||hit.getHitBlockFace()!=BlockFace.UP||!hit.getHitBlock().getType().isSolid())return null;var block=hit.getHitBlock();return new Ground(block,block.getLocation().add(.5,1,.5));}catch(RuntimeException invalid){return null;}}
private List<LivingEntity> candidates(Location origin,double radius,double vertical){List<Entity> nearby;try{nearby=new ArrayList<>(origin.getWorld().getNearbyEntities(origin,radius,vertical,radius));}catch(RuntimeException unavailable){return List.of();}var unique=new LinkedHashMap<UUID,LivingEntity>();int inspected=0;for(var entity:nearby){if(++inspected>256)break;if(entity instanceof LivingEntity living)unique.putIfAbsent(living.getUniqueId(),living);}var result=new ArrayList<>(unique.values());result.sort(Comparator.comparingDouble((LivingEntity target)->distance(origin,target)).thenComparing(LivingEntity::getUniqueId));return result;}
private boolean eligible(AbilityExecution execution,LivingEntity target,Location origin,double radius,double vertical){var source=execution.input().player();if(target==source||!inside(origin,target,radius,vertical)||!unobstructed(origin,target))return false;if(!(target instanceof Player player))return true;var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return false;var sourceParty=parties.partyOf(source.getUniqueId()).orElse(null);var targetParty=parties.partyOf(player.getUniqueId()).orElse(null);return sourceParty==null||targetParty==null||!sourceParty.id().equals(targetParty.id());}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void acceptedDamage(EntityDamageEvent event){var attempt=damaging;if(attempt==null||attempt.accepted||event.isCancelled()||event.getEntity()!=attempt.target||event.getCause()!=EntityDamageEvent.DamageCause.MAGIC||event.getFinalDamage()<=0||!Double.isFinite(event.getFinalDamage()))return;attempt.accepted=true;attempt.execution.report(AbilityReport.Type.DAMAGE,attempt.target,BigDecimal.valueOf(event.getFinalDamage()));}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)
public void acceptedDamage(EntityDamageEvent event){
var attempt=damaging;
if(!ownsAcceptedDamage(event)||attempt.accepted||event.isCancelled()
||event.getFinalDamage()<=0||!Double.isFinite(event.getFinalDamage()))return;
attempt.accepted=true;
attempt.execution.acceptedDamage(attempt.target,attempt.requested,event.getFinalDamage(),
attempt.execution.input().player(),Map.of("damage_kind","owned_meteor_impact","ordinal",attempt.ordinal));
attempt.execution.report(AbilityReport.Type.DAMAGE,attempt.target,BigDecimal.valueOf(event.getFinalDamage()));
}
/** Exclude only this accepted in-flight impact from the generic native damage observer. */
public boolean ownsAcceptedDamage(EntityDamageEvent event){
var attempt=damaging;
if(attempt==null||event.getEntity()!=attempt.target||event.getCause()!=EntityDamageEvent.DamageCause.MAGIC)return false;
var caster=attempt.execution.input().player();
var causing=event.getDamageSource().getCausingEntity();var direct=event.getDamageSource().getDirectEntity();
return (causing==null||causing==caster)&&(direct==null||direct==caster);
}
private static Map<String,Object> locationData(Location location){
var world=location==null?null:location.getWorld();
if(world==null||world.getUID()==null||world.getName()==null)return Map.of("location_status","unavailable");
return Map.of("world_id",world.getUID().toString(),"world_name",world.getName(),
"x",location.getX(),"y",location.getY(),"z",location.getZ());
}
private static boolean inside(Location origin,LivingEntity target,double radius,double vertical){try{var at=target.getLocation();if(origin.getWorld()!=at.getWorld())return false;double dx=at.getX()-origin.getX(),dz=at.getZ()-origin.getZ();return dx*dx+dz*dz<=radius*radius&&Math.abs(at.getY()-origin.getY())<=vertical;}catch(RuntimeException invalid){return false;}}
private static boolean unobstructed(Location origin,LivingEntity target){try{var world=origin.getWorld();var at=target.getLocation();if(world==null||at.getWorld()!=world)return false;var bounds=target.getBoundingBox();var center=bounds==null?at.toVector().add(new org.bukkit.util.Vector(0,target.getHeight()/2,0)):bounds.getCenter();var offset=center.subtract(origin.toVector());double distance=offset.length();if(!Double.isFinite(distance)||distance==0)return distance==0;return world.rayTraceBlocks(origin,offset.multiply(1/distance),distance,FluidCollisionMode.NEVER,true)==null;}catch(RuntimeException invalid){return false;}}
private static double distance(Location origin,LivingEntity target){try{var at=target.getLocation();if(origin.getWorld()!=at.getWorld())return Double.POSITIVE_INFINITY;double dx=at.getX()-origin.getX(),dz=at.getZ()-origin.getZ();return dx*dx+dz*dz;}catch(RuntimeException invalid){return Double.POSITIVE_INFINITY;}}
@@ -41,7 +41,7 @@ public final class OutgoingDamageBonus implements Listener, AutoCloseable {
if (owned != null) { owned.remove(this); if (owned.isEmpty()) grants.remove(recipient.getUniqueId()); }
}
}
private record Used(Grant grant, Entity target, BigDecimal amount) { }
private record Used(Grant grant, Entity target, BigDecimal amount, double base) { }
private final EquipmentSessions sessions;
private final PartyRoster parties;
@@ -82,20 +82,26 @@ public final class OutgoingDamageBonus implements Listener, AutoCloseable {
BigDecimal added = BigDecimal.valueOf(original).multiply(strongest.percent.movePointLeft(2));
double increased = BigDecimal.valueOf(original).add(added).doubleValue();
if (!Double.isFinite(increased) || increased < 0 || added.signum() <= 0) return;
event.setDamage(increased); pending.put(event, new Used(strongest, event.getEntity(), added));
event.setDamage(increased); pending.put(event, new Used(strongest, event.getEntity(), added, original));
}
@EventHandler(priority = EventPriority.MONITOR)
public void accepted(EntityDamageEvent event) {
var used = pending.remove(event);
if (used == null || event.isCancelled() || event.getFinalDamage() <= 0 || !used.grant.valid()) return;
used.grant.execution.report(AbilityReport.Type.BOOST_USED, used.target, used.amount);
if (used == null || event.isCancelled() || !Double.isFinite(event.getFinalDamage())
|| event.getFinalDamage() <= 0 || !used.grant.valid()) return;
double effective = Math.min(used.amount.doubleValue(), event.getFinalDamage());
if (!Double.isFinite(effective) || effective <= 0) return;
var aided = sessions.active(used.grant.recipient.getUniqueId()).filter(sessions::current).orElse(null);
used.grant.execution.acceptedBoost(aided, used.grant.recipient, used.target,
used.base, effective, event.getFinalDamage(), used.grant.percent);
var actual = BigDecimal.valueOf(effective);
used.grant.execution.report(AbilityReport.Type.BOOST_USED, used.target, actual);
if (aided == null || aided.character().equals(used.grant.execution.input().session().character())) return;
var sourceParty = parties.partyOf(used.grant.execution.input().player().getUniqueId()).orElse(null);
var aidedParty = parties.partyOf(used.grant.recipient.getUniqueId()).orElse(null);
if (sourceParty == null || aidedParty == null || !sourceParty.id().equals(aidedParty.id())) return;
supports.accept(new Support(used.target.getUniqueId(), used.grant.execution.input().session().character(), aided, used.amount));
supports.accept(new Support(used.target.getUniqueId(), used.grant.execution.input().session().character(), aided, actual));
}
private static Entity attacker(EntityDamageByEntityEvent event) {
@@ -72,16 +72,22 @@ public final class Purify implements AbilityEffect {
@Override public void activate(AbilityExecution execution) {
if (!execution.stillCurrent()) return;
var configured = from(execution.definition()); var target = target(execution.input(), configured.range());
if (target == null) return;
int removed = 0;
if (target == null) { execution.resolve("miss"); return; }
var removed = new java.util.ArrayList<String>();
int attempted = 0;
for (var type : HARMFUL) {
if (!execution.stillCurrent() || !current(execution.input(), target, configured.range())) break;
if (target.player().getPotionEffect(type) == null) continue;
attempted++;
target.player().removePotionEffect(type);
if (target.player().getPotionEffect(type) == null) removed++;
if (target.player().getPotionEffect(type) == null)
removed.add(type.getKey().getKey().toUpperCase(java.util.Locale.ROOT));
}
if (removed == 0) return;
var amount = BigDecimal.valueOf(removed);
if (removed.isEmpty()) { execution.resolve(attempted == 0 ? "miss" : "cancelled"); return; }
execution.acceptedCleanse(target.active(), target.player(), List.copyOf(removed));
execution.resolve("cleansed", Map.of("target_player_id", target.player().getUniqueId().toString(),
"removed_effects", List.copyOf(removed)));
var amount = BigDecimal.valueOf(removed.size());
execution.report(AbilityReport.Type.CLEANSING, target.player(), amount);
if (target.player() != execution.input().player()
&& execution.stillCurrent() && current(execution.input(), target, configured.range())) {
@@ -40,7 +40,8 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
private final class Area implements AutoCloseable {
final long sequence; final AbilityExecution execution; final Player source; final Settings settings;
final Map<UUID, Benefit> recipients = new java.util.HashMap<>(); boolean closed;
final Map<UUID, Benefit> recipients = new java.util.HashMap<>();
UUID application; String endReason = "interrupted"; boolean closed;
Area(AbilityExecution execution, Settings settings) {
sequence = ++sequences; this.execution = execution; source = execution.input().player(); this.settings = settings;
}
@@ -86,19 +87,33 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
@Override public void close() {
if (closed) return; closed = true; areas.remove(sequence, this);
for (var benefit : List.copyOf(recipients.values())) benefit.close(); recipients.clear();
if (application != null) execution.effectEnded(application, endReason);
}
}
private final class Benefit implements AutoCloseable {
final Area area; final Player player; final AutoCloseable speed; final AutoCloseable bonus; boolean closed;
final Area area; final Player player; final AutoCloseable speed; final AutoCloseable bonus;
final UUID application; boolean closed;
Benefit(Area area, Player player) {
this.area = area; this.player = player;
speed = speed(player, area.settings.speedAmplifier());
bonus = bonuses.grant(area.execution, player, area.settings.damageBonusPercent(), () -> !closed && area.eligible(player));
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
var evidence = new LinkedHashMap<String, Object>();
evidence.put("speed_amplifier", area.settings.speedAmplifier());
evidence.put("damage_bonus_percent", area.settings.damageBonusPercent().toPlainString());
evidence.put("speed_status", "owned_or_superseded_by_existing_speed");
if (active != null) {
evidence.put("recipient_character_id", active.character().toString());
evidence.put("recipient_session_id", active.session().toString());
}
application = area.execution.effectApplied("rallying_cry_benefit", player, evidence);
}
@Override public void close() {
if (closed) return; closed = true; area.recipients.remove(player.getUniqueId(), this);
closeResource(bonus); closeResource(speed);
if (application != null) area.execution.effectEnded(application,
area.closed ? area.endReason : "recipient_eligibility_changed");
}
}
@@ -187,14 +202,23 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return;
var area = new Area(execution, from(execution.definition())); areas.put(area.sequence, area);
execution.own(area); area.refresh(); execution.later(area.settings.duration(), execution::finish);
execution.own(area);
area.application = execution.effectApplied("rallying_cry_area", null,
Map.of("radius", Double.toString(area.settings.radius()),
"duration_seconds", seconds(area.settings.duration())));
area.refresh(); execution.later(area.settings.duration(), () -> {
area.endReason = "expired"; execution.resolve("expired"); execution.finish();
});
}
private AutoCloseable speed(Player player, int amplifier) {
var state = speeds.computeIfAbsent(player.getUniqueId(), ignored -> new SpeedState(player));
var grant = new SpeedGrant(state, amplifier); state.grants.add(grant); state.refresh(); return grant;
}
private void breakArea(Area area) { area.close(); area.execution.finish(); }
private void breakArea(Area area) {
area.endReason = "eligibility_changed"; area.close();
area.execution.resolve("interrupted"); area.execution.finish();
}
private static boolean inside(Player source, Player target, double radius) {
try { var a = source.getLocation(); var b = target.getLocation();
return a.getWorld() == b.getWorld() && a.distanceSquared(b) <= radius * radius;
@@ -220,7 +244,10 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
@Override public void close() {
if (closed) return; closed = true; if (sweepTask != null) sweepTask.cancel();
for (var area : List.copyOf(areas.values())) area.execution.finish(); areas.clear();
for (var area : List.copyOf(areas.values())) {
area.endReason = "shutdown"; area.execution.resolve("interrupted"); area.execution.finish();
}
areas.clear();
for (var state : List.copyOf(speeds.values())) { state.grants.clear(); state.refresh(); }
speeds.clear();
}
@@ -52,6 +52,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
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;
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<>();
@@ -62,16 +63,22 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
final Player target;
final Inventory inventory;
final Map<Integer, Choice> choices;
final games.dmg.fantasystick.events.ReviveEventBridge.Cast cast;
boolean selecting;
boolean eventResolved;
boolean finished;
View(AbilityExecution execution, Player target, Inventory inventory, Map<Integer, Choice> choices) {
this.execution = execution; this.target = target; this.inventory = inventory; this.choices = choices;
View(AbilityExecution execution, Player target, Inventory inventory, Map<Integer, Choice> choices,games.dmg.fantasystick.events.ReviveEventBridge.Cast cast) {
this.execution = execution; this.target = target; this.inventory = inventory; this.choices = choices;this.cast=cast;
}
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);}}
@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); }
}
}
@@ -85,6 +92,7 @@ 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 AbilityDefinition definition() {
return new AbilityDefinition("revive", "Revive", Material.ECHO_SHARD,
@@ -107,15 +115,16 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
execution.finish(); return;
}
if(downed!=null&&downed.downed(target)){var episode=downedEpisode(target);if(episode==null){execution.finish();return;}var action=UUID.randomUUID();var caster=execution.character();var cooldown=caster.cooldowns().get("revive");var distance=execution.input().player().getLocation().distance(target.getLocation());var castAt=clock.instant();var decision=new games.dmg.fantasystick.session.DownedEpisode.Decision("revive",execution.input().player().getUniqueId(),execution.input().player().getName(),caster.id(),caster.name(),execution.input().session().session(),action,castAt,cooldown,distance,true);if(downedEvents!=null)downedEvents.abilityCast(episode,decision);downed.revive(target,downedProtection.get(),decision,status->{if(downedEvents!=null&&status!=games.dmg.fantasystick.session.DownedController.RescueStatus.APPLIED&&status!=games.dmg.fantasystick.session.DownedController.RescueStatus.APPLIED_PENDING_RECOVERY)downedEvents.abilityResolved(episode,decision,status.name().toLowerCase(java.util.Locale.ROOT),clock.instant());execution.input().player().sendMessage(switch(status){case APPLIED->"Revive restored the downed character in place.";case APPLIED_PENDING_RECOVERY->"Revive restored the downed character; durable publication is pending recovery.";case EXPIRED->"Revive arrived after the downed countdown expired.";case STALE->"That downed episode was already resolved or changed.";case FAILED->"Downed Revive is pending recovery; no second rescue was attempted.";});execution.finish();});return;}
var cast=reviveEvents==null?null:reviveEvents.cast(execution,target);
var captured = candidates(target);
if (captured.isEmpty()) {
execution.input().player().sendMessage("That player has no direct-inventory Tail eligible for Revive.");
if(cast!=null)reviveEvents.resolved(cast,"no_eligible_tail","tail_scan");execution.input().player().sendMessage("That player has no direct-inventory Tail eligible for Revive.");
execution.finish(); return;
}
var loads = captured.stream().map(candidate -> characters.find(candidate.identity().characterId())).toList();
CompletableFuture.allOf(loads.toArray(CompletableFuture[]::new)).whenComplete((ignored, failure) -> schedule(() -> {
if (failure != null) { report(failure); execution.input().player().sendMessage("Revive could not inspect those Tails safely."); execution.finish(); return; }
if (closed || !execution.stillCurrent() || !target.isOnline()) { execution.finish(); return; }
if (failure != null) { if(cast!=null)reviveEvents.failed(cast,"tail_inspection_failed");report(failure); execution.input().player().sendMessage("Revive could not inspect those Tails safely."); execution.finish(); return; }
if (closed || !execution.stillCurrent() || !target.isOnline()) {if(cast!=null)reviveEvents.resolved(cast,"stale","tail_inspection"); execution.finish(); return; }
var eligible = new ArrayList<Choice>();
Instant now = clock.instant();
for (int index = 0; index < captured.size(); index++) {
@@ -126,10 +135,10 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
if (eligible(record, candidate.identity(), now) && unchanged(target, candidate)) eligible.add(new Choice(candidate, record));
}
if (eligible.isEmpty()) {
execution.input().player().sendMessage("That player has no current Tail younger than 24 hours.");
if(cast!=null)reviveEvents.resolved(cast,"expired_or_stale","tail_eligibility");execution.input().player().sendMessage("That player has no current Tail younger than 24 hours.");
execution.finish(); return;
}
open(execution, target, eligible, now);
open(execution, target, eligible, now,cast);
}));
}
@@ -174,7 +183,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
return current != null && current.equals(candidate.captured());
}
private void open(AbilityExecution execution, Player target, List<Choice> eligible, Instant now) {
private void open(AbilityExecution execution, Player target, List<Choice> eligible, Instant now,games.dmg.fantasystick.events.ReviveEventBridge.Cast cast) {
View old = targets.get(target.getUniqueId()); if (old != null) old.execution.finish();
int size = Math.min(54, Math.max(9, ((eligible.size() + 8) / 9) * 9));
Inventory inventory = popups.create(size, Component.text("Choose a Tail to Revive"));
@@ -187,7 +196,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
Component.text("Eligible for " + seconds + " more seconds"), Component.text("Click to continue this character")));
display.setItemMeta(meta); inventory.setItem(index, display); choices.put(index, choice);
}
var view = new View(execution, target, inventory, Map.copyOf(choices));
var view = new View(execution, target, inventory, Map.copyOf(choices),cast);
inventories.put(inventory, view); targets.put(target.getUniqueId(), view); execution.own(view);
target.openInventory(inventory);
execution.later(VIEW_TIME, execution::finish);
@@ -231,14 +240,14 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
private void confirm(View view, Choice choice) {
if (closed || !view.execution.stillCurrent() || !view.target.isOnline() || !unchanged(view.target, choice.candidate())) {
view.execution.finish(); return;
view.resolve("stale","tail_confirmation");view.execution.finish(); return;
}
characters.find(choice.record().id()).whenComplete((found, failure) -> schedule(() -> {
if (failure != null) { report(failure); view.execution.input().player().sendMessage("Revive could not recheck that Tail; no item changed."); view.execution.finish(); return; }
if (failure != null) {view.fail("tail_recheck_failed"); report(failure); view.execution.input().player().sendMessage("Revive could not recheck that Tail; no item changed."); view.execution.finish(); return; }
if (closed || !view.execution.stillCurrent() || !view.target.isOnline() || found.isEmpty()
|| !eligible(found.orElseThrow(), choice.candidate().identity(), clock.instant())
|| !unchanged(view.target, choice.candidate())) {
view.execution.input().player().sendMessage("That Tail is no longer eligible; no item changed.");
view.resolve("expired_or_stale","tail_confirmation");view.execution.input().player().sendMessage("That Tail is no longer eligible; no item changed.");
view.execution.finish(); return;
}
reserve(view, choice.candidate(), found.orElseThrow());
@@ -249,17 +258,17 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
ItemStack source = view.target.getInventory().getItem(candidate.slot());
int destination = source.getAmount() == 1 ? -1 : emptyDirectSlot(view.target, candidate.slot());
if (source.getAmount() > 1 && destination < 0) {
view.target.sendMessage("Revive needs one empty direct inventory slot to preserve the copied Tails.");
view.resolve("unsafe_capacity","tail_reservation");view.target.sendMessage("Revive needs one empty direct inventory slot to preserve the copied Tails.");
view.execution.finish(); return;
}
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);
reservations.reserve(reservation).whenComplete((reserved, failure) -> schedule(() -> {
if (failure != null) { report(failure); view.execution.input().player().sendMessage("Revive reservation could not be saved; no Tail changed."); view.execution.finish(); return; }
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)) {
view.execution.input().player().sendMessage("Revive selection became stale after reservation; no Tail was changed.");
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);
@@ -290,22 +299,22 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
}
view.target.updateInventory();
} catch (RuntimeException uncertain) {
report(uncertain); view.target.sendMessage("Revive item delivery is uncertain; retained proof requires recovery.");
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)
.whenComplete((restored, failure) -> schedule(() -> {
if (failure != null || restored.isEmpty()) {
if (failure != null) report(failure);
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 (installFinalSheet(view.target, restored.orElseThrow())) {
reservations.clear(reservation.character(), reservation.id()).exceptionally(failure2 -> { report(failure2); return false; });
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.target.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");
} 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();
}));
}
@@ -349,7 +358,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)) {
reservations.clear(reservation.character(), reservation.id()); return;
if(reviveEvents!=null)reviveEvents.recovered(reservation,record,target);reservations.clear(reservation.character(), reservation.id()); return;
}
if (record.dead() && unchangedTail && !hasSheet) {
reservations.clear(reservation.character(), reservation.id()); return;
@@ -366,7 +375,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())) {
reservations.clear(reservation.character(), reservation.id());
if(reviveEvents!=null)reviveEvents.recovered(reservation,restored.orElseThrow(),target);reservations.clear(reservation.character(), reservation.id());
}
}));
}
@@ -53,23 +53,30 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
private final class State implements AutoCloseable {
final AbilityExecution execution; final Player source;
final Location origin; final Settings captured; final Map<UUID, AutoCloseable> mitigation = new LinkedHashMap<>();
final Location origin; final Settings captured; final Map<UUID, Benefit> mitigation = new LinkedHashMap<>();
Duration elapsed = Duration.ZERO; Duration nextPulse; final Duration scanPeriod;
int pending; boolean expired; boolean closed;
UUID areaApplication; int pulseOrdinal;
String endReason = "interrupted"; int pending; boolean expired; boolean closed;
State(AbilityExecution execution, Settings captured) {
this.execution = execution; this.source = execution.input().player(); this.origin = source.getLocation().clone();
this.captured = captured; this.nextPulse = captured.pulseInterval();
long period = Math.max(50_000_000L, (captured.duration().toNanos() + 59) / 60);
this.scanPeriod = Duration.ofNanos(period);
}
void start() { states.add(this); execution.own(this); reconcile(); visual(); schedule(); }
void start() {
states.add(this); execution.own(this);
areaApplication = execution.effectApplied("sanctuary_area", null,
Map.of("radius", captured.radius(), "vertical_range", captured.verticalRange(),
"duration_seconds", seconds(captured.duration()), "area_location", locationData(origin)));
reconcile(); visual(); schedule();
}
void schedule() {
if (closed || expired) return;
Duration remaining = captured.duration().minus(elapsed);
execution.later(remaining.compareTo(scanPeriod) < 0 ? remaining : scanPeriod, this::step);
}
void step() {
if (closed || !execution.stillCurrent()) { execution.finish(); return; }
if (closed || !execution.stillCurrent()) { execution.resolve("interrupted"); execution.finish(); return; }
Duration remaining = captured.duration().minus(elapsed);
elapsed = elapsed.plus(remaining.compareTo(scanPeriod) < 0 ? remaining : scanPeriod);
reconcile(); visual();
@@ -77,20 +84,22 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
pulse(); nextPulse = nextPulse.plus(captured.pulseInterval());
}
if (elapsed.compareTo(captured.duration()) >= 0) {
expired = true; closeMitigation(); if (pending == 0) execution.finish();
expired = true; endReason = "expired"; closeMitigation();
execution.resolve("expired", Map.of("pulse_count", pulseOrdinal));
if (pending == 0) execution.finish();
} else schedule();
}
void reconcile() {
var eligible = recipients();
for (var entry : List.copyOf(mitigation.entrySet())) {
if (eligible.stream().noneMatch(player -> player.getUniqueId().equals(entry.getKey()))) {
try { entry.getValue().close(); } catch (Exception ignored) { } mitigation.remove(entry.getKey());
entry.getValue().close(); mitigation.remove(entry.getKey());
}
}
for (var player : eligible) mitigation.computeIfAbsent(player.getUniqueId(), ignored ->
combatMitigation.grant(execution, player, captured.damageReductionPercent(), () -> recipientCurrent(player)));
for (var player : eligible) mitigation.computeIfAbsent(player.getUniqueId(), ignored -> new Benefit(this, player));
}
void pulse() {
pulseOrdinal++;
for (var player : recipients()) if (owner(player) == this) heal(player);
}
void heal(Player player) {
@@ -141,11 +150,46 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
captured.radius() / 2, 0.1, captured.radius() / 2, 0); } catch (RuntimeException ignored) { }
}
void closeMitigation() {
for (var grant : List.copyOf(mitigation.values())) try { grant.close(); } catch (Exception ignored) { }
for (var grant : List.copyOf(mitigation.values())) grant.close();
mitigation.clear();
}
@Override public void close() {
if (closed) return; closed = true; expired = true; states.remove(this); closeMitigation();
if (areaApplication != null) execution.effectEnded(areaApplication, endReason);
}
}
private final class Benefit implements AutoCloseable {
final State state; final Player player; final AutoCloseable grant;
UUID application; boolean closed;
Benefit(State state, Player player) {
this.state = state; this.player = player;
grant = combatMitigation.grant(state.execution, player, state.captured.damageReductionPercent(),
() -> !closed && state.recipientCurrent(player), (event, amount) -> {
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
var attacker = event.getDamageSource().getCausingEntity();
if (attacker == null && event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity)
attacker = byEntity.getDamager();
if (active != null && attacker != null && amount > 0)
state.execution.acceptedPrevention(active, player, attacker,
event.getDamage() + amount, amount, event.getFinalDamage(), application);
});
try {
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
var evidence = new LinkedHashMap<String, Object>();
evidence.put("damage_reduction_percent", state.captured.damageReductionPercent().toPlainString());
if (active != null) {
evidence.put("recipient_character_id", active.character().toString());
evidence.put("recipient_session_id", active.session().toString());
}
application = state.execution.effectApplied("sanctuary_benefit", player, evidence);
} catch (RuntimeException ignored) { /* Auditing cannot refuse a native mitigation grant. */ }
}
@Override public void close() {
if (closed) return; closed = true;
try { grant.close(); } catch (Exception ignored) { }
if (application != null) state.execution.effectEnded(application,
state.closed || state.expired ? state.endReason : "recipient_eligibility_changed");
}
}
@@ -209,10 +253,15 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
double desired = Math.min(maximum, persisted.health().doubleValue()); double restored = Math.min(accepted, desired - current);
if (!Double.isFinite(current) || !Double.isFinite(maximum) || !Double.isFinite(restored) || restored <= 0) return;
target.player().setHealth(Math.min(maximum, current + restored));
double after = target.player().getHealth();
double actual = after - current;
if (!Double.isFinite(actual) || actual <= 0) return;
state.execution.acceptedHeal(target.active(), target.player(), persisted,
accepted, actual, current, after, maximum);
state.execution.report(AbilityReport.Type.HEALING, target.player(),
BigDecimal.valueOf(restored).stripTrailingZeros());
BigDecimal.valueOf(actual).stripTrailingZeros());
if (target.player() != state.source) supports.accept(new MendingLight.Support(
state.execution.input().session().character(), target.active(), BigDecimal.valueOf(restored).stripTrailingZeros()));
state.execution.input().session().character(), target.active(), BigDecimal.valueOf(actual).stripTrailingZeros()));
} finally { completePending(state); }
}
private static void completePending(State state) {
@@ -240,6 +289,13 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
return world.rayTraceBlocks(origin, offset.multiply(1 / distance), distance, org.bukkit.FluidCollisionMode.NEVER, true) == null;
} catch (RuntimeException invalid) { return false; }
}
private static Map<String, Object> locationData(Location location) {
var world = location == null ? null : location.getWorld();
if (world == null || world.getUID() == null || world.getName() == null)
return Map.of("location_status", "unavailable");
return Map.of("world_id", world.getUID().toString(), "world_name", world.getName(),
"x", location.getX(), "y", location.getY(), "z", location.getZ());
}
private static double distance(Location origin, Player player) {
try { var at = player.getLocation(); if (origin.getWorld() != at.getWorld()) return Double.POSITIVE_INFINITY;
double dx = at.getX() - origin.getX(), dz = at.getZ() - origin.getZ(); return dx * dx + dz * dz;
@@ -55,13 +55,13 @@ public final class WarBanner implements AbilityEffect, AutoCloseable {
private final class Area implements AutoCloseable {
final long sequence = ++sequences; final AbilityExecution execution; final Player source;
final Location origin; final Settings captured; final Map<UUID, Benefit> recipients = new LinkedHashMap<>();
boolean closed;
UUID application; String endReason = "interrupted"; boolean closed;
Area(AbilityExecution execution, Location origin, Settings captured) {
this.execution = execution; this.source = execution.input().player(); this.origin = origin; this.captured = captured;
}
void refresh() {
if (closed) return;
if (!sourceCurrent()) { execution.finish(); return; }
if (!sourceCurrent()) { endReason = "eligibility_changed"; execution.resolve("interrupted"); execution.finish(); return; }
var eligible = candidates();
for (var entry : List.copyOf(recipients.entrySet())) if (!eligible.containsKey(entry.getKey())) entry.getValue().close();
for (var entry : eligible.entrySet()) recipients.computeIfAbsent(entry.getKey(), ignored -> new Benefit(this, entry.getValue()));
@@ -104,20 +104,36 @@ public final class WarBanner implements AbilityEffect, AutoCloseable {
@Override public void close() {
if (closed) return; closed = true; areas.remove(sequence, this);
for (var benefit : List.copyOf(recipients.values())) benefit.close(); recipients.clear();
if (application != null) execution.effectEnded(application, endReason);
}
}
private final class Benefit implements AutoCloseable {
final Area area; final Player player; final AutoCloseable bonus; final AutoCloseable resistance; boolean closed;
final Area area; final Player player; final AutoCloseable bonus; final AutoCloseable resistance;
UUID application; boolean closed;
Benefit(Area area, Player player) {
this.area = area; this.player = player;
BooleanSupplier current = () -> !closed && area.eligible(player);
bonus = bonuses.grant(area.execution, player, area.captured.damageBonusPercent(), current);
resistance = resistance(player, area.captured.knockbackResistancePercent(), current);
try {
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
var evidence = new LinkedHashMap<String, Object>();
evidence.put("damage_bonus_percent", area.captured.damageBonusPercent().toPlainString());
evidence.put("knockback_resistance_percent", area.captured.knockbackResistancePercent().toPlainString());
evidence.put("area_location", locationData(area.origin));
if (active != null) {
evidence.put("recipient_character_id", active.character().toString());
evidence.put("recipient_session_id", active.session().toString());
}
application = area.execution.effectApplied("war_banner_benefit", player, evidence);
} catch (RuntimeException ignored) { /* Native grant remains authoritative despite audit failure. */ }
}
@Override public void close() {
if (closed) return; closed = true; area.recipients.remove(player.getUniqueId(), this);
closeResource(bonus); closeResource(resistance);
if (application != null) area.execution.effectEnded(application,
area.closed ? area.endReason : "recipient_eligibility_changed");
}
}
@@ -202,9 +218,14 @@ public final class WarBanner implements AbilityEffect, AutoCloseable {
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return; var captured = from(execution.definition());
var origin = placement(execution.input().player(), captured.placementRange());
if (origin == null) { execution.finish(); return; }
if (origin == null) { execution.resolve("miss", Map.of("reason", "placement_unavailable")); execution.finish(); return; }
var area = new Area(execution, origin, captured); areas.put(area.sequence, area); execution.own(area);
area.refresh(); execution.later(captured.duration(), execution::finish);
area.application = execution.effectApplied("war_banner_area", null,
Map.of("area_location", locationData(origin), "radius", captured.radius(),
"vertical_range", captured.verticalRange(), "duration_seconds", seconds(captured.duration())));
area.refresh(); execution.later(captured.duration(), () -> {
area.endReason = "expired"; execution.resolve("expired"); execution.finish();
});
}
private AutoCloseable resistance(Player player, BigDecimal percent, BooleanSupplier current) {
@@ -231,6 +252,13 @@ public final class WarBanner implements AbilityEffect, AutoCloseable {
return world.rayTraceBlocks(origin,offset.multiply(1/distance),distance,FluidCollisionMode.NEVER,true)==null;
} catch(RuntimeException invalid){return false;}
}
private static Map<String, Object> locationData(Location location) {
var world = location == null ? null : location.getWorld();
if (world == null || world.getUID() == null || world.getName() == null)
return Map.of("location_status", "unavailable");
return Map.of("world_id", world.getUID().toString(), "world_name", world.getName(),
"x", location.getX(), "y", location.getY(), "z", location.getZ());
}
private static double distance(Location origin, Player player) {
try { var at=player.getLocation();if(origin.getWorld()!=at.getWorld())return Double.POSITIVE_INFINITY;
double dx=at.getX()-origin.getX(),dz=at.getZ()-origin.getZ();return dx*dx+dz*dz;
@@ -36,6 +36,11 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
private games.dmg.fantasystick.shop.RareMagicCoreService rareMagicCores;
private final org.bukkit.Server server;
private final Map<UUID, Object> pending = new ConcurrentHashMap<>();
private final Map<UUID, games.dmg.fantasystick.events.BookEventBridge.Request> pendingBookEvents = new ConcurrentHashMap<>();
private games.dmg.fantasystick.events.BookEventBridge bookEvents;
private games.dmg.fantasystick.events.ProgressionEventBridge progressionEvents;
private games.dmg.fantasystick.events.AbilityEventBridge abilityEvents;
private games.dmg.fantasystick.events.PassiveEventBridge passiveEvents;
private final Map<UUID, Object> adminPending = new ConcurrentHashMap<>();
private final Map<UUID, AdminView> adminViews = new ConcurrentHashMap<>();
private final Map<UUID, SavedView> savedViews = new ConcurrentHashMap<>();
@@ -61,9 +66,9 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
games.dmg.fantasystick.session.EquipmentSessions.Active active,
int previous, int next) { }
private record SavedView(String nonce, UUID administrator, UUID character, int previous, int next) { }
private record StatView(String nonce, ControlInput input, java.math.BigInteger revision) { }
private record AbilityView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
private record PassiveView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
private record StatView(String nonce, ControlInput input, java.math.BigInteger revision, CharacterRecord before) { }
private record AbilityView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids, CharacterRecord before) { }
private record PassiveView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids, CharacterRecord before) { }
private record RareView(String nonce,ControlInput input,java.util.Map<String,java.math.BigInteger> quantities){}
private record ReskillView(String nonce,ControlInput input,games.dmg.fantasystick.shop.RareReskillService.Preview preview){}
private final Map<UUID, View> views = new ConcurrentHashMap<>();
@@ -117,7 +122,12 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
public void attachFishingInventory(games.dmg.fantasystick.adventure.FishingDirectory fishing){if(closed||this.fishing!=null)throw new IllegalStateException("Fishing inventory already attached or books closed");this.fishing=Objects.requireNonNull(fishing);}
public void attachRareInventory(games.dmg.fantasystick.shop.RareInventoryDirectory inventory,games.dmg.fantasystick.shop.RarePotionService potions,games.dmg.fantasystick.shop.RareReskillService reskills,games.dmg.fantasystick.shop.RareMagicCoreService magicCores){if(closed||rareInventory!=null)throw new IllegalStateException("Rare inventory already attached or books closed");rareInventory=Objects.requireNonNull(inventory);rarePotions=Objects.requireNonNull(potions);rareReskills=Objects.requireNonNull(reskills);rareMagicCores=Objects.requireNonNull(magicCores);}
@Override public void accept(ControlInput input) { load(input, 0); }
public void attachEvents(games.dmg.fantasystick.events.BookEventBridge events){if(closed||bookEvents!=null)throw new IllegalStateException("Book events already attached or controller closed");bookEvents=Objects.requireNonNull(events);}
public void attachProgressionEvents(games.dmg.fantasystick.events.ProgressionEventBridge events){if(closed||progressionEvents!=null)throw new IllegalStateException("Progression events already attached or controller closed");progressionEvents=Objects.requireNonNull(events);}
public void attachAbilityEvents(games.dmg.fantasystick.events.AbilityEventBridge events){if(closed||abilityEvents!=null)throw new IllegalStateException("Ability events already attached or controller closed");abilityEvents=Objects.requireNonNull(events);}
public void attachPassiveEvents(games.dmg.fantasystick.events.PassiveEventBridge events){if(closed||passiveEvents!=null)throw new IllegalStateException("Passive events already attached or controller closed");passiveEvents=Objects.requireNonNull(events);}
@Override public void accept(ControlInput input) { load(input, 0, true); }
public void openReadOnly(org.bukkit.entity.Player administrator, org.bukkit.entity.Player target) {
Objects.requireNonNull(administrator); Objects.requireNonNull(target);
@@ -233,7 +243,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
});
}
private void load(ControlInput input, int offset) {
private void load(ControlInput input, int offset, boolean userRequested) {
if (closed || input.kind() != ControlInput.Kind.BOOK || input.gesture() != ControlInput.Gesture.USE
|| !input.stillCurrent()) return;
var owner = input.session().player();
@@ -244,32 +254,45 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
passiveViews.remove(owner);
rareViews.remove(owner);
reskillViews.remove(owner);
var current=sessions.currentRecord(input.session()).orElse(null);
var audit=userRequested&&bookEvents!=null&&current!=null?bookEvents.request(input,current,offset):null;
var superseded=pendingBookEvents.remove(owner);if(superseded!=null)bookEvents.refused(superseded,"superseded");
if(audit!=null)pendingBookEvents.put(owner,audit);
Object ticket = new Object();
pending.put(owner, ticket);
characters.find(input.session().character()).whenComplete((record, failure) -> {
if (closed || pending.get(owner) != ticket) return;
try {
nextTick.accept(() -> {
if (closed || !pending.remove(owner, ticket) || !input.stillCurrent()) return;
if (closed || !pending.remove(owner, ticket)) return;
if (!input.stillCurrent()) { finishRefused(owner,audit,"stale_session"); return; }
if (failure != null) {
finishFailed(owner,audit,"load_failed");
report(input, failure);
input.player().sendMessage("Character book could not be loaded; no character state was changed.");
return;
}
if (record.isEmpty() || record.orElseThrow().dead()) {
finishRefused(owner,audit,"character_unavailable");
input.player().sendMessage("This character is no longer available.");
return;
}
try { input.player().openBook(render(record.orElseThrow(), input, offset)); }
catch (RuntimeException error) { partyControls.forget(owner); views.remove(owner); report(input, error); }
try {
var book=render(record.orElseThrow(), input, offset);input.player().openBook(book);
if(audit!=null&&pendingBookEvents.remove(owner,audit)){var pages=((BookMeta)book.getItemMeta()).pages().size();bookEvents.opened(audit,record.orElseThrow(),pages,1);}
}
catch (RuntimeException error) { finishFailed(owner,audit,"native_open_failed");partyControls.forget(owner); views.remove(owner); report(input, error); }
});
} catch (RuntimeException rejected) {
pending.remove(owner, ticket);
pending.remove(owner, ticket);finishFailed(owner,audit,"scheduler_rejected");
report(input, rejected);
}
});
}
private void finishRefused(UUID owner,games.dmg.fantasystick.events.BookEventBridge.Request request,String reason){if(request!=null&&pendingBookEvents.remove(owner,request))bookEvents.refused(request,reason);}
private void finishFailed(UUID owner,games.dmg.fantasystick.events.BookEventBridge.Request request,String reason){if(request!=null&&pendingBookEvents.remove(owner,request))bookEvents.failed(request,reason);}
private ItemStack render(CharacterRecord record, ControlInput input, int offset) {
return renderBook(record, input.player(), input, offset, null, null, false);
}
@@ -295,7 +318,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
meta.setTitle("Character");
meta.setAuthor("Fantasy Stick");
var statView = interactive ? new StatView(UUID.randomUUID().toString(), input, record.revision()) : null;
var statView = interactive ? new StatView(UUID.randomUUID().toString(), input, record.revision(), record) : null;
var allOverview = overviewPages(record, subject, statView, savedState);
int start = Math.min(offset, (allOverview.size() - 1) / VOLUME_SIZE * VOLUME_SIZE);
int end = Math.min(start + VOLUME_SIZE, allOverview.size());
@@ -318,11 +341,11 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
if ((!definitions.isEmpty() || !passiveDefinitions.isEmpty()) && end == allOverview.size()) {
var abilityView = interactive ? new AbilityView(UUID.randomUUID().toString(), input, record.revision(),
definitions.stream().map(games.dmg.fantasystick.ability.AbilityDefinition::id)
.collect(java.util.stream.Collectors.toUnmodifiableSet())) : null;
.collect(java.util.stream.Collectors.toUnmodifiableSet()), record) : null;
if (interactive && !definitions.isEmpty()) abilityViews.put(input.session().player(), abilityView);
var passiveView = interactive ? new PassiveView(UUID.randomUUID().toString(), input, record.revision(),
passiveDefinitions.stream().map(games.dmg.fantasystick.passive.PassiveDefinition::id)
.collect(java.util.stream.Collectors.toUnmodifiableSet())) : null;
.collect(java.util.stream.Collectors.toUnmodifiableSet()), record) : null;
if (interactive && !passiveDefinitions.isEmpty()) passiveViews.put(input.session().player(), passiveView);
var passiveNames = passiveDefinitions.stream().collect(java.util.stream.Collectors.toMap(
games.dmg.fantasystick.passive.PassiveDefinition::id,
@@ -412,7 +435,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
return item;
}
private java.util.List<Component> inventoryPages(UUID character,ControlInput input){var directory=fishing;var rare=rareInventory;if(directory==null&&rare==null)return java.util.List.of();var values=directory==null?java.util.List.<games.dmg.fantasystick.adventure.FishingState.ItemStack>of():directory.inventory(character);var trophies=directory==null?java.util.List.<games.dmg.fantasystick.adventure.FishingState.Trophy>of():directory.trophies(character);var goods=rare==null?java.util.List.<games.dmg.fantasystick.shop.RareInventoryDirectory.Item>of():rare.inventory(character);RareView view=input==null?null:new RareView(UUID.randomUUID().toString(),input,goods.stream().filter(games.dmg.fantasystick.shop.RareInventoryDirectory.Item::available).collect(java.util.stream.Collectors.toUnmodifiableMap(games.dmg.fantasystick.shop.RareInventoryDirectory.Item::id,games.dmg.fantasystick.shop.RareInventoryDirectory.Item::quantity)));if(view!=null&&!view.quantities().isEmpty())rareViews.put(input.session().player(),view);var builder=new PageBuilder();builder.text("Inventory");builder.blank();if(values.isEmpty()&&trophies.isEmpty()&&goods.isEmpty())builder.text("No items.");else{int shown=0;for(var value:values){if(shown++>=256)break;builder.text(value.name()+": "+value.amount());builder.text("Fishing origin: "+value.site());}for(var trophy:trophies){if(shown++>=256)break;builder.text(trophy.name()+": 1");builder.text("Trophy origin: "+trophy.origin());}for(var good:goods){if(shown++>=256)break;builder.inline(good.name()+": "+good.quantity(),view!=null&&good.available()?rareButton(view,good.id()):null);if(!good.available())builder.text("Unavailable item type");}if(values.size()+trophies.size()+goods.size()>256)builder.text("More items are retained.");}return builder.pages();}
private java.util.List<Component> inventoryPages(UUID character,ControlInput input){var directory=fishing;var rare=rareInventory;if(directory==null&&rare==null)return java.util.List.of();var values=directory==null?java.util.List.<games.dmg.fantasystick.adventure.FishingState.ItemStack>of():directory.inventory(character);var trophies=directory==null?java.util.List.<games.dmg.fantasystick.adventure.FishingState.Trophy>of():directory.trophies(character);var goods=rare==null?java.util.List.<games.dmg.fantasystick.shop.RareInventoryDirectory.Item>of():rare.inventory(character);RareView view=input==null?null:new RareView(UUID.randomUUID().toString(),input,goods.stream().filter(games.dmg.fantasystick.shop.RareInventoryDirectory.Item::available).collect(java.util.stream.Collectors.toUnmodifiableMap(games.dmg.fantasystick.shop.RareInventoryDirectory.Item::id,games.dmg.fantasystick.shop.RareInventoryDirectory.Item::quantity)));if(view!=null&&!view.quantities().isEmpty())rareViews.put(input.session().player(),view);var builder=new PageBuilder();builder.text("Inventory");builder.blank();if(values.isEmpty()&&trophies.isEmpty()&&goods.isEmpty())builder.text("No items.");else{int shown=0;for(var value:values){if(shown++>=256)break;builder.text(value.name()+": "+value.amount());builder.text("Fishing origin: "+value.site());}for(var trophy:trophies){if(shown++>=256)break;builder.text(trophy.name()+": 1");builder.text("Trophy origin: "+trophy.origin());}for(var good:goods){if(shown++>=256)break;boolean phoenix=games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN.id().equals(good.id());builder.inline(good.name()+": "+good.quantity(),view!=null&&good.available()&&!phoenix?rareButton(view,good.id()):null);if(phoenix&&good.available())builder.text("Use Phoenix Down while downed.");if(!good.available())builder.text("Unavailable item type");}if(values.size()+trophies.size()+goods.size()>256)builder.text("More items are retained.");}return builder.pages();}
private static Component rareButton(RareView view,String product){return Component.text("[Use]",net.kyori.adventure.text.format.NamedTextColor.DARK_GREEN).clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(RARE_USE,net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\""+view.nonce()+":"+product+"\"")));}
private Component gearPage(CharacterRecord record){return Component.text("Gear\n\nHead: Cosmetic\nChestplate: "+tier(record.gear().chestplate())+"\nLeggings: "+tier(record.gear().leggings())+"\nBoots: "+tier(record.gear().boots()));}
@@ -743,15 +766,20 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return;
var player = connection.getPlayer();
var view = views.get(player.getUniqueId());
if (view == null || view.input().player() != player || event.getTag() == null) return;
if (view == null) { refusePage(player,"missing_or_used_view"); return; }
if (view.input().player() != player || event.getTag() == null) { refusePage(player,"foreign_or_missing_payload"); return; }
String raw = event.getTag().string();
if (raw.length() > 64) return;
if (raw.length() > 64) { refusePage(player,"invalid_payload"); return; }
var match = PAGE_PAYLOAD.matcher(raw);
if (!match.matches() || !view.nonce().equals(match.group(1))) return;
if (!match.matches() || !view.nonce().equals(match.group(1))) { refusePage(player,"stale_or_forged_payload"); return; }
int offset = match.group(2).equals("0") ? view.previous() : view.next();
if (offset < 0 || !views.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
if (offset < 0 || !views.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) { refusePage(player,"stale_or_unavailable_page"); return; }
// Reread authoritative data, rather than retaining a stale giant character snapshot.
load(view.input(), offset);
load(view.input(), offset, true);
}
private void refusePage(org.bukkit.entity.Player player,String reason){
if(bookEvents==null)return;var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return;var current=sessions.currentRecord(active).orElse(null);if(current!=null)bookEvents.refused(player,active,current,"character_book_navigate",reason);
}
private void report(ControlInput input, Throwable failure) {
@@ -767,49 +795,53 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
public void statAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
if (closed || !STATS.equals(event.getIdentifier())
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return;
var player = connection.getPlayer();
var player = connection.getPlayer();if(event.getTag()==null)return;String raw=event.getTag().string();if(raw.length()>80)return;var match=STAT_PAYLOAD.matcher(raw);if(!match.matches())return;var stat=Stat.valueOf(match.group(2));
var view = statViews.get(player.getUniqueId());
if (view == null || view.input().player() != player || event.getTag() == null) return;
String raw = event.getTag().string();
if (raw.length() > 80) return;
var match = STAT_PAYLOAD.matcher(raw);
if (!match.matches() || !view.nonce().equals(match.group(1))
|| !statViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
var stat = Stat.valueOf(match.group(2));
if (view == null || view.input().player() != player || !view.nonce().equals(match.group(1))
|| !statViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) { refuseStat(player,stat,"stale_forged_or_repeated_action"); return; }
var currentHealth = java.math.BigDecimal.valueOf(player.getHealth());
var nativeLimit = NativeHealthLimit.read();
characters.purchaseStat(view.input().session().character(), view.revision(), stat, currentHealth, nativeLimit)
.whenComplete((purchase, failure) -> {
try {
nextTick.accept(() -> finishPurchase(view, stat, purchase, failure));
} catch (RuntimeException rejected) { report(view.input(), rejected); }
});
var nativeMaximum=java.math.BigDecimal.valueOf(java.util.Objects.requireNonNull(player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).getValue());
var audit=progressionEvents==null?null:progressionEvents.begin(view.input(),view.before(),stat,currentHealth,nativeMaximum,nativeLimit);
try {
characters.purchaseStat(view.input().session().character(), view.revision(), stat, currentHealth, nativeLimit)
.whenComplete((purchase, failure) -> {
try { nextTick.accept(() -> finishPurchase(view, stat, audit, purchase, failure)); }
catch (RuntimeException rejected) { if(audit!=null){if(purchase!=null&&purchase.status()==CharacterStorage.StatPurchaseStatus.APPLIED)progressionEvents.increased(audit,purchase.record().orElseThrow(),"scheduler_rejected_before_native_publication",purchase.record().orElseThrow().health());else progressionEvents.failed(audit,"scheduler_rejected");}report(view.input(), rejected); }
});
} catch (RuntimeException failure) { if(audit!=null)progressionEvents.failed(audit,"purchase_rejected");report(view.input(),failure); }
}
private void finishPurchase(StatView view, Stat stat, CharacterStorage.StatPurchase purchase, Throwable failure) {
private void refuseStat(org.bukkit.entity.Player player,Stat stat,String reason){if(progressionEvents==null)return;var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return;var current=sessions.currentRecord(active).orElse(null);if(current!=null)progressionEvents.refused(player,active,current,stat,NativeHealthLimit.read(),reason);}
private void finishPurchase(StatView view, Stat stat, games.dmg.fantasystick.events.ProgressionEventBridge.Action audit, CharacterStorage.StatPurchase purchase, Throwable failure) {
var input = view.input();
if (closed || !input.stillCurrent()) return;
if (failure != null) {
report(input, failure);
input.player().sendMessage("Stat purchase could not be saved; no character state was changed.");
if(audit!=null)progressionEvents.failed(audit,"save_failed");report(input, failure);
if(!closed&&input.player().isOnline())input.player().sendMessage("Stat purchase could not be saved; no character state was changed.");
return;
}
if (purchase.status() == CharacterStorage.StatPurchaseStatus.APPLIED) {
var saved = purchase.record().orElseThrow();
if(closed||!input.stillCurrent()){if(audit!=null)progressionEvents.increased(audit,saved,closed?"plugin_shutdown_before_native_publication":"session_changed_before_native_publication",saved.health());return;}
try {
if (!sessions.acknowledgeProgress(input.session(), view.revision(), saved)) {
if(audit!=null)progressionEvents.increased(audit,saved,"session_changed_before_native_publication",saved.health());
input.player().sendMessage("Stat purchase was saved; reopen the character after the session transition.");
return;
}
} catch (RuntimeException nativeFailure) {
report(input, nativeFailure);
if(audit!=null)progressionEvents.increased(audit,saved,"native_publication_failed",saved.health());report(input, nativeFailure);
input.player().sendMessage("Stat purchase was saved, but native health refresh failed; exit for recovery.");
return;
}
if(audit!=null)progressionEvents.increased(audit,saved,"acknowledged",java.math.BigDecimal.valueOf(input.player().getHealth()));
input.player().sendMessage(stat + " increased to " + saved.stats().get(stat) + ".");
load(input, 0);
load(input, 0, false);
return;
}
if(audit!=null)progressionEvents.refused(audit,purchase.status().name().toLowerCase(java.util.Locale.ROOT),purchase.record().orElse(null));
if(closed||!input.stillCurrent())return;
String message = switch (purchase.status()) {
case STALE -> "That character page is stale; reopen it before spending points.";
case NO_POINTS -> "This character has no unspent points.";
@@ -817,7 +849,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
case APPLIED -> throw new IllegalStateException("Handled applied purchase");
};
input.player().sendMessage(message);
load(input, 0);
load(input, 0, false);
}
@org.bukkit.event.EventHandler
@@ -827,18 +859,16 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
if (closed || abilityEngine == null || !unlock && !unlearn
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)
|| event.getTag() == null) return;
var player = connection.getPlayer();
var player = connection.getPlayer();String raw=event.getTag().string();if(raw.length()>120)return;var match=ABILITY_PAYLOAD.matcher(raw);if(!match.matches())return;String ability=match.group(2);
var view = abilityViews.get(player.getUniqueId());
if (view == null || view.input().player() != player) return;
String raw = event.getTag().string();
if (raw.length() > 120) return;
var match = ABILITY_PAYLOAD.matcher(raw);
if (!match.matches() || !view.nonce().equals(match.group(1)) || !view.ids().contains(match.group(2))
|| !abilityViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
String ability = match.group(2);
boolean audited=unlearn||unlock&&("revive".equals(ability)||abilityEngine.eventsEnabledFor(ability));
if (view == null || view.input().player() != player || !view.nonce().equals(match.group(1)) || !view.ids().contains(ability)
|| !abilityViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) {if(audited)refuseAbilityMutation(player,ability,unlock,"stale_forged_or_repeated_action");return;}
var audit=audited&&abilityEvents!=null?abilityEvents.begin(view.input(),view.before(),ability,unlock):null;
var operation = unlock ? abilityEngine.purchase(view.input(), ability, view.revision())
: abilityEngine.unlearn(view.input(), ability, view.revision());
operation.whenComplete((mutation, failure) -> {
if(audit!=null){if(failure!=null)abilityEvents.failed(audit,"save_or_native_cleanup_uncertain");else abilityEvents.resolved(audit,mutation);}
if (closed || !view.input().stillCurrent()) return;
if (failure != null) {
report(view.input(), failure);
@@ -846,10 +876,12 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
return;
}
player.sendMessage(abilityMessage(unlock, ability, mutation.status()));
load(view.input(), 0);
load(view.input(), 0, false);
});
}
private void refuseAbilityMutation(org.bukkit.entity.Player player,String ability,boolean learn,String reason){if(abilityEvents==null||abilities.find(ability).isEmpty())return;var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return;var current=sessions.currentRecord(active).orElse(null);if(current!=null)abilityEvents.refused(player,active,current,ability,learn,reason);}
@org.bukkit.event.EventHandler
public void passiveAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
boolean unlock = PASSIVE_UNLOCK.equals(event.getIdentifier());
@@ -857,16 +889,14 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
if (closed || passiveEngine == null || !unlock && !unlearn
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)
|| event.getTag() == null) return;
var player = connection.getPlayer(); var view = passiveViews.get(player.getUniqueId());
if (view == null || view.input().player() != player) return;
String raw = event.getTag().string(); if (raw.length() > 120) return;
var match = ABILITY_PAYLOAD.matcher(raw);
if (!match.matches() || !view.nonce().equals(match.group(1)) || !view.ids().contains(match.group(2))
|| !passiveViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
String passive = match.group(2);
var player=connection.getPlayer();String raw=event.getTag().string();if(raw.length()>120)return;var match=ABILITY_PAYLOAD.matcher(raw);if(!match.matches())return;String passive=match.group(2);var definition=passives.find(passive).orElse(null);if(definition==null)return;var view=passiveViews.get(player.getUniqueId());boolean owned=games.dmg.fantasystick.passive.QuickerRegenerationI.ID.equals(passive);
if (view == null || view.input().player() != player || !view.nonce().equals(match.group(1)) || !view.ids().contains(passive)
|| !passiveViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) {if(owned)refusePassive(player,definition,unlock,"stale_forged_or_repeated_action");return;}
var audit=owned&&passiveEvents!=null?passiveEvents.begin(view.input(),view.before(),definition,unlock):null;
var operation = unlock ? passiveEngine.purchase(view.input(), passive, view.revision())
: passiveEngine.unlearn(view.input(), passive, view.revision());
operation.whenComplete((mutation, failure) -> {
if(audit!=null){if(failure!=null)passiveEvents.failed(audit,"save_or_native_publication_uncertain");else passiveEvents.resolved(audit,mutation);}
if (closed || !view.input().stillCurrent()) return;
if (failure != null) {
report(view.input(), failure);
@@ -874,10 +904,12 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
return;
}
player.sendMessage(passiveMessage(unlock, passive, mutation.status()));
load(view.input(), 0);
load(view.input(), 0, false);
});
}
private void refusePassive(org.bukkit.entity.Player player,games.dmg.fantasystick.passive.PassiveDefinition definition,boolean learn,String reason){if(passiveEvents==null)return;var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return;var current=sessions.currentRecord(active).orElse(null);if(current!=null)passiveEvents.refused(player,active,current,definition,learn,reason);}
private static String passiveMessage(boolean unlock, String passive,
CharacterStorage.PassiveMutationStatus status) {
return switch (status) {
@@ -912,12 +944,12 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
}
@org.bukkit.event.EventHandler
public void rareAction(io.papermc.paper.event.player.PlayerCustomClickEvent event){if(closed||rarePotions==null||rareReskills==null||rareMagicCores==null||!RARE_USE.equals(event.getIdentifier())||!(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)||event.getTag()==null)return;var player=connection.getPlayer();var view=rareViews.get(player.getUniqueId());if(view==null||view.input().player()!=player)return;String raw=event.getTag().string();if(raw.length()>120)return;var match=RARE_PAYLOAD.matcher(raw);var product=match.matches()?games.dmg.fantasystick.shop.RareProduct.find(match.group(2)).orElse(null):null;if(product==null||!view.nonce().equals(match.group(1))||!view.quantities().getOrDefault(product.id(),java.math.BigInteger.ZERO).equals(rareInventory.quantity(view.input().session().character(),product))||!rareViews.remove(player.getUniqueId(),view)||!view.input().stillCurrent())return;if(product==games.dmg.fantasystick.shop.RareProduct.RESKILL){var preview=rareReskills.preview(player);if(preview.isEmpty()){player.sendMessage("Reskill preview refused: the character, session or quantity changed.");return;}if(preview.orElseThrow().refund().signum()==0){player.sendMessage("Reskill use refused: this character has no investments to reset.");load(view.input(),0);return;}var confirmation=new ReskillView(UUID.randomUUID().toString(),view.input(),preview.orElseThrow());reskillViews.put(player.getUniqueId(),confirmation);player.openBook(reskillConfirmation(confirmation));return;}if(product==games.dmg.fantasystick.shop.RareProduct.MAGIC_CORE){rareMagicCores.use(player,outcome->{if(closed)return;player.sendMessage(switch(outcome.status()){case APPLIED->"Used Magic Core and gained 1 ordinary unspent point.";case NOT_AVAILABLE->"Magic Core use refused: that item is no longer available.";case STALE->"Magic Core use refused: the character, session, points or quantity changed.";case FAILED->"Magic Core could not be acknowledged; retained proof prevents replay.";});if(view.input().stillCurrent())load(view.input(),0);});return;}if(!product.heals())return;rarePotions.use(player,product,outcome->{if(closed||!view.input().stillCurrent())return;player.sendMessage(switch(outcome.status()){case APPLIED->"Used "+product.displayName()+" and restored "+outcome.healed().toPlainString()+" health.";case FULL_HEALTH->"Potion use refused: health is already full.";case NO_EFFECT->"Potion use refused: no positive healing was accepted.";case NOT_AVAILABLE->"Potion use refused: that item is no longer available.";case STALE->"Potion use refused: the character, session or quantity changed.";case FAILED->"Potion use could not be acknowledged; retained proof prevents replay.";});load(view.input(),0);});}
public void rareAction(io.papermc.paper.event.player.PlayerCustomClickEvent event){if(closed||rarePotions==null||rareReskills==null||rareMagicCores==null||!RARE_USE.equals(event.getIdentifier())||!(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)||event.getTag()==null)return;var player=connection.getPlayer();var view=rareViews.get(player.getUniqueId());if(view==null||view.input().player()!=player)return;String raw=event.getTag().string();if(raw.length()>120)return;var match=RARE_PAYLOAD.matcher(raw);var product=match.matches()?games.dmg.fantasystick.shop.RareProduct.find(match.group(2)).orElse(null):null;if(product==null||!view.nonce().equals(match.group(1))||!view.quantities().getOrDefault(product.id(),java.math.BigInteger.ZERO).equals(rareInventory.quantity(view.input().session().character(),product))||!rareViews.remove(player.getUniqueId(),view)||!view.input().stillCurrent())return;if(product==games.dmg.fantasystick.shop.RareProduct.RESKILL){var preview=rareReskills.preview(player);if(preview.isEmpty()){player.sendMessage("Reskill preview refused: the character, session or quantity changed.");return;}if(preview.orElseThrow().refund().signum()==0){player.sendMessage("Reskill use refused: this character has no investments to reset.");load(view.input(),0,false);return;}var confirmation=new ReskillView(UUID.randomUUID().toString(),view.input(),preview.orElseThrow());reskillViews.put(player.getUniqueId(),confirmation);player.openBook(reskillConfirmation(confirmation));return;}if(product==games.dmg.fantasystick.shop.RareProduct.MAGIC_CORE){rareMagicCores.use(player,outcome->{if(closed)return;player.sendMessage(switch(outcome.status()){case APPLIED->"Used Magic Core and gained 1 ordinary unspent point.";case NOT_AVAILABLE->"Magic Core use refused: that item is no longer available.";case STALE->"Magic Core use refused: the character, session, points or quantity changed.";case FAILED->"Magic Core could not be acknowledged; retained proof prevents replay.";});if(view.input().stillCurrent())load(view.input(),0,false);});return;}if(!product.heals())return;rarePotions.use(player,product,outcome->{if(closed||!view.input().stillCurrent())return;player.sendMessage(switch(outcome.status()){case APPLIED->"Used "+product.displayName()+" and restored "+outcome.healed().toPlainString()+" health.";case FULL_HEALTH->"Potion use refused: health is already full.";case NO_EFFECT->"Potion use refused: no positive healing was accepted.";case NOT_AVAILABLE->"Potion use refused: that item is no longer available.";case STALE->"Potion use refused: the character, session or quantity changed.";case FAILED->"Potion use could not be acknowledged; retained proof prevents replay.";});load(view.input(),0,false);});}
private static ItemStack reskillConfirmation(ReskillView view){var item=new ItemStack(Material.WRITTEN_BOOK);var meta=(BookMeta)item.getItemMeta();meta.setTitle("Confirm Reskill");meta.setAuthor("Fantasy Stick");var page=Component.text("Confirm Reskill\n\nRefund: "+view.preview().refund()+" points\nStats → 0\nAbilities/passives removed\nHealth only clamps to the new maximum\nCooldowns retained\n\n").append(Component.text("[Confirm Reskill]",net.kyori.adventure.text.format.NamedTextColor.DARK_RED).clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(RARE_RESKILL_CONFIRM,net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\""+view.nonce()+"\""))));meta.pages(java.util.List.of(page));item.setItemMeta(meta);return item;}
@org.bukkit.event.EventHandler
public void reskillConfirm(io.papermc.paper.event.player.PlayerCustomClickEvent event){if(closed||rareReskills==null||!RARE_RESKILL_CONFIRM.equals(event.getIdentifier())||!(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)||event.getTag()==null)return;var player=connection.getPlayer();var view=reskillViews.get(player.getUniqueId());String raw=event.getTag().string();if(view==null||view.input().player()!=player||raw.length()>80)return;var match=RARE_CONFIRM_PAYLOAD.matcher(raw);if(!match.matches()||!view.nonce().equals(match.group(1))||!reskillViews.remove(player.getUniqueId(),view)||!view.input().stillCurrent())return;rareReskills.confirm(player,view.preview(),outcome->{if(closed)return;player.sendMessage(switch(outcome.status()){case APPLIED->"Used Reskill; all purchased stats, abilities and passives were refunded.";case NOTHING_TO_RESET->"Reskill use refused: this character has no investments to reset.";case NOT_AVAILABLE->"Reskill use refused: that item is no longer available.";case STALE->"Reskill use refused: the character, session, build or quantity changed.";case FAILED->"Reskill could not be acknowledged; retained proof prevents replay.";});if(view.input().stillCurrent())load(view.input(),0);});}
public void reskillConfirm(io.papermc.paper.event.player.PlayerCustomClickEvent event){if(closed||rareReskills==null||!RARE_RESKILL_CONFIRM.equals(event.getIdentifier())||!(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)||event.getTag()==null)return;var player=connection.getPlayer();var view=reskillViews.get(player.getUniqueId());String raw=event.getTag().string();if(view==null||view.input().player()!=player||raw.length()>80)return;var match=RARE_CONFIRM_PAYLOAD.matcher(raw);if(!match.matches()||!view.nonce().equals(match.group(1))||!reskillViews.remove(player.getUniqueId(),view)||!view.input().stillCurrent())return;rareReskills.confirm(player,view.preview(),outcome->{if(closed)return;player.sendMessage(switch(outcome.status()){case APPLIED->"Used Reskill; all purchased stats, abilities and passives were refunded.";case NOTHING_TO_RESET->"Reskill use refused: this character has no investments to reset.";case NOT_AVAILABLE->"Reskill use refused: that item is no longer available.";case STALE->"Reskill use refused: the character, session, build or quantity changed.";case FAILED->"Reskill could not be acknowledged; retained proof prevents replay.";});if(view.input().stillCurrent())load(view.input(),0,false);});}
@org.bukkit.event.EventHandler
public void partyAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) { partyControls.click(event); }
@@ -926,6 +958,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
public void quit(org.bukkit.event.player.PlayerQuitEvent event) {
var owner = event.getPlayer().getUniqueId();
pending.remove(owner);
var bookRequest=pendingBookEvents.remove(owner);if(bookRequest!=null)bookEvents.refused(bookRequest,"player_quit");
adminPending.remove(owner);
adminViews.remove(owner);
savedViews.remove(owner);
@@ -939,6 +972,6 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
}
@Override public void close() {
closed = true; pending.clear(); adminPending.clear(); adminViews.clear(); savedViews.clear(); views.clear(); statViews.clear(); abilityViews.clear(); passiveViews.clear(); rareViews.clear(); reskillViews.clear(); partyControls.close();
closed = true; pending.clear();pendingBookEvents.values().forEach(request->bookEvents.refused(request,"plugin_shutdown"));pendingBookEvents.clear(); adminPending.clear(); adminViews.clear(); savedViews.clear(); views.clear(); statViews.clear(); abilityViews.clear(); passiveViews.clear(); rareViews.clear(); reskillViews.clear(); partyControls.close();
}
}
@@ -0,0 +1,266 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.ability.AbilityDefinition;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.session.ControlInput;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.entity.Entity;
/** Shared cast and explicit effect-observation boundary; never infers effects from feedback. */
public final class AbilityCastEventBridge {
public record Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
String targetType, Map<String, Object> targetLocation, Instant requestedAt) { }
private final Consumer<EventInput> sink;
private final Clock clock;
public AbilityCastEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
}
public Cast begin(ControlInput input, CharacterRecord before, AbilityDefinition definition) {
try {
var player = input.player();
var target = input.target();
var targetAtRequest = target == null ? null : target.getLocation();
var cast = new Cast(UUID.randomUUID(),
new EventInput.Context(player.getUniqueId(), player.getName(), input.session().character(),
before.name(), input.session().session(), location(player.getLocation())),
definition.id(), target == null ? null : target.getUniqueId(),
target == null ? null : target instanceof org.bukkit.entity.Player ? "PLAYER" : target.getType().name(),
targetAtRequest == null ? Map.of() : locationData(targetAtRequest),
clock.instant());
emit(cast, "action.observed", "observed", Map.of("action_kind", "ability_cast"));
return cast;
} catch (RuntimeException ignored) {
return null; // Auditing may never refuse a cast or change its authority.
}
}
public void cast(Cast cast, CharacterRecord saved, Instant deadline, AbilityDefinition definition) {
if (cast == null) return;
try {
var settings = new LinkedHashMap<String, Object>();
definition.parameters().forEach((key, value) -> settings.put(key, value.toPlainString()));
var data = new LinkedHashMap<String, Object>();
data.put("cooldown_until", deadline);
data.put("after_revision", saved.revision().toString());
data.put("stage", "cooldown_committed");
data.put("activation", definition.activation().name().toLowerCase(java.util.Locale.ROOT));
data.put("captured_settings", settings);
data.put("settings_scope", "definition_at_request");
for (Stat stat : definition.requirements().keySet())
data.put("captured_" + stat.name().toLowerCase(java.util.Locale.ROOT), saved.stats().get(stat).toString());
emit(cast, "ability.cast", "accepted", data);
} catch (RuntimeException ignored) { /* Capturing metadata cannot refuse a saved cast. */ }
}
public void resolved(Cast cast, String outcome, String reason) {
resolved(cast, outcome, reason, Map.of());
}
public void resolved(Cast cast, String outcome, String reason, Map<String, Object> evidence) {
if (cast == null) return;
var data = new LinkedHashMap<String, Object>(evidence);
data.put("reason", reason);
emit(cast, "ability.resolved", outcome, data);
}
public UUID effectApplied(Cast cast, String effectId, Entity target, Map<String, Object> evidence) {
Objects.requireNonNull(effectId);
Objects.requireNonNull(evidence);
UUID application = UUID.randomUUID();
if (cast != null) {
try {
var data = new LinkedHashMap<String, Object>(evidence);
data.put("effect_id", effectId);
data.put("application_id", application.toString());
if (target != null) {
data.put("target_entity_id", target.getUniqueId().toString());
data.put("target_type", entityType(target));
var place = target.getLocation();
if (place != null) data.put("target_location", locationData(place));
}
emit(cast, "effect.applied", "applied", data, application);
} catch (RuntimeException ignored) { /* An observation failure cannot interrupt an applied effect. */ }
}
return application;
}
public void acceptedDamage(Cast cast, Entity target, double requested, double actual,
Entity direct, Map<String, Object> evidence) {
if (cast == null || !Double.isFinite(requested) || !Double.isFinite(actual) || actual <= 0) return;
try {
var data = new LinkedHashMap<String, Object>(evidence);
var application = UUID.randomUUID();
data.put("application_id", application.toString());
data.put("causation_id", cast.actionId().toString());
data.put("requested_amount", requested);
data.put("actual_amount", actual);
data.put("target_entity_id", target.getUniqueId().toString());
data.put("target_type", entityType(target));
var targetAtApplication = target.getLocation();
if (targetAtApplication != null) data.put("target_location", locationData(targetAtApplication));
if (target instanceof org.bukkit.entity.Player player) data.put("target_player_id", player.getUniqueId().toString());
data.put("direct_entity_id", direct.getUniqueId().toString());
data.put("direct_entity_type", entityType(direct));
emit(cast, "combat.damage", "accepted", data, application);
} catch (RuntimeException ignored) { /* Native damage remains authoritative even when auditing fails. */ }
}
public void healed(Cast cast, org.bukkit.entity.Player target, UUID aidedCharacter, UUID aidedSession,
CharacterRecord saved, double requested, double actual, double before, double after,
double maximum) {
if (cast == null || !Double.isFinite(actual) || actual <= 0) return;
try {
var data = new LinkedHashMap<String, Object>();
var application = UUID.randomUUID();
data.put("application_id", application.toString());
data.put("causation_id", cast.actionId().toString());
data.put("target_player_id", target.getUniqueId().toString());
data.put("target_character_id", aidedCharacter.toString());
data.put("target_session_id", aidedSession.toString());
data.put("target_location", locationData(target.getLocation()));
data.put("after_revision", saved.revision().toString());
data.put("requested_amount", decimal(requested));
data.put("actual_amount", decimal(actual));
data.put("health_before", decimal(before));
data.put("health_after", decimal(after));
data.put("maximum_health", decimal(maximum));
emit(cast, "combat.healed", "acknowledged", data, application);
} catch (RuntimeException ignored) { /* Healing remains authoritative despite logging failure. */ }
}
private static String decimal(double value) {
if (!Double.isFinite(value)) throw new IllegalArgumentException("Non-finite health observation");
return java.math.BigDecimal.valueOf(value).stripTrailingZeros().toPlainString();
}
public void cleansed(Cast cast, org.bukkit.entity.Player target, UUID aidedCharacter,
UUID aidedSession, java.util.List<String> removed) {
if (cast == null || removed == null || removed.isEmpty()) return;
try {
var data = new LinkedHashMap<String, Object>();
var application = UUID.randomUUID();
data.put("application_id", application.toString());
data.put("causation_id", cast.actionId().toString());
data.put("target_player_id", target.getUniqueId().toString());
data.put("target_character_id", aidedCharacter.toString());
data.put("target_session_id", aidedSession.toString());
data.put("target_location", locationData(target.getLocation()));
data.put("self_target", aidedCharacter.equals(cast.context().characterId()));
data.put("removed_effects", java.util.List.copyOf(removed));
data.put("actual_count", Integer.toString(removed.size()));
emit(cast, "combat.cleansed", "accepted", data, application);
} catch (RuntimeException ignored) { /* Native cleanse remains authoritative. */ }
}
public void prevented(Cast cast, org.bukkit.entity.Player ally, UUID aidedCharacter,
UUID aidedSession, Entity attacker, double incoming, double actual,
double remaining, UUID bondApplication) {
if (cast == null || !Double.isFinite(actual) || actual <= 0) return;
try {
var data = new LinkedHashMap<String, Object>();
var application = UUID.randomUUID();
data.put("application_id", application.toString());
data.put("causation_id", cast.actionId().toString());
if (bondApplication != null) data.put("bond_application_id", bondApplication.toString());
data.put("target_player_id", ally.getUniqueId().toString());
data.put("target_character_id", aidedCharacter.toString());
data.put("target_session_id", aidedSession.toString());
data.put("target_location", locationData(ally.getLocation()));
data.put("attacker_entity_id", attacker.getUniqueId().toString());
data.put("attacker_entity_type", entityType(attacker));
data.put("incoming_amount", decimal(incoming));
data.put("actual_amount", decimal(actual));
data.put("remaining_amount", decimal(remaining));
emit(cast, "combat.prevented", "accepted", data, application);
} catch (RuntimeException ignored) { /* Prevention authority belongs to the native damage event. */ }
}
public void boosted(Cast cast, games.dmg.fantasystick.session.EquipmentSessions.Active attacking,
org.bukkit.entity.Player attacker, Entity target, double base, double bonus,
double finalDamage, java.math.BigDecimal percent) {
if (cast == null || !Double.isFinite(finalDamage) || finalDamage <= 0
|| !Double.isFinite(bonus) || bonus <= 0) return;
try {
var data = new LinkedHashMap<String, Object>();
var application = UUID.randomUUID();
data.put("application_id", application.toString());
data.put("causation_id", cast.actionId().toString());
data.put("attacker_player_id", attacker.getUniqueId().toString());
if (attacking != null) {
data.put("attacker_character_id", attacking.character().toString());
data.put("attacker_session_id", attacking.session().toString());
}
data.put("target_entity_id", target.getUniqueId().toString());
data.put("target_type", entityType(target));
var location = target.getLocation();
if (location != null && location.getWorld() != null) data.put("target_location", locationData(location));
data.put("base_amount", decimal(base));
data.put("actual_amount", decimal(bonus));
data.put("final_amount", decimal(finalDamage));
data.put("winning_bonus_percent", percent.stripTrailingZeros().toPlainString());
data.put("selection", "strongest_applicable_only");
emit(cast, "combat.boosted", "accepted", data, application);
} catch (RuntimeException ignored) { /* Native combat remains authoritative. */ }
}
private static String entityType(Entity entity) {
if (entity instanceof org.bukkit.entity.Player) return "PLAYER";
var type = entity.getType();
return type == null ? "unavailable" : type.name();
}
public void effectEnded(Cast cast, UUID application, String effectId, String reason) {
if (cast == null) return;
emit(cast, "effect.ended", "ended", Map.of("application_id", application.toString(),
"effect_id", effectId, "reason", reason), application);
}
private void emit(Cast cast, String type, String outcome, Map<String, Object> extra) {
emit(cast, type, outcome, extra, null);
}
private void emit(Cast cast, String type, String outcome, Map<String, Object> extra, UUID application) {
try {
var data = new LinkedHashMap<String, Object>(extra);
data.put("action_kind", "ability_cast");
data.put("ability_id", cast.abilityId());
data.put("outcome", outcome);
if (cast.targetId() != null) {
data.put("target_entity_id", cast.targetId().toString());
data.put("target_type", cast.targetType());
if (!cast.targetLocation().isEmpty()) data.put("target_location", cast.targetLocation());
}
var identity = application == null ? cast.actionId() : application;
sink.accept(new EventInput(stable(identity, type), cast.actionId(),
type.equals("action.observed") ? cast.requestedAt() : clock.instant(), type, cast.context(), data));
} catch (RuntimeException ignored) { /* A writer failure cannot authorize or undo gameplay. */ }
}
private EventInput.Location location(Location value) {
return new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(),
value.getX(), value.getY(), value.getZ(), clock.instant(), "exact");
}
private Map<String, Object> locationData(Location value) {
return Map.of("world_id", value.getWorld().getUID().toString(), "world_name", value.getWorld().getName(),
"x", value.getX(), "y", value.getY(), "z", value.getZ(), "captured_at", clock.instant().toString());
}
private static UUID stable(UUID identity, String type) {
return UUID.nameUUIDFromBytes(("fantasystick:ability-cast:" + identity + ":" + type)
.getBytes(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,61 @@
package games.dmg.fantasystick.events;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.command.BlockCommandSender;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
/** Authorized ability-setting reads and acknowledged updates; no write authority. */
public final class AbilityConfigEventBridge {
public record Actor(String kind, UUID playerId, String playerName) { }
private final Consumer<EventInput> sink;
private final Clock clock;
public AbilityConfigEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
}
/** Capture account identity on the command thread, never on the file worker. */
public Actor capture(CommandSender sender) {
try {
if (sender instanceof Player player) return new Actor("player", player.getUniqueId(), player.getName());
return new Actor(sender instanceof BlockCommandSender ? "command_block" : "console", null, null);
} catch (RuntimeException ignored) {
return null; // Omit uncertain identity, never refuse the authorized action.
}
}
public void read(Actor actor, String abilityId, String key, String value) {
emit("config.read", actor, abilityId, key, null, value);
}
public void changed(Actor actor, String abilityId, String key, String before, String after) {
emit("config.changed", actor, abilityId, key, before, after);
}
private void emit(String type, Actor actor, String abilityId, String key, String before, String after) {
try {
var data = new LinkedHashMap<String, Object>();
data.put("actor_kind", actor.kind());
data.put("ability_id", abilityId);
data.put("key", key);
if (before != null) data.put("before", before);
data.put("after", after);
data.put("application", "future_casts_only");
data.put("outcome", "acknowledged");
var action = UUID.randomUUID();
var id = UUID.nameUUIDFromBytes(("fantasystick:ability-config:" + action + ":" + type)
.getBytes(StandardCharsets.UTF_8));
var context = actor.playerId() == null ? null
: new EventInput.Context(actor.playerId(), actor.playerName(), null, null, null, null);
sink.accept(new EventInput(id, action, clock.instant(), type, context, data));
} catch (RuntimeException ignored) { /* File logging cannot change an authorized configuration action. */ }
}
}
@@ -0,0 +1,54 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.ability.AbilityEngine;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
/** Feature-owned exact ability-refund outcomes; never an authorization or refund authority. */
public final class AbilityEventBridge {
public record Action(UUID actionId,EventInput.Context context,String ability,boolean learn,CharacterRecord before){
public Action{Objects.requireNonNull(actionId);Objects.requireNonNull(context);Objects.requireNonNull(ability);Objects.requireNonNull(before);}
}
private final Consumer<EventInput> sink;private final Clock clock;
public AbilityEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
public Action begin(ControlInput input,CharacterRecord before,String ability){return begin(input,before,ability,false);}
public Action begin(ControlInput input,CharacterRecord before,String ability,boolean learn){
var action=new Action(UUID.randomUUID(),context(input.player(),input.session(),before.name()),ability,learn,before);
emit("action.observed",action,"observed",null,null,null);return action;
}
public void refused(org.bukkit.entity.Player player,EquipmentSessions.Active active,CharacterRecord before,String ability,String reason){refused(player,active,before,ability,false,reason);}
public void refused(org.bukkit.entity.Player player,EquipmentSessions.Active active,CharacterRecord before,String ability,boolean learn,String reason){
var action=new Action(UUID.randomUUID(),context(player,active,before.name()),ability,learn,before);emit("action.observed",action,"observed",null,null,null);emit("action.resolved",action,"refused",reason,null,null);
}
public void failed(Action action,String reason){emit("action.resolved",action,"failed",reason,null,null);}
public void resolved(Action action,AbilityEngine.Mutation mutation){
if(mutation!=null&&mutation.status()==games.dmg.fantasystick.persistence.CharacterStorage.AbilityMutationStatus.APPLIED&&mutation.record()!=null){
emit("action.resolved",action,"accepted","acknowledged",mutation.record(),"acknowledged");
if(action.learn()){emit("ability.learned",action,"accepted","acknowledged",mutation.record(),"acknowledged");emit("ability.assigned",action,"accepted","acknowledged",mutation.record(),"acknowledged");}
else emit("ability.unlearned",action,"accepted","acknowledged",mutation.record(),"acknowledged");
}else emit("action.resolved",action,"refused",mutation==null?"unavailable":mutation.status().name().toLowerCase(java.util.Locale.ROOT),mutation==null?null:mutation.record(),null);
}
private void emit(String type,Action action,String outcome,String reason,CharacterRecord after,String nativePublication){
var before=action.before();var data=new LinkedHashMap<String,Object>();data.put("action_kind",action.learn()?"ability_learn":"ability_unlearn");data.put("ability_id",action.ability());data.put("expected_revision",before.revision().toString());data.put("before_revision",before.revision().toString());data.put("points_before",before.unspentPoints().toString());data.put("outcome",outcome);
var beforeCooldown=before.cooldowns().get(action.ability());if(beforeCooldown!=null)data.put("cooldown_before",beforeCooldown.toString());
if(after!=null){data.put("after_revision",after.revision().toString());data.put("points_after",after.unspentPoints().toString());var afterCooldown=after.cooldowns().get(action.ability());if(afterCooldown!=null)data.put("cooldown_after",afterCooldown.toString());if(action.learn()){data.put("ability_slot",after.loadout().indexOf(action.ability()));data.put("slot_index_base",0);}else{var cleared=new ArrayList<Integer>();for(int slot=0;slot<before.loadout().size();slot++)if(action.ability().equals(before.loadout().get(slot))&&(slot>=after.loadout().size()||!action.ability().equals(after.loadout().get(slot))))cleared.add(slot);data.put("cleared_slot_indexes",cleared);data.put("slot_index_base",0);}}
if(nativePublication!=null){data.put("native_publication",nativePublication);if(!action.learn())data.put("owned_cleanup","invalidation_acknowledged_close_attempted");}
if(reason!=null)data.put(type.startsWith("ability.")?"native_boundary":"reason",reason);
try{sink.accept(new EventInput(id(action.actionId(),type),action.actionId(),clock.instant(),type,action.context(),Map.copyOf(data)));}catch(RuntimeException ignored){}
}
private EventInput.Context context(org.bukkit.entity.Player player,EquipmentSessions.Active active,String characterName){return new EventInput.Context(player.getUniqueId(),player.getName(),active.character(),characterName,active.session(),location(player.getLocation()));}
private EventInput.Location location(Location location){return new EventInput.Location(location.getWorld().getUID(),location.getWorld().getName(),location.getX(),location.getY(),location.getZ(),clock.instant(),"exact");}
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:ability-mutation:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
}
@@ -0,0 +1,106 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
/** Stable feature-owned character-book outcomes; event delivery never authorizes or rolls back an open. */
public final class BookEventBridge {
public record Request(UUID actionId, EventInput.Context context, String actionKind, String viewKind,
int offset, BigInteger requestedRevision) {
public Request {
Objects.requireNonNull(actionId); Objects.requireNonNull(context); Objects.requireNonNull(actionKind);
Objects.requireNonNull(viewKind); Objects.requireNonNull(requestedRevision);
if (offset < -1) throw new IllegalArgumentException("Invalid book offset");
}
}
private final Consumer<EventInput> sink;
private final Clock clock;
public BookEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
}
public Request request(ControlInput input, CharacterRecord current, int offset) {
Objects.requireNonNull(input); Objects.requireNonNull(current);
var request = new Request(UUID.randomUUID(), context(input, current.name()),
offset == 0 ? "character_book_open" : "character_book_navigate", "active", offset,
current.revision());
emit("action.observed", request, "observed", null, null, null, null);
return request;
}
public void refused(Request request, String reason) {
emit("action.resolved", request, "refused", reason, null, null, null);
}
public void refused(org.bukkit.entity.Player player, games.dmg.fantasystick.session.EquipmentSessions.Active active,
CharacterRecord current, String actionKind, String reason) {
Objects.requireNonNull(player); Objects.requireNonNull(active); Objects.requireNonNull(current);
var request=new Request(UUID.randomUUID(),context(player,active,current.name()),actionKind,"active",-1,current.revision());
emit("action.observed",request,"observed",null,null,null,null);
refused(request,reason);
}
public void failed(Request request, String reason) {
emit("action.resolved", request, "failed", reason, null, null, null);
}
public void opened(Request request, CharacterRecord loaded, int presentedPages, int presentedVolumes) {
Objects.requireNonNull(loaded);
emit("action.resolved", request, "accepted", "acknowledged", loaded.revision(), presentedPages,
presentedVolumes);
emit(request.offset() == 0 ? "character_book.opened" : "character_book.navigated", request,
"accepted", "acknowledged", loaded.revision(), presentedPages, presentedVolumes);
}
private void emit(String type, Request request, String outcome, String reason, BigInteger loadedRevision,
Integer pages, Integer volumes) {
var data = new LinkedHashMap<String, Object>();
data.put("action_kind", request.actionKind()); data.put("view_kind", request.viewKind());
data.put("section", "character_inspection"); data.put("control_kind", "book"); data.put("control_slot", "0");
data.put("offset", request.offset()<0?"unknown":Integer.toString(request.offset()));
data.put("requested_revision", request.requestedRevision().toString()); data.put("outcome", outcome);
if (reason != null) data.put(type.startsWith("character_book.") ? "native_open" : "reason", reason);
if (loadedRevision != null) data.put("loaded_revision", loadedRevision.toString());
if (pages != null) data.put("presented_pages", pages);
if (volumes != null) data.put("presented_volumes", volumes);
try {
sink.accept(new EventInput(id(request.actionId(), type), request.actionId(), clock.instant(), type,
request.context(), Map.copyOf(data)));
} catch (RuntimeException ignored) {
// Observability cannot change book authorization, rendering or native opening.
}
}
private EventInput.Context context(ControlInput input, String characterName) {
return context(input.player(),input.session(),characterName);
}
private EventInput.Context context(org.bukkit.entity.Player player,
games.dmg.fantasystick.session.EquipmentSessions.Active active,
String characterName) {
return new EventInput.Context(player.getUniqueId(), player.getName(), active.character(),
characterName, active.session(), location(player.getLocation()));
}
private EventInput.Location location(Location location) {
return new EventInput.Location(location.getWorld().getUID(), location.getWorld().getName(), location.getX(),
location.getY(), location.getZ(), clock.instant(), "exact");
}
private static UUID id(UUID action, String type) {
return UUID.nameUUIDFromBytes(("fantasystick:book:" + action + ":" + type)
.getBytes(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,26 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.progression.CoinRules;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
/** Failure-isolated character coin initialization, final-death loss and configuration records. */
public final class CoinEventBridge {
private final Consumer<EventInput> sink;private final Clock clock;
public CoinEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
public void initialized(CharacterRecord record,String source,CoinRules rules){var operation=stable(record.id(),"coins-initialized");var data=new LinkedHashMap<String,Object>();data.put("operation_id",operation.toString());data.put("character_id",record.id().toString());data.put("character_name",record.name());data.put("source",source);data.put("configured_minimum",Long.toString(rules.startingMin()));data.put("configured_maximum",Long.toString(rules.startingMax()));data.put("balance",record.coins().toString());data.put("revision",record.revision().toString());data.put("outcome","acknowledged");emit(operation,"character.coins_initialized",data);}
public void lost(CharacterRecord before,CharacterRecord after,UUID death,Instant diedAt){var data=new LinkedHashMap<String,Object>();data.put("operation_id",death.toString());data.put("death_id",death.toString());data.put("character_id",after.id().toString());data.put("character_name",after.name());data.put("reason","player_attributed_final_death");data.put("balance_before",before.coins().toString());data.put("balance_after",after.coins().toString());data.put("death_bound_escrow",after.pvpCoinEscrow().toString());data.put("before_revision",before.revision().toString());data.put("after_revision",after.revision().toString());data.put("final_death_at",diedAt.toString());data.put("outcome","acknowledged");emit(death,"character.coins_lost",data);}
public void configRead(String actorKind,UUID player,String name,String key,String value){config("config.read",actorKind,player,name,key,null,value);}
public void configChanged(String actorKind,UUID player,String name,String key,String before,String after){config("config.changed",actorKind,player,name,key,before,after);}
private void config(String type,String actorKind,UUID player,String name,String key,String before,String after){var action=UUID.randomUUID();var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);if(player!=null)data.put("actor_player_id",player.toString());if(name!=null)data.put("actor_player_name",name);data.put("key",key);if(before!=null)data.put("before",before);data.put("after",after);data.put("application",key.startsWith("starting-")?"future_character_initialization":"future_mob_defeats");data.put("outcome","acknowledged");try{sink.accept(new EventInput(id(action,type),action,clock.instant(),type,player==null?null:new EventInput.Context(player,name,null,null,null,null),Map.copyOf(data)));}catch(RuntimeException ignored){}}
private void emit(UUID action,String type,Map<String,Object> data){try{sink.accept(new EventInput(id(action,type),action,clock.instant(),type,null,Map.copyOf(data)));}catch(RuntimeException ignored){}}
private static UUID stable(UUID character,String kind){return UUID.nameUUIDFromBytes(("fantasystick:coin:"+character+":"+kind).getBytes(StandardCharsets.UTF_8));}
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:coin:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
}
@@ -0,0 +1,39 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.CombatRewardRules;
import games.dmg.fantasystick.progression.ManagedEncounterOrigin;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.entity.Player;
/** Failure-isolated, single-owner recipient resolution for combat XP and coin shares. */
public final class CombatRewardEventBridge {
public record Capture(UUID targetId,String targetType,String experienceClass,String coinClass,
CombatRewardRules.KillPosition killPosition,ManagedEncounterOrigin managedOrigin,
String stage,Instant defeatedAt,java.util.List<UUID> directContributors,
String rewardUnitId,int rewardUnitCount,BigInteger experiencePool,BigInteger coinPool){
public Capture{Objects.requireNonNull(targetId);Objects.requireNonNull(targetType);Objects.requireNonNull(experienceClass);Objects.requireNonNull(coinClass);Objects.requireNonNull(stage);Objects.requireNonNull(defeatedAt);directContributors=java.util.List.copyOf(directContributors);Objects.requireNonNull(rewardUnitId);Objects.requireNonNull(experiencePool);Objects.requireNonNull(coinPool);if(rewardUnitCount<1||experiencePool.signum()<0||coinPool.signum()<0)throw new IllegalArgumentException("Invalid combat reward capture");}
}
private final Consumer<EventInput> sink;private final Clock clock;
public CombatRewardEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
public void resolved(UUID operation,UUID character,BigInteger allocatedExperience,BigInteger allocatedCoins,
CombatRewardRules.KillPosition restricted,CombatRewardRules rules,Capture capture,
CharacterRecord before,CharacterStorage.CombatAward award,EquipmentSessions.Active active,
Player player,String nativeSync,String feedback){
if(award==null||award.record().isEmpty()||before==null)return;var after=award.record().orElseThrow();var data=new LinkedHashMap<String,Object>();data.put("operation_id",operation.toString());data.put("reward_receipt",operation.toString());data.put("stage",capture.stage());data.put("recipient_character_id",character.toString());data.put("target_id",capture.targetId().toString());data.put("target_type",capture.targetType());data.put("experience_class",capture.experienceClass());data.put("coin_class",capture.coinClass());data.put("defeated_at",capture.defeatedAt().toString());data.put("direct_contributor_character_ids",capture.directContributors().stream().map(UUID::toString).sorted().toList());data.put("reward_unit_id",capture.rewardUnitId());data.put("reward_unit_count",capture.rewardUnitCount());data.put("experience_pool",capture.experiencePool().toString());data.put("coin_pool",capture.coinPool().toString());if(capture.killPosition()!=null)data.put("kill_location",position(capture.killPosition()));data.put("allocated_experience",allocatedExperience.toString());data.put("allocated_coins",allocatedCoins.toString());var appliedExperience=after.experience().subtract(before.experience()).max(BigInteger.ZERO);var appliedCoins=after.coins().subtract(before.coins()).max(BigInteger.ZERO);data.put("applied_experience",appliedExperience.toString());data.put("applied_coins",appliedCoins.toString());data.put("suppressed_experience",allocatedExperience.subtract(appliedExperience.min(allocatedExperience)).toString());data.put("suppressed_coins",allocatedCoins.subtract(appliedCoins.min(allocatedCoins)).toString());data.put("before_revision",before.revision().toString());data.put("after_revision",after.revision().toString());data.put("before_level",before.level());data.put("after_level",after.level());data.put("before_experience",before.experience().toString());data.put("after_experience",after.experience().toString());data.put("before_points",before.unspentPoints().toString());data.put("after_points",after.unspentPoints().toString());data.put("balance_before",before.coins().toString());data.put("balance_after",after.coins().toString());data.put("storage_status",award.status().name().toLowerCase(java.util.Locale.ROOT));data.put("levels_gained",award.levels());data.put("native_sync",nativeSync);data.put("feedback",feedback);if(capture.managedOrigin()!=null){data.put("resolution","managed_origin_exempt");data.put("managed_origin_kind",capture.managedOrigin().kind().name().toLowerCase(java.util.Locale.ROOT));data.put("managed_origin_source",capture.managedOrigin().source().toString());data.put("spacing_applicability","authenticated_managed_origin_exempt");}else if(restricted!=null){boolean blocked=award.status()==CharacterStorage.CombatAwardStatus.SPACING_BLOCKED;data.put("resolution",blocked?"ordinary_spacing_blocked":"ordinary_spacing_eligible");data.put("spacing_applicability","ordinary_restricted_kill");data.put("spacing_minimum",rules.restrictedSpacing().toPlainString());var prior=before.restrictedKills().get(restricted.world());if(prior!=null)data.put("ordinary_history_before",position(prior));var current=after.restrictedKills().get(restricted.world());if(current!=null)data.put("ordinary_history_after",position(current));}else{data.put("resolution","spacing_not_applicable");data.put("spacing_applicability","passive_or_fantasy_character");}data.put("loss_reason",loss(award.status()));var context=context(active,player,after.name());try{sink.accept(new EventInput(id(operation),operation,clock.instant(),"combat.reward_resolved",context,Map.copyOf(data)));}catch(RuntimeException ignored){}
}
private EventInput.Context context(EquipmentSessions.Active active,Player player,String characterName){if(active==null||player==null)return null;var location=player.getLocation();if(location==null||location.getWorld()==null)return new EventInput.Context(active.player(),player.getName(),active.character(),characterName,active.session(),null);return new EventInput.Context(active.player(),player.getName(),active.character(),characterName,active.session(),new EventInput.Location(location.getWorld().getUID(),location.getWorld().getName(),location.getX(),location.getY(),location.getZ(),clock.instant(),"exact"));}
private static Map<String,Object> position(CombatRewardRules.KillPosition value){return Map.of("world_id",value.world().toString(),"x",value.x().toPlainString(),"y",value.y().toPlainString(),"z",value.z().toPlainString());}
private static String loss(CharacterStorage.CombatAwardStatus status){return switch(status){case SPACING_BLOCKED->"restricted_kill_spacing";case AT_CAP->"maximum_level";case UNCHANGED->"zero_experience_and_coins";case DUPLICATE->"duplicate_receipt";case UNAVAILABLE->"recipient_unavailable";case STALE->"stale_retry";case APPLIED->"none";};}
private static UUID id(UUID operation){return UUID.nameUUIDFromBytes(("fantasystick:combat-reward:"+operation).getBytes(StandardCharsets.UTF_8));}
}
@@ -30,7 +30,7 @@ public final class DownedEventBridge {
if (episode.decision() != null && "revive".equals(episode.decision().source())) {
abilityCast(episode, episode.decision());
}
resolved(episode);
if (!"phoenix_down".equals(decisionSource(episode)) || episode.published()) resolved(episode);
if (episode.decision() != null && "revive".equals(episode.decision().source())) {
abilityResolved(episode);
}
@@ -82,6 +82,12 @@ public final class DownedEventBridge {
if (decision.cooldownUntil() != null) data.put("cooldown_until", decision.cooldownUntil());
if (decision.finalRange() != null) data.put("final_range", decision.finalRange());
if (decision.lineOfSight() != null) data.put("line_of_sight", decision.lineOfSight());
if (decision.phoenix() != null) {
var phoenix=decision.phoenix();data.put("selection_entry_point",phoenix.entryPoint());data.put("item_id","phoenix-down");
data.put("quantity_before",phoenix.quantityBefore().toString());data.put("quantity_after",phoenix.quantityAfter().toString());
data.put("health_before",phoenix.healthBefore().toPlainString());data.put("health_after",phoenix.healthAfter().toPlainString());data.put("maximum_health",phoenix.maximum().toPlainString());
data.put("item_use_operation_id",decision.actionId().toString());
}
}
emit(stable(episode.id(), "resolved"), episode.outcomeOperation(), episode.resolvedAt(),
"character.downed_resolved", actor(decision), data);
@@ -107,16 +113,32 @@ public final class DownedEventBridge {
"character.died", data);
}
/** Physical return is a separate observed native result, never proof of final-death commitment or Tail delivery. */
public void returnAttempted(DownedEpisode episode, String outcome) {
if (episode.outcome() != DownedEpisode.Outcome.FINAL_DEATH) return;
var data = common(episode);
data.put("action_kind", "final_death_return");
data.put("death_id", episode.id().toString());
data.put("effective_death_time", episode.resolvedAt());
data.put("stage", "native_return_attempt");
data.put("outcome", outcome);
emit(stable(episode.id(), "return"), episode.outcomeOperation(), clock.instant(), "action.resolved", data);
}
public void outcomePublished(DownedEpisode episode) {
if(episode.decision()!=null&&"phoenix_down".equals(episode.decision().source()))resolved(episode);
var data = common(episode);
data.put("action_kind", episode.outcome() == DownedEpisode.Outcome.REVIVED
? "downed_revival_native_application" : "final_death_tail_delivery");
? episode.decision()!=null&&"phoenix_down".equals(episode.decision().source())?"phoenix_down_native_application":"downed_revival_native_application" : "final_death_tail_delivery");
data.put("stage", "native_effect_acknowledged");
data.put("outcome", "completed");
emit(stable(episode.id(), "published"), episode.outcomeOperation(), episode.resolvedAt(),
"action.resolved", data);
if(episode.outcome()==DownedEpisode.Outcome.REVIVED&&episode.protectionUntil()!=null&&episode.decision()!=null&&"revive".equals(episode.decision().source()))protectionApplied(episode);
}
private void protectionApplied(DownedEpisode episode){var data=common(episode);data.put("effect_id","revive_downed_invulnerability");data.put("application_id",stable(episode.id(),"revive-protection").toString());data.put("stage","native_application_acknowledged");data.put("outcome","applied");data.put("protection_until",episode.protectionUntil());data.put("end_policy","persisted_absolute_deadline");emit(stable(episode.id(),"protection-applied"),episode.outcomeOperation(),episode.resolvedAt(),"effect.applied",data);}
public void prevented(DownedEpisode episode, String reason, double amount) {
if (!Double.isFinite(amount) || amount < 0) return;
var now = clock.instant();
@@ -132,7 +154,7 @@ public final class DownedEventBridge {
public void protectionEnded(DownedEpisode episode) {
if (episode.outcome() != DownedEpisode.Outcome.REVIVED || episode.protectionUntil() == null) return;
var data = common(episode);
data.put("effect_id", "revive_downed_invulnerability");
data.put("effect_id", episode.decision()!=null&&"phoenix_down".equals(episode.decision().source())?"phoenix_down_invulnerability":"revive_downed_invulnerability");
data.put("stage", "expiry");
data.put("outcome", "expired");
emit(stable(episode.id(), "protection-ended"), episode.outcomeOperation(), episode.protectionUntil(),
@@ -197,7 +219,7 @@ public final class DownedEventBridge {
data.put("key", Objects.requireNonNull(key));
if (before != null) data.put("before", before);
data.put("after", Objects.requireNonNull(after));
data.put("application", "future_downed_outcomes");
data.put("application", key.equals("rare-phoenix-down-invulnerability-seconds")?"future_phoenix_rescues":key.startsWith("rare-price-phoenix-down")||key.startsWith("rare-stock-phoenix-down")?"next_rare_restock":"future_downed_outcomes");
data.put("outcome", "acknowledged");
EventInput.Context context=player==null?null:new EventInput.Context(player,playerName,null,null,null,null);
emit(UUID.randomUUID(), action, now, type, context, data);
@@ -17,15 +17,20 @@ import org.bukkit.entity.Player;
import org.bukkit.event.Cancellable;
import org.bukkit.event.entity.EntityPickupItemEvent;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryDragEvent;
import org.bukkit.event.player.PlayerDropItemEvent;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerItemHeldEvent;
import org.bukkit.event.player.PlayerSwapHandItemsEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.event.player.PlayerItemConsumeEvent;
import org.bukkit.event.vehicle.VehicleEnterEvent;
import org.bukkit.event.vehicle.VehicleExitEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
import org.bukkit.NamespacedKey;
import org.bukkit.persistence.PersistentDataType;
/** Captures immutable generic native action context without changing event cancellation or gameplay. */
public final class EventCaptureListener implements Listener {
@@ -34,6 +39,12 @@ public final class EventCaptureListener implements Listener {
private final EquipmentSessions sessions;
private final Clock clock;
private final Map<Object, Captured> captured = java.util.Collections.synchronizedMap(new WeakHashMap<>());
private java.util.function.Predicate<org.bukkit.event.entity.EntityDamageEvent> ownedDamage = ignored -> false;
/** Feature owners replace the generic incoming view with one richer application record. */
public void excludeOwnedDamage(java.util.function.Predicate<org.bukkit.event.entity.EntityDamageEvent> owner) {
ownedDamage = ownedDamage.or(Objects.requireNonNull(owner));
}
public EventCaptureListener(LocalEventLog events, EquipmentSessions sessions, Clock clock) {
this.events = Objects.requireNonNull(events);
@@ -53,7 +64,10 @@ public final class EventCaptureListener implements Listener {
var details = new LinkedHashMap<String, Object>();
details.put("entry_point", "PlayerInteractEvent");
details.put("native_action", event.getAction().name());
details.put("hand", event.getHand().name());
details.put("initially_cancelled", event.isCancelled());
details.putAll(item(event.getItem()));
downing(event.getPlayer(), details, event.isCancelled());
if (event.getClickedBlock() != null) {details.put("target_type", event.getClickedBlock().getType().name());var target=event.getClickedBlock().getLocation();if(target!=null&&target.getWorld()!=null)details.put("target_location",locationMap(target));}
var value = new Captured(UUID.randomUUID(), snapshot, kind, Map.copyOf(details));
captured.put(event, value);
@@ -63,17 +77,61 @@ public final class EventCaptureListener implements Listener {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
public void resolve(PlayerInteractEvent event) { resolveCaptured(event, event); }
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(PlayerInteractEntityEvent event){if(event.getHand()!=EquipmentSlot.HAND)return;capture(event,event.getPlayer(),"interact_entity",entity(event.getRightClicked(),"PlayerInteractEntityEvent"),event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(PlayerInteractEntityEvent event){if(event.getHand()!=EquipmentSlot.HAND)return;var details=new LinkedHashMap<>(entity(event.getRightClicked(),"PlayerInteractEntityEvent"));details.put("hand",event.getHand().name());capture(event,event.getPlayer(),"interact_entity",details,event);}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(PlayerInteractEntityEvent event){resolveCaptured(event,event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(InventoryClickEvent event){if(event.getWhoClicked() instanceof Player player)capture(event,player,"inventory_click",Map.of("entry_point","InventoryClickEvent","slot",event.getRawSlot(),"click",event.getClick().name(),"inventory_action",event.getAction().name()),event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(InventoryClickEvent event){if(event.getWhoClicked() instanceof Player player){var details=new LinkedHashMap<String,Object>();details.put("entry_point",event.getClass().getSimpleName());details.put("slot",event.getRawSlot());if(event.getView()!=null)details.put("container",event.getView().getType().name());details.put("click",event.getClick().name());details.put("inventory_action",event.getAction().name());details.putAll(item(event.getCurrentItem()));capture(event,player,"inventory_click",details,event);}}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(InventoryClickEvent event){resolveCaptured(event,event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(PlayerDropItemEvent event){capture(event,event.getPlayer(),"drop_item",Map.of("entry_point","PlayerDropItemEvent","item_type",event.getItemDrop().getItemStack().getType().name()),event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(InventoryDragEvent event){if(event.getWhoClicked() instanceof Player player){var details=new LinkedHashMap<String,Object>();details.put("entry_point","InventoryDragEvent");if(event.getView()!=null)details.put("container",event.getView().getType().name());details.put("slots",event.getNewItems().keySet().stream().sorted().toList());details.putAll(item(event.getOldCursor()));capture(event,player,"inventory_drag",details,event);}}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(InventoryDragEvent event){resolveCaptured(event,event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(PlayerDropItemEvent event){var details=new LinkedHashMap<String,Object>();details.put("entry_point","PlayerDropItemEvent");details.putAll(item(event.getItemDrop().getItemStack()));capture(event,event.getPlayer(),"drop_item",details,event);}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(PlayerDropItemEvent event){resolveCaptured(event,event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(EntityPickupItemEvent event){if(event.getEntity() instanceof Player player)capture(event,player,"pickup_item",Map.of("entry_point","EntityPickupItemEvent","item_type",event.getItem().getItemStack().getType().name(),"remaining",event.getRemaining()),event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(EntityPickupItemEvent event){if(event.getEntity() instanceof Player player){var details=new LinkedHashMap<String,Object>();details.put("entry_point","EntityPickupItemEvent");details.put("remaining",event.getRemaining());details.putAll(item(event.getItem().getItemStack()));capture(event,player,"pickup_item",details,event);}}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(EntityPickupItemEvent event){resolveCaptured(event,event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(PlayerItemConsumeEvent event){var details=new LinkedHashMap<String,Object>();details.put("entry_point","PlayerItemConsumeEvent");details.putAll(item(event.getItem()));capture(event,event.getPlayer(),"consume_item",details,event);}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(PlayerItemConsumeEvent event){resolveCaptured(event,event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent event){capture(event,event.getPlayer(),"recipe_autofill",Map.of("entry_point","PlayerRecipeBookClickEvent","recipe",event.getRecipe().asString()),event);}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent event){resolveCaptured(event,event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(org.bukkit.event.player.PlayerArmorStandManipulateEvent event){var details=new LinkedHashMap<String,Object>();details.put("entry_point","PlayerArmorStandManipulateEvent");details.put("target_id",event.getRightClicked().getUniqueId().toString());details.putAll(item(event.getPlayerItem()));capture(event,event.getPlayer(),"armor_stand",details,event);}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(org.bukkit.event.player.PlayerArmorStandManipulateEvent event){resolveCaptured(event,event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(org.bukkit.event.block.BlockPlaceEvent event){var details=new LinkedHashMap<String,Object>();details.put("entry_point","BlockPlaceEvent");details.putAll(item(event.getItemInHand()));capture(event,event.getPlayer(),"place_item",details,event);}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(org.bukkit.event.block.BlockPlaceEvent event){resolveCaptured(event,event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(com.destroystokyo.paper.event.player.PlayerLaunchProjectileEvent event){var details=new LinkedHashMap<String,Object>();details.put("entry_point","PlayerLaunchProjectileEvent");details.putAll(item(event.getItemStack()));capture(event,event.getPlayer(),"launch_projectile",details,event);}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(com.destroystokyo.paper.event.player.PlayerLaunchProjectileEvent event){resolveCaptured(event,event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(org.bukkit.event.entity.EntityShootBowEvent event){if(event.getEntity() instanceof Player player){var details=new LinkedHashMap<String,Object>();details.put("entry_point","EntityShootBowEvent");details.putAll(item(event.getBow()));capture(event,player,"shoot_bow",details,event);}}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(org.bukkit.event.entity.EntityShootBowEvent event){resolveCaptured(event,event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(org.bukkit.event.block.BlockDispenseArmorEvent event){if(event.getTargetEntity() instanceof Player player){var details=new LinkedHashMap<String,Object>();details.put("entry_point","BlockDispenseArmorEvent");details.putAll(item(event.getItem()));capture(event,player,"dispense_armor",details,event);}}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(org.bukkit.event.block.BlockDispenseArmorEvent event){resolveCaptured(event,event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(io.papermc.paper.event.player.PrePlayerAttackEntityEvent event){var details=new LinkedHashMap<String,Object>();details.put("entry_point","PrePlayerAttackEntityEvent");details.put("target_id",event.getAttacked().getUniqueId().toString());details.put("will_attack",event.willAttack());details.putAll(item(event.getPlayer().getInventory().getItemInMainHand()));capture(event,event.getPlayer(),"attack_entity",details,event);}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(io.papermc.paper.event.player.PrePlayerAttackEntityEvent event){resolveCaptured(event,event);}
/** Observe the final accepted native damage event once; no damage or health mutation occurs here. */
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)
public void acceptedDamage(org.bukkit.event.entity.EntityDamageEvent event) {
if (!(event.getEntity() instanceof Player player) || !Double.isFinite(event.getFinalDamage())
|| event.getFinalDamage() <= 0) return;
try { if (ownedDamage.test(event)) return; }
catch (RuntimeException ignored) { /* Observation failure cannot change native damage. */ }
var snapshot = context(player).orElse(null);
if (snapshot == null) return;
var details = new LinkedHashMap<String, Object>();
details.put("target_player_id", player.getUniqueId().toString());
details.put("target_character_id", snapshot.characterId().toString());
details.put("cause", event.getCause().name());
details.put("amount", Double.toString(event.getFinalDamage()));
details.put("outcome", "accepted_native_event");
details.put("cancelled", false);
if (event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent by) {
details.put("source_entity_id", by.getDamager().getUniqueId().toString());
details.put("source_entity_type", by.getDamager().getType().name());
}
try {
var operation = UUID.randomUUID();
events.emit(new EventInput(operation, operation, clock.instant(), "combat.damage", snapshot, details));
} catch (RuntimeException ignored) { }
}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(PlayerItemHeldEvent event){capture(event,event.getPlayer(),"held_slot",Map.of("entry_point","PlayerItemHeldEvent","previous_slot",event.getPreviousSlot(),"new_slot",event.getNewSlot()),event);}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(PlayerItemHeldEvent event){resolveCaptured(event,event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(PlayerSwapHandItemsEvent event){capture(event,event.getPlayer(),"swap_hands",Map.of("entry_point","PlayerSwapHandItemsEvent"),event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(PlayerSwapHandItemsEvent event){var details=new LinkedHashMap<String,Object>();details.put("entry_point","PlayerSwapHandItemsEvent");details.putAll(item(event.getOffHandItem()));capture(event,event.getPlayer(),"swap_hands",details,event);}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(PlayerSwapHandItemsEvent event){resolveCaptured(event,event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(PlayerTeleportEvent event){var details=new LinkedHashMap<String,Object>();details.put("entry_point","PlayerTeleportEvent");details.put("cause",event.getCause().name());details.put("from_location",locationMap(event.getFrom()));if(event.getTo()!=null)details.put("to_location",locationMap(event.getTo()));capture(event,event.getPlayer(),"teleport",details,event);}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(PlayerTeleportEvent event){resolveCaptured(event,event);}
@@ -83,14 +141,39 @@ public final class EventCaptureListener implements Listener {
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(VehicleExitEvent event){resolveCaptured(event,event);}
private Map<String,Object> entity(Entity entity,String entry){var details=new LinkedHashMap<String,Object>();details.put("entry_point",entry);details.put("target_id",entity.getUniqueId().toString());details.put("target_type",entity.getType().name());var target=entity.getLocation();if(target!=null&&target.getWorld()!=null)details.put("target_location",locationMap(target));return details;}
private void capture(Object event,Player player,String kind,Map<String,Object> details,Cancellable cancellation){var snapshot=context(player).orElse(null);if(snapshot==null)return;var copied=new LinkedHashMap<>(details);copied.put("initially_cancelled",cancellation.isCancelled());var value=new Captured(UUID.randomUUID(),snapshot,kind,Map.copyOf(copied));captured.put(event,value);emit("action.observed",value,"observed",cancellation.isCancelled());}
private void capture(Object event,Player player,String kind,Map<String,Object> details,Cancellable cancellation){var snapshot=context(player).orElse(null);if(snapshot==null)return;var copied=new LinkedHashMap<>(details);copied.put("initially_cancelled",cancellation.isCancelled());downing(player,copied,cancellation.isCancelled());var value=new Captured(UUID.randomUUID(),snapshot,kind,Map.copyOf(copied));captured.put(event,value);emit("action.observed",value,"observed",cancellation.isCancelled());}
private void resolveCaptured(Object event,Cancellable cancellation){var value=captured.remove(event);if(value!=null)emit("action.resolved",value,cancellation.isCancelled()?"cancelled":"accepted",cancellation.isCancelled());}
private void downing(Player player,Map<String,Object> details,boolean initiallyCancelled){
var found=sessions.downed(player.getUniqueId());
if(found!=null&&found.isPresent()){details.put("downing_id",found.orElseThrow().id().toString());details.put("guard_reason","downed");return;}
var kind=details.get("item_kind");
if(kind!=null&&!kind.equals("ordinary")&&!kind.equals("empty")){details.put("guard_reason","protected_item");return;}
details.put("guard_reason",initiallyCancelled?"prior_cancellation":"none");
}
private static Map<String,Object> item(ItemStack item){
var details=new LinkedHashMap<String,Object>();
if(item==null||item.getType().isAir()){details.put("item_kind","empty");return details;}
details.put("item_type",item.getType().name());
if(!item.hasItemMeta()){details.put("item_kind","ordinary");return details;}
var data=item.getItemMeta().getPersistentDataContainer();var keys=data.getKeys().stream().filter(key->key.getNamespace().equals("fantasystick")).map(NamespacedKey::getKey).collect(java.util.stream.Collectors.toSet());
String kind=keys.contains("sheet_version")?"character_sheet":keys.contains("storage_version")?"storage_box":keys.contains("fishing_quest")?"quest_fishing_rod":keys.contains("control_version")?"control":keys.contains("character_gear")?"character_gear":keys.contains("tail_version")?"tail":"ordinary";
details.put("item_kind",kind);
copy(data,details,"control_kind","control_kind");copy(data,details,"control_owner_id","item_owner_id");copy(data,details,"control_session_id","item_session_id");copy(data,details,"storage_owner_id","item_owner_id");copy(data,details,"storage_session_id","item_session_id");copy(data,details,"character_gear_owner","item_owner_id");copy(data,details,"character_gear_session","item_session_id");copy(data,details,"character_gear_slot","gear_slot");copy(data,details,"character_gear_tier","gear_tier");copy(data,details,"character_id","item_character_id");copy(data,details,"sheet_generation","sheet_generation");copy(data,details,"death_id","death_id");copy(data,details,"fishing_quest","quest_id");copy(data,details,"fishing_site","site_id");
return details;
}
private static void copy(org.bukkit.persistence.PersistentDataContainer data,Map<String,Object> details,String key,String output){var value=data.get(new NamespacedKey("fantasystick",key),PersistentDataType.STRING);if(value!=null&&value.length()<=128)details.put(output,value);}
private void emit(String type, Captured capturedAction, String outcome, boolean cancelled) {
var data = new LinkedHashMap<>(capturedAction.details());
data.put("action_kind", capturedAction.kind());
data.put("outcome", outcome);
data.put("cancelled", cancelled);
if (type.equals("action.resolved")) {
if (!cancelled) data.put("guard_reason", "accepted");
else if (Objects.equals(data.get("guard_reason"), "none")) data.put("guard_reason", "native_cancellation");
}
events.emit(new EventInput(UUID.randomUUID(), capturedAction.action(), clock.instant(), type,
capturedAction.context(), data));
}
@@ -337,7 +337,11 @@ public final class LocalEventLog implements AutoCloseable {
data.put("session_id", context.sessionId().toString());
}
if (context.location() != null) data.put("location", location(context.location()));
} else data.put("actor_kind", "system");
} else {
var claimed = data.get("actor_kind");
data.put("actor_kind", "console".equals(claimed) || "command_block".equals(claimed)
? claimed : "system");
}
var envelope = new LinkedHashMap<String, Object>();
envelope.put("specversion", "1.0");
envelope.put("id", input.id().toString());
@@ -0,0 +1,88 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.presentation.CharacterPresentation;
import io.papermc.paper.event.player.AsyncChatEvent;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerCommandPreprocessEvent;
/** Observes supported character messages; no recipient/delivery claims or signed-body changes. */
public final class MessageEventBridge implements Listener {
private static final Set<String> PRIVATE = Set.of("msg", "message", "tell", "w", "whisper", "pm", "reply", "r");
private final Function<UUID, CharacterPresentation.View> views;
private final SessionEventBridge sessions;
private final java.util.function.Consumer<EventInput> sink;
private final Clock clock;
public MessageEventBridge(Function<UUID, CharacterPresentation.View> views, SessionEventBridge sessions,
java.util.function.Consumer<EventInput> sink, Clock clock) {
this.views=Objects.requireNonNull(views);this.sessions=Objects.requireNonNull(sessions);
this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);
}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false)
public void chat(AsyncChatEvent event) {
try {
var view=views.apply(event.getPlayer().getUniqueId());
if(view==null)return;
var context=sessions.lastKnown(view.holder(),view.session(),view.name());
if(context==null)return;
var data=new LinkedHashMap<String,Object>();
data.put("channel","public");
data.put("message",event.signedMessage().message());
data.put("asynchronous",event.isAsynchronous());
data.put("cancelled",event.isCancelled());
data.put("outcome",event.isCancelled()?"cancelled":"observed");
data.put("delivery","unverified");
data.put("location_quality","last_native_session_capture");
emit("character.chat",context,data);
} catch(RuntimeException ignored) { }
}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false)
public void privateMessage(PlayerCommandPreprocessEvent event) {
try {
String command=event.getMessage();
if(command==null||!command.startsWith("/"))return;
int end=command.indexOf(' ');
String label=(end<0?command.substring(1):command.substring(1,end)).toLowerCase(Locale.ROOT);
label=label.substring(label.lastIndexOf(':')+1);
if(!PRIVATE.contains(label))return;
var view=views.apply(event.getPlayer().getUniqueId());
if(view==null)return;
var cached=sessions.lastKnown(view.holder(),view.session(),view.name());
if(cached==null)return;
var position=event.getPlayer().getLocation();
var context=new EventInput.Context(cached.playerId(),cached.playerName(),view.character(),view.name(),view.session(),
new EventInput.Location(position.getWorld().getUID(),position.getWorld().getName(),
position.getX(),position.getY(),position.getZ(),clock.instant(),"native_capture"));
boolean reply=label.equals("reply")||label.equals("r");
String remainder=end<0?"":command.substring(end+1);
var data=new LinkedHashMap<String,Object>();
data.put("command",label);data.put("reply",reply);
if(reply)data.put("message",remainder);
else {
int separator=remainder.indexOf(' ');
if(!remainder.isEmpty())data.put("recipient_token",separator<0?remainder:remainder.substring(0,separator));
data.put("message",separator<0?"":remainder.substring(separator+1));
}
data.put("cancelled",event.isCancelled());
data.put("outcome",event.isCancelled()?"cancelled":"observed");
data.put("delivery","unverified");
emit("character.private_message",context,data);
} catch(RuntimeException ignored) { }
}
private void emit(String type,EventInput.Context context,java.util.Map<String,Object> data) {
var action=UUID.randomUUID();
sink.accept(new EventInput(action,action,clock.instant(),type,context,data));
}
}
@@ -0,0 +1,52 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.party.CharacterParty;
import games.dmg.fantasystick.party.CharacterPartyDirectory;
import games.dmg.fantasystick.party.PartyRegistrar;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.entity.Player;
/** Failure-isolated Registrar and persistent character-party mutation records. */
public final class PartyEventBridge {
public record Action(UUID id,EventInput.Context context,PartyRegistrar registrar,String kind,UUID target,EventInput.Context targetContext,UUID invitation,
CharacterParty before,BigInteger charge,String reason){
public Action{Objects.requireNonNull(id);Objects.requireNonNull(context);Objects.requireNonNull(registrar);Objects.requireNonNull(kind);if(charge==null)charge=BigInteger.ZERO;if(reason==null)reason="requested";}
public Action(UUID id,EventInput.Context context,PartyRegistrar registrar,String kind,UUID target,UUID invitation,CharacterParty before,BigInteger charge){this(id,context,registrar,kind,target,null,invitation,before,charge,"requested");}
}
private final Consumer<EventInput> sink;private final Clock clock;
public PartyEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
public Action begin(Player player,games.dmg.fantasystick.session.EquipmentSessions.Active active,String characterName,PartyRegistrar registrar,String kind,UUID target,EventInput.Context targetContext,UUID invitation,CharacterParty before,BigInteger charge,UUID operation,String reason){var action=new Action(operation==null?stableAction(active.character(),kind,target,invitation,before):operation,context(player,active,characterName),registrar,kind,target,targetContext,invitation,before,charge,reason);emitAction("action.observed",action,"observed",null,null,null);return action;}
public void failed(Action action,String reason){emitAction("action.resolved",action,"failed",reason,null,null);}
public void refused(Action action,String reason){emitAction("action.resolved",action,"refused",reason,null,null);}
public void resolved(Action action,CharacterPartyDirectory.Result result){if(result==null||result.status()!=CharacterPartyDirectory.Status.APPLIED){refused(action,result==null?"unavailable":result.status().name().toLowerCase(java.util.Locale.ROOT));return;}resolved(action,result.party().orElse(null),result.invitation().orElse(null));}
public void resolved(Action action,games.dmg.fantasystick.party.PartyManagement.Outcome outcome){if(outcome==null||outcome.status()!=games.dmg.fantasystick.party.PartyManagement.Status.APPLIED){refused(action,outcome==null?"unavailable":outcome.status().name().toLowerCase(java.util.Locale.ROOT));return;}var after=outcome.party().orElse(null);CharacterPartyDirectory.Offer offer=null;if(after!=null){if("invite".equals(action.kind()))offer=after.invitations().stream().filter(value->value.character().equals(action.target())&&(action.before()==null||action.before().invitations().stream().noneMatch(old->old.id().equals(value.id())))).findFirst().map(value->new CharacterPartyDirectory.Offer(value.id(),after.id(),value.character())).orElse(null);else if(action.invitation()!=null)offer=new CharacterPartyDirectory.Offer(action.invitation(),after.id(),action.target()==null?action.context().characterId():action.target());}resolved(action,after,offer);}
private void resolved(Action action,CharacterParty after,CharacterPartyDirectory.Offer offer){emitAction("action.resolved",action,"accepted","acknowledged",after,offer);var businessAfter="disband".equals(action.kind())||"leader_leave".equals(action.kind())?null:after;emitAction(type(action.kind(),action.before(),businessAfter),action,"accepted","acknowledged",businessAfter,offer);}
public void registrarPlaced(String actorKind,UUID actorPlayer,String actorName,PartyRegistrar registrar,UUID entity,boolean loaded){registrar("party_registrar.placed",actorKind,actorPlayer,actorName,registrar,entity,loaded,"registered_and_reconciled");}
public void registrarRemoved(String actorKind,UUID actorPlayer,String actorName,PartyRegistrar registrar,UUID entity,boolean loaded){registrar("party_registrar.removed",actorKind,actorPlayer,actorName,registrar,entity,loaded,"registration_removed");}
public void finalDefeat(UUID character,CharacterParty before,CharacterPartyDirectory.Defeat outcome){if(before==null||outcome==CharacterPartyDirectory.Defeat.UNCHANGED)return;var action=stable(before.id(),character,before.revision(),"final-death");var data=partyData(before,null);data.put("reason","final_death");data.put("subject_character_id",character.toString());data.put("popup_delivery",outcome==CharacterPartyDirectory.Defeat.PARTY_DISBANDED?"active_former_member_delivery_queued":"not_applicable");data.put("outcome","acknowledged");data.put("operation_id",action.toString());emit(id(action,outcome==CharacterPartyDirectory.Defeat.PARTY_DISBANDED?"party.disbanded":"party.left"),action,clock.instant(),outcome==CharacterPartyDirectory.Defeat.PARTY_DISBANDED?"party.disbanded":"party.left",null,data);}
public void configRead(String actorKind,UUID player,String name,String key,String value){config("config.read",actorKind,player,name,key,null,value);}
public void configChanged(String actorKind,UUID player,String name,String key,String before,String after){config("config.changed",actorKind,player,name,key,before,after);}
private void emitAction(String type,Action action,String outcome,String reason,CharacterParty after,CharacterPartyDirectory.Offer invitation){var data=partyData(action.before(),after);data.put("action_kind","party_"+action.kind());data.put("operation_id",action.id().toString());data.put("registrar_id",action.registrar().id().toString());data.put("registrar_location",registrarLocation(action.registrar()));data.put("actor_character_id",action.context().characterId().toString());data.put("actor_player_id",action.context().playerId().toString());data.put("actor_session_id",action.context().sessionId().toString());if(action.target()!=null)data.put("target_character_id",action.target().toString());if(action.targetContext()!=null){data.put("target_player_id",action.targetContext().playerId().toString());data.put("target_session_id",action.targetContext().sessionId().toString());if(action.targetContext().playerName()!=null)data.put("target_player_name",action.targetContext().playerName());if(action.targetContext().location()!=null)data.put("target_location",locationData(action.targetContext().location()));}if(action.invitation()!=null)data.put("invitation_id",action.invitation().toString());else if(invitation!=null)data.put("invitation_id",invitation.id().toString());if(action.charge().signum()>0){data.put("charged_coins",action.charge().toString());data.put("charge_receipt",action.id().toString());}data.put("reason",action.reason());data.put("popup_delivery",type.equals("party.disbanded")?"active_former_member_delivery_queued":"not_applicable");data.put("outcome",outcome);if(reason!=null)data.put(type.startsWith("party.")?"mutation_boundary":"reason",reason);emit(id(action.id(),type),action.id(),clock.instant(),type,action.context(),data);}
private static String type(String kind,CharacterParty before,CharacterParty after){return switch(kind){case "create"->"party.created";case "invite"->"party.invitation_created";case "cancel"->"party.invitation_cancelled";case "decline"->"party.invitation_declined";case "accept"->"party.joined";case "kick"->"party.member_kicked";case "transfer"->"party.leadership_transferred";case "disband","leader_leave"->"party.disbanded";case "leave"->before!=null&&before.leader().equals(before.members().stream().filter(id->after==null||!after.members().contains(id)).findFirst().orElse(null))?"party.disbanded":"party.left";default->"party.mutated";};}
private static LinkedHashMap<String,Object> partyData(CharacterParty before,CharacterParty after){var data=new LinkedHashMap<String,Object>();var party=after==null?before:after;if(party!=null){data.put("party_id",party.id().toString());data.put("party_display_name",party.displayName());data.put("party_canonical_name",party.canonicalName());}if(before!=null){data.put("before_revision",before.revision().toString());data.put("before_leader_character_id",before.leader().toString());data.put("before_member_character_ids",before.members().stream().map(UUID::toString).sorted().toList());}if(after!=null){data.put("after_revision",after.revision().toString());data.put("after_leader_character_id",after.leader().toString());data.put("after_member_character_ids",after.members().stream().map(UUID::toString).sorted().toList());}return data;}
private void registrar(String type,String actorKind,UUID actorPlayer,String actorName,PartyRegistrar registrar,UUID entity,boolean loaded,String outcome){var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);if(actorPlayer!=null)data.put("actor_player_id",actorPlayer.toString());if(actorName!=null)data.put("actor_player_name",actorName);data.put("registrar_id",registrar.id().toString());data.put("registrar_name",registrar.name());data.put("registrar_revision","0");data.put("operation_id",registrar.id().toString());data.put("registered_location",registrarLocation(registrar));if(entity!=null)data.put("entity_id",entity.toString());data.put("entity_status",loaded?"loaded":"unloaded");data.put("outcome",outcome);emit(id(registrar.id(),type),registrar.id(),clock.instant(),type,actorPlayer==null?null:new EventInput.Context(actorPlayer,actorName,null,null,null,null),data);}
private void config(String type,String actorKind,UUID player,String name,String key,String before,String after){var action=UUID.randomUUID();var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);if(player!=null)data.put("actor_player_id",player.toString());if(name!=null)data.put("actor_player_name",name);data.put("key",key);if(before!=null)data.put("before",before);data.put("after",after);data.put("application","future_party_transactions");data.put("outcome","acknowledged");emit(id(action,type),action,clock.instant(),type,player==null?null:new EventInput.Context(player,name,null,null,null,null),data);}
private static Map<String,Object> locationData(EventInput.Location value){return 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 static Map<String,Object> registrarLocation(PartyRegistrar value){return Map.of("world_id",value.world().toString(),"x",Double.toString(value.x()),"y",Double.toString(value.y()),"z",Double.toString(value.z()),"yaw",Float.toString(value.yaw()),"pitch",Float.toString(value.pitch()),"quality","registered_exact");}
public 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()));}
private EventInput.Location location(Location value){return new EventInput.Location(value.getWorld().getUID(),value.getWorld().getName(),value.getX(),value.getY(),value.getZ(),clock.instant(),"exact");}
private static UUID stableAction(UUID actor,String kind,UUID target,UUID invitation,CharacterParty before){String party=before==null?"none":before.id().toString(),revision=before==null?"none":before.revision().toString();return UUID.nameUUIDFromBytes(("fantasystick:party-action:"+actor+":"+kind+":"+party+":"+revision+":"+target+":"+invitation).getBytes(StandardCharsets.UTF_8));}
private static UUID stable(UUID party,UUID character,BigInteger revision,String kind){return UUID.nameUUIDFromBytes(("fantasystick:party:"+party+":"+character+":"+revision+":"+kind).getBytes(StandardCharsets.UTF_8));}
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:party:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
private void emit(UUID id,UUID action,java.time.Instant time,String type,EventInput.Context context,Map<String,Object> data){try{sink.accept(new EventInput(id,action,time,type,context,Map.copyOf(data)));}catch(RuntimeException ignored){}}
}
@@ -0,0 +1,39 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.passive.PassiveDefinition;
import games.dmg.fantasystick.passive.PassiveEngine;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
/** Feature-owned acknowledged passive mutations and Quicker Regeneration configuration records. */
public final class PassiveEventBridge {
public record Action(UUID actionId,EventInput.Context context,String passive,boolean learn,
PassiveDefinition definition,CharacterRecord before){
public Action{Objects.requireNonNull(actionId);Objects.requireNonNull(context);Objects.requireNonNull(passive);Objects.requireNonNull(definition);Objects.requireNonNull(before);}
}
private final Consumer<EventInput> sink;private final Clock clock;
public PassiveEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
public Action begin(ControlInput input,CharacterRecord before,PassiveDefinition definition,boolean learn){var action=new Action(UUID.randomUUID(),context(input.player(),input.session(),before.name()),definition.id(),learn,definition,before);emit("action.observed",action,"observed",null,null);return action;}
public void refused(org.bukkit.entity.Player player,EquipmentSessions.Active active,CharacterRecord before,PassiveDefinition definition,boolean learn,String reason){var action=new Action(UUID.randomUUID(),context(player,active,before.name()),definition.id(),learn,definition,before);emit("action.observed",action,"observed",null,null);emit("action.resolved",action,"refused",reason,null);}
public void failed(Action action,String reason){emit("action.resolved",action,"failed",reason,null);}
public void resolved(Action action,PassiveEngine.Mutation mutation){if(mutation!=null&&mutation.status()==games.dmg.fantasystick.persistence.CharacterStorage.PassiveMutationStatus.APPLIED&&mutation.record()!=null){emit("action.resolved",action,"accepted","acknowledged",mutation.record());emit(action.learn()?"passive.learned":"passive.unlearned",action,"accepted","acknowledged",mutation.record());}else emit("action.resolved",action,"refused",mutation==null?"unavailable":mutation.status().name().toLowerCase(java.util.Locale.ROOT),mutation==null?null:mutation.record());}
public void configRead(String actorKind,UUID player,String playerName,String key,String value){config("config.read",actorKind,player,playerName,key,null,value);}
public void configChanged(String actorKind,UUID player,String playerName,String key,String before,String after){config("config.changed",actorKind,player,playerName,key,before,after);}
private void emit(String type,Action action,String outcome,String reason,CharacterRecord after){var before=action.before();var data=new LinkedHashMap<String,Object>();data.put("action_kind",action.learn()?"passive_learn":"passive_unlearn");data.put("passive_id",action.passive());data.put("expected_revision",before.revision().toString());data.put("before_revision",before.revision().toString());data.put("points_before",before.unspentPoints().toString());data.put("point_cost",action.definition().pointCost().toString());var requirements=new LinkedHashMap<String,Object>();action.definition().requirements().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry->requirements.put(entry.getKey().name(),entry.getValue().toString()));data.put("stat_prerequisites",requirements);data.put("passive_prerequisites",action.definition().prerequisites().stream().sorted().toList());data.put("outcome",outcome);if(after!=null){data.put("after_revision",after.revision().toString());data.put("points_after",after.unspentPoints().toString());int slot=(action.learn()?after.passives():before.passives()).indexOf(action.passive());data.put("passive_slot",slot);data.put("slot_index_base",0);data.put("native_publication","acknowledged");data.put("owned_cleanup",action.learn()?"passive_state_acknowledged":"passive_state_acknowledged");}if(reason!=null)data.put(type.startsWith("passive.")?"native_boundary":"reason",reason);try{sink.accept(new EventInput(id(action.actionId(),type),action.actionId(),clock.instant(),type,action.context(),Map.copyOf(data)));}catch(RuntimeException ignored){}
}
private void config(String type,String actorKind,UUID player,String playerName,String key,String before,String after){var action=UUID.randomUUID();var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);if(player!=null)data.put("actor_player_id",player.toString());if(playerName!=null)data.put("actor_player_name",playerName);data.put("key",key);if(before!=null)data.put("before",before);data.put("after",after);data.put("application","future_recovery_pulses");data.put("outcome","acknowledged");var context=player==null?null:new EventInput.Context(player,playerName,null,null,null,null);try{sink.accept(new EventInput(id(action,type),action,clock.instant(),type,context,Map.copyOf(data)));}catch(RuntimeException ignored){}
}
private EventInput.Context context(org.bukkit.entity.Player player,EquipmentSessions.Active active,String name){return new EventInput.Context(player.getUniqueId(),player.getName(),active.character(),name,active.session(),location(player.getLocation()));}
private EventInput.Location location(Location value){return new EventInput.Location(value.getWorld().getUID(),value.getWorld().getName(),value.getX(),value.getY(),value.getZ(),clock.instant(),"exact");}
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:passive:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
}
@@ -0,0 +1,198 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.PlayerFeedbackPreferences;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
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.Function;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.entity.Player;
/** Captured account preferences remain independent of gameplay and feedback delivery. */
public final class PreferenceEventBridge {
public record Action(UUID id, Instant time, EventInput.Context context, String key,
boolean before, boolean savedBefore, Boolean requested, String actorKind) { }
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final Clock clock;
private final EquipmentSessions sessions;
private final PlayerFeedbackPreferences preferences;
private final Consumer<Throwable> failures;
public PreferenceEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink, Clock clock,
EquipmentSessions sessions, PlayerFeedbackPreferences preferences,
Consumer<Throwable> failures) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
this.sessions = Objects.requireNonNull(sessions);
this.preferences = Objects.requireNonNull(preferences);
this.failures = Objects.requireNonNull(failures);
}
public Action begin(Player player, String key, boolean before, Boolean requested) {
Objects.requireNonNull(player);
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
var where = player.getLocation();
EventInput.Location location = where == null || where.getWorld() == null ? null
: new EventInput.Location(where.getWorld().getUID(), where.getWorld().getName(), where.getX(),
where.getY(), where.getZ(), clock.instant(), "exact");
var action = new Action(UUID.randomUUID(), clock.instant(), new EventInput.Context(player.getUniqueId(),
player.getName(), record == null ? null : record.id(), record == null ? null : record.name(),
record == null ? null : active.session(), location), key, before,
key.equals("player-aura-particles") ? preferences.savedPlayerAurasEnabled(player.getUniqueId())
: key.equals("combat_feedback") ? preferences.savedEnabled(player.getUniqueId())
: preferences.savedAllParticlesEnabled(player.getUniqueId()), requested, "player");
emit("action.observed", action, "observed", null, null, before, false);
return action;
}
public void refuseNonPlayer(org.bukkit.command.CommandSender sender, String key) {
var kind = sender instanceof org.bukkit.command.ConsoleCommandSender ? "console"
: sender instanceof org.bukkit.command.BlockCommandSender ? "command_block" : "system";
boolean initial = !key.equals("player-aura-particles");
var action = new Action(UUID.randomUUID(), clock.instant(), null, key, initial, initial, null, kind);
emit("action.observed", action, "observed", null, null, true, false);
emit("action.resolved", action, "refused", "player_required", null, true, false);
}
/** Written with the preference value in the same acknowledged file revision. */
public PlayerFeedbackPreferences.EventIntent intent(Action action) {
var fields = new LinkedHashMap<String,String>();
fields.put("time", action.time().toString());
fields.put("account", action.context().playerId().toString());
fields.put("account_name", action.context().playerName());
fields.put("key", action.key());
fields.put("before", Boolean.toString(action.before()));
fields.put("saved_before", Boolean.toString(action.savedBefore()));
fields.put("requested", Boolean.toString(action.requested()));
fields.put("all_enabled", Boolean.toString(preferences.allParticlesEnabled(action.context().playerId())));
if (action.context().characterId() != null) {
fields.put("character", action.context().characterId().toString());
fields.put("character_name", action.context().characterName());
fields.put("session", action.context().sessionId().toString());
}
var location = action.context().location();
if (location != null) {
fields.put("world", location.worldId().toString());
fields.put("world_name", location.worldName());
fields.put("x", Double.toString(location.x()));
fields.put("y", Double.toString(location.y()));
fields.put("z", Double.toString(location.z()));
fields.put("location_time", location.capturedAt().toString());
}
return new PlayerFeedbackPreferences.EventIntent(action.id(), fields);
}
public void replay(PlayerFeedbackPreferences.EventIntent intent) {
try {
var row = intent.captured();
UUID account = UUID.fromString(row.get("account"));
if (!"true".equals(row.get("published"))
|| Boolean.parseBoolean(row.getOrDefault("saved_before", row.get("before")))
== Boolean.parseBoolean(row.get("requested"))) return;
UUID character = row.containsKey("character") ? UUID.fromString(row.get("character")) : null;
EventInput.Location location = row.containsKey("world")
? new EventInput.Location(UUID.fromString(row.get("world")), row.get("world_name"),
Double.parseDouble(row.get("x")), Double.parseDouble(row.get("y")), Double.parseDouble(row.get("z")),
Instant.parse(row.get("location_time")), "exact") : null;
var context = new EventInput.Context(account, row.get("account_name"), character,
row.get("character_name"), character == null ? null : UUID.fromString(row.get("session")), location);
var action = new Action(intent.actionId(), Instant.parse(row.get("time")), context, row.get("key"),
Boolean.parseBoolean(row.get("before")), Boolean.parseBoolean(
row.getOrDefault("saved_before", row.get("before"))),
Boolean.parseBoolean(row.get("requested")), "player");
boolean after = Boolean.parseBoolean(row.get("requested"));
boolean effective = row.get("key").equals("player-aura-particles")
? after && Boolean.parseBoolean(row.get("all_enabled")) : after;
emit("preference.changed", action, "accepted", "acknowledged", intent.sequence(), effective, true);
} catch (RuntimeException ignored) { /* Invalid pending evidence cannot authorize a preference change. */ }
}
public void read(Action action) {
emit("action.resolved", action, "accepted", "read", null, action.before(), false);
emit("preference.read", action, "accepted", "acknowledged", null, action.before(), false);
}
public void saved(Action action, PlayerFeedbackPreferences.Update update, boolean published, boolean effective) {
if (!published) {
if (update.intent() != null) preferences.acknowledgeEvent(action.id());
emit("action.resolved", action, "refused", "superseded", update.sequence(), effective, false);
return;
}
if (update.intent() == null || action.savedBefore() == action.requested()) {
if (update.intent() != null) preferences.acknowledgeEvent(action.id());
emit("action.resolved", action, "refused", "unchanged", update.sequence(), effective, false);
return;
}
try {
preferences.markPublished(action.id()).whenComplete((ignored, failure) -> {
if (failure != null) try { failures.accept(failure); } catch (RuntimeException ignoredFailure) { }
});
} catch (RuntimeException failure) {
try { failures.accept(failure); } catch (RuntimeException ignoredFailure) { }
}
emit("action.resolved", action, "accepted", "acknowledged", update.sequence(), effective, false);
emit("preference.changed", action, "accepted", "acknowledged", update.sequence(), effective, true);
}
public void failed(Action action, String reason) {
emit("action.resolved", action, "failed", reason, null, action.before(), false);
}
public void refused(Action action, String reason) {
emit("action.resolved", action, "refused", reason, null, action.before(), false);
}
private void emit(String type, Action action, String outcome, String reason, Long preferenceSequence,
boolean effective, boolean acknowledgedIntent) {
if (action == null) return;
var data = new LinkedHashMap<String, Object>();
data.put("action_kind", "preference_" + (action.requested() == null ? "read" : "change"));
data.put("entry_point", action.key().equals("combat_feedback") ? "/fstick feedback"
: "/fstick particles " + (action.key().equals("player-aura-particles") ? "players" : "all"));
data.put("preference_key", action.key());
if (action.context() == null) { data.put("actor_kind", action.actorKind()); data.put("location_status", "not_applicable"); }
data.put("scope", "account");
data.put("default_value", Boolean.toString(!action.key().equals("player-aura-particles")));
data.put("before", Boolean.toString(action.before()));
data.put("saved_before", Boolean.toString(action.savedBefore()));
data.put("effective_before", Boolean.toString(action.before()));
if (action.requested() != null) data.put("requested", Boolean.toString(action.requested()));
if (action.context() != null) {
boolean latestSaved = type.equals("preference.changed") ? action.requested()
: preferenceSequence == null ? action.savedBefore()
: action.key().equals("player-aura-particles")
? preferences.savedPlayerAurasEnabled(action.context().playerId())
: action.key().equals("combat_feedback")
? preferences.savedEnabled(action.context().playerId())
: preferences.savedAllParticlesEnabled(action.context().playerId());
data.put("saved_value", Boolean.toString(latestSaved));
data.put("effective_value", Boolean.toString(effective));
data.put("effective_after", Boolean.toString(effective));
}
if (preferenceSequence != null) data.put("preference_sequence", Long.toString(preferenceSequence));
data.put("outcome", outcome);
if (reason != null) data.put(type.startsWith("preference.") ? "acknowledgment" : "reason", reason);
if (type.equals("preference.changed")) {
data.put("after", Boolean.toString(action.requested()));
data.put("preference_revision", Long.toString(preferenceSequence));
}
try {
var input = new EventInput(UUID.nameUUIDFromBytes(("fantasystick:preference:"+action.id()+":"+type)
.getBytes(StandardCharsets.UTF_8)), action.id(), type.equals("preference.changed") ? action.time()
: clock.instant(), type, action.context(), Map.copyOf(data));
var result = sink.apply(input);
if (acknowledgedIntent) result.whenComplete((delivery, failure) -> {
if (failure == null && delivery != LocalEventLog.Delivery.REJECTED)
preferences.acknowledgeEvent(action.id());
});
} catch (RuntimeException ignored) { /* Logging cannot affect the command or persistent state. */ }
}
}
@@ -0,0 +1,77 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
/** Feature-owned permanent-stat outcomes, isolated from purchase and native-health authority. */
public final class ProgressionEventBridge {
public record Action(UUID actionId, EventInput.Context context, Stat stat, BigInteger expectedRevision,
BigInteger statBefore, BigInteger pointsBefore, BigDecimal healthBefore,
BigDecimal maximumBefore, BigDecimal nativeLimit) {
public Action {
Objects.requireNonNull(actionId); Objects.requireNonNull(context); Objects.requireNonNull(stat);
Objects.requireNonNull(expectedRevision); Objects.requireNonNull(statBefore); Objects.requireNonNull(pointsBefore);
Objects.requireNonNull(healthBefore); Objects.requireNonNull(maximumBefore); Objects.requireNonNull(nativeLimit);
}
}
private final Consumer<EventInput> sink;
private final Clock clock;
public ProgressionEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);
}
public Action begin(ControlInput input, CharacterRecord before, Stat stat, BigDecimal health,
BigDecimal maximum, BigDecimal nativeLimit) {
Objects.requireNonNull(input);Objects.requireNonNull(before);Objects.requireNonNull(stat);
var action=new Action(UUID.randomUUID(),context(input,before.name()),stat,before.revision(),
before.stats().get(stat),before.unspentPoints(),health,maximum.min(nativeLimit),nativeLimit);
emit("action.observed",action,"observed",null,null,null,null);
return action;
}
public void refused(Action action,String reason,CharacterRecord current){
emit("action.resolved",action,"refused",reason,current,null,null);
}
public void failed(Action action,String reason){emit("action.resolved",action,"failed",reason,null,null,null);}
public void increased(Action action,CharacterRecord saved,String nativePublication,BigDecimal nativeHealthAfter){
emit("action.resolved",action,"accepted","acknowledged",saved,nativePublication,nativeHealthAfter);
emit("character.stat_increased",action,"accepted","acknowledged",saved,nativePublication,nativeHealthAfter);
}
public void refused(org.bukkit.entity.Player player,games.dmg.fantasystick.session.EquipmentSessions.Active active,
CharacterRecord current,Stat stat,BigDecimal nativeLimit,String reason){
var input=new ControlInput(player,active,0,"",ControlInput.Kind.BOOK,ControlInput.Gesture.USE,null,0,0,()->true);
var action=begin(input,current,stat,BigDecimal.valueOf(player.getHealth()),current.maximumHealth().min(nativeLimit),nativeLimit);
emit("action.resolved",action,"refused",reason,current,null,null);
}
private void emit(String type,Action action,String outcome,String reason,CharacterRecord after,
String nativePublication,BigDecimal nativeHealthAfter){
var data=new LinkedHashMap<String,Object>();data.put("action_kind","character_stat_increase");
data.put("stat_id",action.stat().name());data.put("expected_revision",action.expectedRevision().toString());
data.put("before_revision",action.expectedRevision().toString());data.put("stat_before",action.statBefore().toString());
data.put("points_before",action.pointsBefore().toString());data.put("health_before",action.healthBefore().toPlainString());
data.put("maximum_health_before",action.maximumBefore().toPlainString());data.put("native_health_ceiling",action.nativeLimit().toPlainString());
data.put("outcome",outcome);if(reason!=null)data.put(type.equals("character.stat_increased")?"native_boundary":"reason",reason);
if(after!=null){data.put("after_revision",after.revision().toString());data.put("stat_after",after.stats().get(action.stat()).toString());data.put("points_after",after.unspentPoints().toString());data.put("health_after",(nativeHealthAfter==null?after.health():nativeHealthAfter).toPlainString());data.put("maximum_health_after",after.maximumHealth().min(action.nativeLimit()).toPlainString());}
if(nativePublication!=null)data.put("native_publication",nativePublication);
try{sink.accept(new EventInput(id(action.actionId(),type),action.actionId(),clock.instant(),type,action.context(),Map.copyOf(data)));}catch(RuntimeException ignored){}
}
private EventInput.Context context(ControlInput input,String characterName){var player=input.player();return new EventInput.Context(player.getUniqueId(),player.getName(),input.session().character(),characterName,input.session().session(),location(player.getLocation()));}
private EventInput.Location location(Location location){return new EventInput.Location(location.getWorld().getUID(),location.getWorld().getName(),location.getX(),location.getY(),location.getZ(),clock.instant(),"exact");}
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:progression:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
}
@@ -0,0 +1,21 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.shop.RareCatalog;
import games.dmg.fantasystick.shop.RareProduct;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
/** Failure-isolated Phoenix Down catalog and acknowledged purchase event adapter. */
public final class RareEventBridge {
private final Consumer<EventInput> sink;private final Clock clock;
public RareEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
public void catalogRefreshed(RareCatalog catalog){if(!catalog.available(RareProduct.PHOENIX_DOWN))return;var offer=catalog.offer(RareProduct.PHOENIX_DOWN);var data=new LinkedHashMap<String,Object>();data.put("actor_kind","system");data.put("product_id",RareProduct.PHOENIX_DOWN.id());data.put("catalog_revision",catalog.revision().toString());data.put("catalog_day",catalog.day().toString());data.put("refresh_reason",catalog.reason().name().toLowerCase(java.util.Locale.ROOT));data.put("price",offer.price().toString());data.put("stock",Integer.toString(offer.remaining()));data.put("outcome","acknowledged");emit(stable(catalog.revision(),"phoenix-catalog"),catalog.revision(),catalog.refreshedAt(),"rare_catalog.refreshed",null,data);}
public void purchased(UUID operation,UUID catalog,UUID player,String playerName,UUID character,String characterName,UUID session,BigInteger price,int stockBefore,int stockAfter,BigInteger quantityBefore,BigInteger quantityAfter){var data=new LinkedHashMap<String,Object>();data.put("actor_kind","player");data.put("product_id",RareProduct.PHOENIX_DOWN.id());data.put("catalog_revision",catalog.toString());data.put("purchase_operation_id",operation.toString());data.put("price",price.toString());data.put("stock_before",Integer.toString(stockBefore));data.put("stock_after",Integer.toString(stockAfter));data.put("quantity_before",quantityBefore.toString());data.put("quantity_after",quantityAfter.toString());data.put("outcome","acknowledged");var context=new EventInput.Context(player,playerName,character,characterName,session,null);emit(stable(operation,"phoenix-purchased"),operation,clock.instant(),"rare_item.purchased",context,data);}
private void emit(UUID id,UUID action,java.time.Instant time,String type,EventInput.Context context,java.util.Map<String,Object> data){try{sink.accept(new EventInput(id,action,time,type,context,data));}catch(RuntimeException ignored){}}
private static UUID stable(UUID authority,String kind){return UUID.nameUUIDFromBytes(("fantasystick:"+authority+':'+kind).getBytes(StandardCharsets.UTF_8));}
}
@@ -0,0 +1,35 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.entity.Player;
/** One authoritative out-of-combat healing publication with bounded passive attribution. */
public final class RecoveryEventBridge {
public record Action(UUID actionId,EventInput.Context context,BigInteger expectedRevision,
BigDecimal healthBefore,BigDecimal proposed,BigInteger configuredBase,
BigInteger configuredQuickerRegeneration){
public Action{Objects.requireNonNull(actionId);Objects.requireNonNull(context);Objects.requireNonNull(expectedRevision);Objects.requireNonNull(healthBefore);Objects.requireNonNull(proposed);Objects.requireNonNull(configuredBase);Objects.requireNonNull(configuredQuickerRegeneration);}
}
private final Consumer<EventInput> sink;private final Clock clock;
public RecoveryEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
public Action begin(Player player,EquipmentSessions.Active active,CharacterRecord record,double current,double proposed,BigInteger base,BigInteger bonus){var action=new Action(UUID.randomUUID(),context(player,active,record.name()),record.revision(),BigDecimal.valueOf(current),BigDecimal.valueOf(proposed),base,bonus);emit("action.observed",action,"observed",null,null,null,null,null);return action;}
public void refused(Action action,String reason){emit("action.resolved",action,"refused",reason,null,null,null,null);}
public void failed(Action action,String reason){emit("action.resolved",action,"failed",reason,null,null,null,null);}
public void healed(Action action,BigDecimal healthAfter,BigDecimal acceptedTotal,BigDecimal baseContribution,BigDecimal passiveContribution){emit("action.resolved",action,"accepted","acknowledged",healthAfter,acceptedTotal,baseContribution,passiveContribution);emit("combat.healed",action,"accepted","acknowledged",healthAfter,acceptedTotal,baseContribution,passiveContribution);}
private void emit(String type,Action action,String outcome,String reason,BigDecimal healthAfter,BigDecimal acceptedTotal,BigDecimal baseContribution,BigDecimal passiveContribution){var data=new LinkedHashMap<String,Object>();data.put("action_kind","out_of_combat_recovery");data.put("recovery_kind","out_of_combat");data.put("cause","recovery_timer");data.put("expected_revision",action.expectedRevision().toString());data.put("health_before",action.healthBefore().toPlainString());data.put("proposed_total",action.proposed().toPlainString());data.put("configured_base",action.configuredBase().toString());data.put("configured_quicker_regeneration_i",action.configuredQuickerRegeneration().toString());data.put("outcome",outcome);if(healthAfter!=null)data.put("health_after",healthAfter.toPlainString());if(acceptedTotal!=null){data.put("accepted_total",acceptedTotal.toPlainString());data.put("base_contribution",baseContribution.toPlainString());if(passiveContribution.signum()>0)data.put("passive_contributions",Map.of("quicker_regeneration_i",passiveContribution.toPlainString()));data.put("native_publication","acknowledged");}if(reason!=null)data.put(type.equals("combat.healed")?"native_boundary":"reason",reason);try{sink.accept(new EventInput(id(action.actionId(),type),action.actionId(),clock.instant(),type,action.context(),Map.copyOf(data)));}catch(RuntimeException ignored){}
}
private EventInput.Context context(Player player,EquipmentSessions.Active active,String name){return new EventInput.Context(player.getUniqueId(),player.getName(),active.character(),name,active.session(),location(player.getLocation()));}
private EventInput.Location location(Location value){return new EventInput.Location(value.getWorld().getUID(),value.getWorld().getName(),value.getX(),value.getY(),value.getZ(),clock.instant(),"exact");}
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:recovery:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
}
@@ -0,0 +1,40 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.ability.AbilityExecution;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.RevivalStore;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.entity.Player;
/** Learned Revive Tail-branch cast and consume-once restoration evidence. */
public final class ReviveEventBridge {
public record Cast(UUID actionId,EventInput.Context caster,UUID casterCharacter,String casterCharacterName,
UUID targetPlayer,String targetPlayerName,Map<String,Object> targetLocation,
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);}
}
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);}
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 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()));}
private EventInput.Location location(Location value){return new EventInput.Location(value.getWorld().getUID(),value.getWorld().getName(),value.getX(),value.getY(),value.getZ(),clock.instant(),"exact");}
private static Map<String,Object> locationData(Location value,Instant at){return Map.of("world_id",value.getWorld().getUID().toString(),"world_name",value.getWorld().getName(),"x",Double.toString(value.getX()),"y",Double.toString(value.getY()),"z",Double.toString(value.getZ()),"captured_at",at,"quality","exact");}
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:revive:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
private void emit(UUID id,UUID action,Instant time,String type,EventInput.Context context,Map<String,Object> data){try{sink.accept(new EventInput(id,action,time,type,context,Map.copyOf(data)));}catch(RuntimeException ignored){}}
}
@@ -0,0 +1,99 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.entity.Player;
/** Observes acknowledged session boundaries; the log never authorizes a transition. */
public final class SessionEventBridge {
private final Consumer<EventInput> sink;
private final Clock clock;
private final java.util.concurrent.ConcurrentHashMap<UUID, EventInput.Context> current = new java.util.concurrent.ConcurrentHashMap<>();
public SessionEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
}
public void entered(Player player, UUID session, CharacterRecord record, Location entry,
boolean resumedDowned, boolean adventureMode) {
try {
var now = clock.instant();
var data = new LinkedHashMap<String, Object>();
data.put("session_id", session.toString());
data.put("character_id", record.id().toString());
data.put("revision", record.revision().toString());
data.put("entry_location", locationMap(entry));
data.put("resumed_downed", resumedDowned);
data.put("adventure_mode_applied", adventureMode);
data.put("equipment_outcome", "packed");
data.put("session_receipt", "acknowledged");
data.put("outcome", "active");
var context = new EventInput.Context(player.getUniqueId(), player.getName(), record.id(), record.name(),
session, location(player.getLocation()));
current.put(context.playerId(), context);
sink.accept(new EventInput(id(session, "entered"), session, now, "character.entered", context, data));
} catch (RuntimeException ignored) {
// An unavailable journal cannot change the acknowledged session.
}
}
/** Thread-safe last native capture, never a live Bukkit lookup from asynchronous chat. */
public EventInput.Context lastKnown(UUID player, UUID session, String characterName) {
var value = current.get(player);
if (value == null || !value.sessionId().equals(session)) return null;
return new EventInput.Context(value.playerId(), value.playerName(), value.characterId(), characterName,
session, value.location());
}
public void forget(UUID player, UUID session) {
var value=current.get(player);
if(value!=null&&value.sessionId().equals(session))current.remove(player,value);
}
public record ExitCapture(EventInput.Context context, java.util.Map<String, Object> entryLocation, String revision) { }
public ExitCapture captureExit(Player player, UUID session, CharacterRecord record, Location entry) {
try {
return new ExitCapture(new EventInput.Context(player.getUniqueId(), player.getName(), record.id(),
record.name(), session, location(player.getLocation())), locationMap(entry), record.revision().toString());
} catch (RuntimeException ignored) { return null; }
}
public void exited(UUID session, ExitCapture capture) {
if (capture == null) return;
try {
var data = new LinkedHashMap<String, Object>();
data.put("session_id", session.toString());
data.put("character_id", capture.context().characterId().toString());
data.put("revision", capture.revision());
data.put("entry_location", capture.entryLocation());
data.put("reason", "living_exit");
data.put("equipment_outcome", "restored");
data.put("session_receipt", "completed");
data.put("outcome", "acknowledged");
forget(capture.context().playerId(),session);
sink.accept(new EventInput(id(session, "exited"), session, clock.instant(), "character.exited", capture.context(), data));
} catch (RuntimeException ignored) { }
}
private java.util.Map<String, Object> locationMap(Location value) {
return java.util.Map.of("world_id", value.getWorld().getUID().toString(), "world_name", value.getWorld().getName(),
"x", value.getX(), "y", value.getY(), "z", value.getZ(), "captured_at", clock.instant().toString());
}
private EventInput.Location location(Location value) {
return new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(), value.getX(),
value.getY(), value.getZ(), clock.instant(), "native_capture");
}
private static UUID id(UUID session, String stage) {
return UUID.nameUUIDFromBytes(("fantasystick:session:" + session + ':' + stage).getBytes(StandardCharsets.UTF_8));
}
}
@@ -79,6 +79,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
private java.util.function.Predicate<org.bukkit.entity.Entity> excluded = ignored -> false;
private java.util.function.Function<LivingEntity,java.util.Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin>> managedOrigins=ignored->java.util.Optional.empty();
private Consumer<games.dmg.fantasystick.progression.ManagedEncounterOrigin> managedSettlement=ignored->{};
private games.dmg.fantasystick.events.CombatRewardEventBridge events;
private boolean closed;
private final class PendingAward {
@@ -88,12 +89,14 @@ public final class CombatExperience implements Listener, AutoCloseable {
final BigInteger coins;
final CombatRewardRules.KillPosition restricted;
final CombatRewardRules rewardRules;
BigInteger expectedRevision;
final games.dmg.fantasystick.events.CombatRewardEventBridge.Capture capture;
BigInteger expectedRevision;CharacterRecord before;
boolean running;final CompletableFuture<Void> settled=new CompletableFuture<>();
PendingAward(UUID id, UUID character, BigInteger experience, BigInteger coins,
CombatRewardRules.KillPosition restricted, CombatRewardRules rewardRules) {
CombatRewardRules.KillPosition restricted, CombatRewardRules rewardRules,
games.dmg.fantasystick.events.CombatRewardEventBridge.Capture capture) {
this.id = id; this.character = character; this.experience = experience;
this.coins = coins; this.restricted = restricted; this.rewardRules = rewardRules;
this.coins = coins; this.restricted = restricted; this.rewardRules = rewardRules;this.capture=Objects.requireNonNull(capture);
}
}
@@ -140,6 +143,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
if(closed)throw new IllegalStateException("Combat experience is closed");managedOrigins=Objects.requireNonNull(classifier);
}
public void attachManagedSettlement(Consumer<games.dmg.fantasystick.progression.ManagedEncounterOrigin> settlement){if(closed)throw new IllegalStateException("Combat experience is closed");managedSettlement=Objects.requireNonNull(settlement);}
public void attachEvents(games.dmg.fantasystick.events.CombatRewardEventBridge value){if(closed||events!=null)throw new IllegalStateException("Combat reward events already attached or combat is closed");events=Objects.requireNonNull(value);}
public void install(org.bukkit.plugin.Plugin plugin) {
if (closed || retryTask != null) throw new IllegalStateException("Combat experience is already installed or closed");
@@ -207,12 +211,12 @@ public final class CombatExperience implements Listener, AutoCloseable {
/** Freeze final-time eligibility and exact shares before the durable death claim. */
public List<games.dmg.fantasystick.session.DownedEpisode.Reward> allocateDownedDefeat(games.dmg.fantasystick.session.DownedEpisode episode){
var attribution=episode.attribution();if(!attribution.playerDefeat()||attribution.contributors().isEmpty())return List.of();var units=rewardUnits(new ArrayList<>(attribution.contributors()));if(units.isEmpty())return List.of();var capturedRewards=rewards;var replaySafeRandom=new java.util.Random(episode.id().getMostSignificantBits()^episode.id().getLeastSignificantBits());var xp=games.dmg.fantasystick.progression.RewardAllocator.allocate(capturedRewards.pool(CombatRewardRules.Target.FANTASY_CHARACTER,attribution.victimLevel()),units,replaySafeRandom);var coins=games.dmg.fantasystick.progression.RewardAllocator.allocate(attribution.victimCoins(),units,replaySafeRandom);return xp.keySet().stream().sorted().map(character->new games.dmg.fantasystick.session.DownedEpisode.Reward(character,xp.get(character),coins.get(character))).toList();
var attribution=episode.attribution();if(!attribution.playerDefeat()||attribution.contributors().isEmpty())return List.of();var units=rewardUnits(new ArrayList<>(attribution.contributors()));if(units.isEmpty())return List.of();var capturedRewards=rewards;var replaySafeRandom=new java.util.Random(episode.id().getMostSignificantBits()^episode.id().getLeastSignificantBits());var experiencePool=capturedRewards.pool(CombatRewardRules.Target.FANTASY_CHARACTER,attribution.victimLevel());var xp=games.dmg.fantasystick.progression.RewardAllocator.allocate(experiencePool,units,replaySafeRandom);var coins=games.dmg.fantasystick.progression.RewardAllocator.allocate(attribution.victimCoins(),units,replaySafeRandom);return xp.keySet().stream().sorted().map(character->{var unit=units.stream().filter(value->value.recipients().contains(character)).findFirst().orElseThrow();return new games.dmg.fantasystick.session.DownedEpisode.Reward(character,xp.get(character),coins.get(character),String.valueOf(unit.key()),units.size());}).toList();
}
/** Apply only the shares frozen in the winning durable final-death claim. */
public CompletionStage<Void> settleDownedDefeat(games.dmg.fantasystick.session.DownedEpisode episode){
if(episode.finalRewards().isEmpty())return CompletableFuture.completedStage(null);var capturedRewards=rewards;var settlements=new ArrayList<CompletableFuture<Void>>();for(var reward:episode.finalRewards()){UUID receipt=UUID.nameUUIDFromBytes(("downed:"+episode.id()+":"+reward.character()).getBytes(java.nio.charset.StandardCharsets.UTF_8));var proposed=new PendingAward(receipt,reward.character(),reward.experience(),reward.coins(),null,capturedRewards);var existing=pending.putIfAbsent(receipt,proposed);var operation=existing==null?proposed:existing;settlements.add(operation.settled);attempt(operation);}return CompletableFuture.allOf(settlements.toArray(CompletableFuture[]::new));
if(episode.finalRewards().isEmpty())return CompletableFuture.completedStage(null);var capturedRewards=rewards;var settlements=new ArrayList<CompletableFuture<Void>>();for(var reward:episode.finalRewards()){UUID receipt=UUID.nameUUIDFromBytes(("downed:"+episode.id()+":"+reward.character()).getBytes(java.nio.charset.StandardCharsets.UTF_8));var capture=new games.dmg.fantasystick.events.CombatRewardEventBridge.Capture(episode.player(),"player","fantasy_character","pvp_balance",null,null,"final_death",episode.resolvedAt(),episode.attribution().contributors(),reward.unitId(),reward.unitCount(),capturedRewards.pool(CombatRewardRules.Target.FANTASY_CHARACTER,episode.attribution().victimLevel()),episode.attribution().victimCoins());var proposed=new PendingAward(receipt,reward.character(),reward.experience(),reward.coins(),null,capturedRewards,capture);var existing=pending.putIfAbsent(receipt,proposed);var operation=existing==null?proposed:existing;settlements.add(operation.settled);attempt(operation);}return CompletableFuture.allOf(settlements.toArray(CompletableFuture[]::new));
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@@ -240,7 +244,8 @@ public final class CombatExperience implements Listener, AutoCloseable {
? coinRules.get().mob(coinTarget(target), random) : defeatedCoins;
if (units.isEmpty()) {managed.ifPresent(this::settled);return;} // The defeat transition has already persisted any PvP balance loss.
var capturedRewards = rewards;
var xp = games.dmg.fantasystick.progression.RewardAllocator.allocate(capturedRewards.pool(kind, level), units, random);
var experiencePool=capturedRewards.pool(kind,level);
var xp = games.dmg.fantasystick.progression.RewardAllocator.allocate(experiencePool, units, random);
var coins = games.dmg.fantasystick.progression.RewardAllocator.allocate(coinPool, units, random);
CombatRewardRules.KillPosition restricted = managed.isPresent()||kind == CombatRewardRules.Target.PASSIVE
|| kind == CombatRewardRules.Target.FANTASY_CHARACTER ? null : position(target.getLocation());
@@ -250,7 +255,8 @@ public final class CombatExperience implements Listener, AutoCloseable {
for (UUID character : xp.keySet()) {
String receiptAuthority=managed.isPresent()?authority:targetReward+":"+now+":"+authority;
UUID receipt = UUID.nameUUIDFromBytes((receiptAuthority+":"+character).getBytes(java.nio.charset.StandardCharsets.UTF_8));
var proposed = new PendingAward(receipt, character, xp.get(character), coins.get(character), restricted,capturedRewards);
var unit=units.stream().filter(value->value.recipients().contains(character)).findFirst().orElseThrow();var capture=new games.dmg.fantasystick.events.CombatRewardEventBridge.Capture(target.getUniqueId(),target.getType().name().toLowerCase(java.util.Locale.ROOT),kind.name().toLowerCase(java.util.Locale.ROOT),coinTarget(target).name().toLowerCase(java.util.Locale.ROOT),defeatedCharacter==null?position(target.getLocation()):null,managed.orElse(null),defeatedCharacter==null?"mob_defeat":"fantasy_character_defeat",now,direct,String.valueOf(unit.key()),units.size(),experiencePool,coinPool);
var proposed = new PendingAward(receipt, character, xp.get(character), coins.get(character), restricted,capturedRewards,capture);
var existing=pending.putIfAbsent(receipt,proposed);var operation=existing==null?proposed:existing;settlements.add(operation.settled);attempt(operation);
}
managed.ifPresent(origin->CompletableFuture.allOf(settlements.toArray(CompletableFuture[]::new)).thenRun(()->settled(origin)));
@@ -304,7 +310,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
synchronized (operation) { operation.running = false; }
if (failure != null) { fail(operation, failure); return; }
if (record.isEmpty() || record.orElseThrow().dead()) { complete(operation); return; }
operation.expectedRevision = record.orElseThrow().revision();
operation.before=record.orElseThrow();operation.expectedRevision = operation.before.revision();
attempt(operation);
});
return;
@@ -314,7 +320,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
synchronized (operation) { operation.running = false; }
if (failure != null) { fail(operation, failure); return; }
if (award.status() == CharacterStorage.CombatAwardStatus.STALE && award.record().isPresent()) {
operation.expectedRevision = award.record().orElseThrow().revision(); attempt(operation); return;
operation.before=award.record().orElseThrow();operation.expectedRevision = operation.before.revision(); attempt(operation); return;
}
complete(operation);
if (award.record().isEmpty() || award.status() == CharacterStorage.CombatAwardStatus.UNAVAILABLE) return;
@@ -344,12 +350,9 @@ public final class CombatExperience implements Listener, AutoCloseable {
if (closed) return;
CharacterRecord saved = award.record().orElseThrow();
var active = sessions.activeCharacter(operation.character).orElse(null);
if (active != null && !sessions.acknowledgeProgress(active, saved.revision().subtract(BigInteger.ONE), saved)
&& !sessions.currentRecord(active).filter(saved::equals).isPresent()) {
report(operation.character, new IllegalStateException("Saved combat progress could not synchronize with active session"));
return;
}
Player player = active == null ? null : players.apply(active.player());
boolean synchronizedSession=active==null||sessions.acknowledgeProgress(active,saved.revision().subtract(BigInteger.ONE),saved)||sessions.currentRecord(active).filter(saved::equals).isPresent();
Player player=active==null?null:players.apply(active.player());String feedback=feedback(operation,award,player,synchronizedSession);var bridge=events;if(bridge!=null)bridge.resolved(operation.id,operation.character,operation.experience,operation.coins,operation.restricted,operation.rewardRules,operation.capture,operation.before,award,active,player,synchronizedSession?active==null?"not_applicable_offline":"acknowledged":"failed",feedback);
if(!synchronizedSession){report(operation.character,new IllegalStateException("Saved combat progress could not synchronize with active session"));return;}
if (player == null || !player.isOnline()) return;
if (award.status() == CharacterStorage.CombatAwardStatus.APPLIED) {
if (operation.experience.signum() > 0) player.sendMessage("Earned " + operation.experience + " fantasy XP.");
@@ -373,6 +376,8 @@ public final class CombatExperience implements Listener, AutoCloseable {
}
}
private static String feedback(PendingAward operation,CharacterStorage.CombatAward award,Player player,boolean synchronizedSession){if(!synchronizedSession)return "suppressed_native_sync_failed";if(player==null||!player.isOnline())return "not_delivered_offline";return switch(award.status()){case APPLIED->operation.experience.signum()>0&&operation.coins.signum()>0?"experience_and_coins":operation.experience.signum()>0?"experience":operation.coins.signum()>0?"coins":"none";case SPACING_BLOCKED->"spacing_refusal";case AT_CAP->operation.coins.signum()>0?"maximum_level_and_coins":"maximum_level";case UNCHANGED->"zero_reward";case DUPLICATE->"none_duplicate";case STALE,UNAVAILABLE->"none_unavailable";};}
public void configure(CombatRewardRules updated) {
if (closed) throw new IllegalStateException("Combat rewards are closed");
rewards = Objects.requireNonNull(updated);
@@ -9,19 +9,25 @@ import org.bukkit.entity.Player;
/** Registrar-authorized mutations with durable operation plans spanning coin and party files. */
public final class PartyManagement implements AutoCloseable {
public enum Status{APPLIED,INVALID_SESSION,TOO_FAR,INVALID_NAME,ALREADY_MEMBER,NAME_TAKEN,INSUFFICIENT,FAILED}
public enum Status{APPLIED,INVALID_SESSION,TOO_FAR,INVALID_NAME,ALREADY_MEMBER,NAME_TAKEN,INSUFFICIENT,FAILED,ERROR}
public record Outcome(Status status,Optional<CharacterParty> party){}
private record Context(EquipmentSessions.Active active,CharacterRecord record,PartyRegistrar registrar){}
private final CharacterPartyDirectory parties;private final PartyRegistrarDirectory registrars;private final CharacterStorage characters;private final EquipmentSessions sessions;private final PartyEconomySettings prices;private final PartyOperationStore operations;private final Consumer<Runnable> nextTick;private final Consumer<Throwable> failures;private volatile boolean closed;private volatile boolean recovered;private final java.util.concurrent.CompletableFuture<Void> recovery=new java.util.concurrent.CompletableFuture<>();
private final CharacterPartyDirectory parties;private final PartyRegistrarDirectory registrars;private final CharacterStorage characters;private final EquipmentSessions sessions;private final PartyEconomySettings prices;private final PartyOperationStore operations;private final Consumer<Runnable> nextTick;private final Consumer<Throwable> failures;private games.dmg.fantasystick.events.PartyEventBridge events;private volatile boolean closed;private volatile boolean recovered;private final java.util.concurrent.CompletableFuture<Void> recovery=new java.util.concurrent.CompletableFuture<>();
public PartyManagement(CharacterPartyDirectory parties,PartyRegistrarDirectory registrars,CharacterStorage characters,EquipmentSessions sessions,PartyEconomySettings prices,PartyOperationStore operations,Consumer<Runnable> nextTick,Consumer<Throwable> failures){this.parties=Objects.requireNonNull(parties);this.registrars=Objects.requireNonNull(registrars);this.characters=Objects.requireNonNull(characters);this.sessions=Objects.requireNonNull(sessions);this.prices=Objects.requireNonNull(prices);this.operations=Objects.requireNonNull(operations);this.nextTick=Objects.requireNonNull(nextTick);this.failures=Objects.requireNonNull(failures);parties.readiness().thenCompose(ignored->operations.load()).thenCompose(plans->java.util.concurrent.CompletableFuture.allOf(plans.stream().map(plan->execute(plan,null,null).toCompletableFuture()).toArray(java.util.concurrent.CompletableFuture[]::new))).whenComplete((ignored,failure)->{if(failure==null){recovered=true;recovery.complete(null);}else{report(failure);recovery.completeExceptionally(failure);}});}
public void register(Player player,UUID registrarId,String name,Consumer<Outcome> result){var context=context(player,registrarId);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var parsed=PartyNames.parse(name);if(parsed.isEmpty()){result.accept(new Outcome(Status.INVALID_NAME,Optional.empty()));return;}var captured=context.orElseThrow();if(parties.partyOf(captured.record().id()).isPresent()){result.accept(new Outcome(Status.ALREADY_MEMBER,Optional.empty()));return;}if(parties.partyByCanonicalName(parsed.orElseThrow().canonical()).isPresent()){result.accept(new Outcome(Status.NAME_TAKEN,Optional.empty()));return;}var plan=new PartyOperationStore.Plan(UUID.randomUUID(),PartyOperationStore.Kind.CREATE,captured.record().id(),captured.record().revision(),BigInteger.valueOf(prices.registrationPrice()),parsed.orElseThrow().display(),null,null,null);prepare(plan,captured,result);}
public void transfer(Player player,UUID registrarId,UUID target,Consumer<Outcome> result){var context=context(player,registrarId);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var captured=context.orElseThrow();var party=parties.partyOf(captured.record().id()).orElse(null);if(party==null||!party.leader().equals(captured.record().id())||!party.members().contains(target)||target.equals(captured.record().id())){result.accept(new Outcome(Status.FAILED,Optional.empty()));return;}var plan=new PartyOperationStore.Plan(UUID.randomUUID(),PartyOperationStore.Kind.TRANSFER,captured.record().id(),captured.record().revision(),BigInteger.valueOf(prices.leaderChangePrice()),null,party.id(),target,party.revision());prepare(plan,captured,result);}
public void attachEvents(games.dmg.fantasystick.events.PartyEventBridge value){if(closed||events!=null)throw new IllegalStateException("Party events already attached or management closed");events=Objects.requireNonNull(value);}
public void register(Player player,UUID registrarId,String name,Consumer<Outcome> result){var context=context(player,registrarId);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var captured=context.orElseThrow();var before=parties.partyOf(captured.record().id()).orElse(null);var operation=UUID.randomUUID();var audit=audit(player,captured,"create",null,null,before,BigInteger.valueOf(prices.registrationPrice()),operation);var audited=wrap(audit,result);var parsed=PartyNames.parse(name);if(parsed.isEmpty()){audited.accept(new Outcome(Status.INVALID_NAME,Optional.empty()));return;}if(before!=null){audited.accept(new Outcome(Status.ALREADY_MEMBER,Optional.of(before)));return;}if(parties.partyByCanonicalName(parsed.orElseThrow().canonical()).isPresent()){audited.accept(new Outcome(Status.NAME_TAKEN,Optional.empty()));return;}var plan=new PartyOperationStore.Plan(operation,PartyOperationStore.Kind.CREATE,captured.record().id(),captured.record().revision(),BigInteger.valueOf(prices.registrationPrice()),parsed.orElseThrow().display(),null,null,null);prepare(plan,captured,audited);}
public void transfer(Player player,UUID registrarId,UUID target,Consumer<Outcome> result){var context=context(player,registrarId);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var captured=context.orElseThrow();var party=parties.partyOf(captured.record().id()).orElse(null);var operation=UUID.randomUUID();var audit=audit(player,captured,"transfer",target,null,party,BigInteger.valueOf(prices.leaderChangePrice()),operation);var audited=wrap(audit,result);if(party==null||!party.leader().equals(captured.record().id())||!party.members().contains(target)||target.equals(captured.record().id())){audited.accept(new Outcome(Status.FAILED,Optional.empty()));return;}var plan=new PartyOperationStore.Plan(operation,PartyOperationStore.Kind.TRANSFER,captured.record().id(),captured.record().revision(),BigInteger.valueOf(prices.leaderChangePrice()),null,party.id(),target,party.revision());prepare(plan,captured,audited);}
private void prepare(PartyOperationStore.Plan plan,Context context,Consumer<Outcome> result){operations.prepare(plan).whenComplete((saved,failure)->{if(failure!=null){fail(failure,result);return;}execute(saved,context,result);});}
private java.util.concurrent.CompletionStage<Void> execute(PartyOperationStore.Plan plan,Context context,Consumer<Outcome> result){var done=new java.util.concurrent.CompletableFuture<Void>();parties.readiness().thenCompose(ignored->plan.kind()==PartyOperationStore.Kind.CREATE?parties.paidCreate(plan,characters):parties.paidTransfer(plan,characters)).whenComplete((paid,failure)->{if(failure!=null){if(result!=null)fail(failure,result);else report(failure);done.completeExceptionally(failure);return;}var spent=paid.spend();var changed=paid.result();if(spent!=null&&(spent.status()==CharacterStorage.CoinSpendStatus.INSUFFICIENT||spent.status()==CharacterStorage.CoinSpendStatus.UNAVAILABLE)){finish(plan,context,result,spent,new Outcome(Status.INSUFFICIENT,Optional.empty()),done);return;}if(spent==null){finish(plan,context,result,null,new Outcome(map(changed.status()),changed.party()),done);return;}if(spent.status()!=CharacterStorage.CoinSpendStatus.APPLIED&&spent.status()!=CharacterStorage.CoinSpendStatus.DUPLICATE){finish(plan,context,result,spent,new Outcome(Status.FAILED,Optional.empty()),done);return;}if(changed.status()!=CharacterPartyDirectory.Status.APPLIED&&changed.status()!=CharacterPartyDirectory.Status.DUPLICATE){finish(plan,context,result,spent,new Outcome(map(changed.status()),changed.party()),done);return;}finish(plan,context,result,spent,new Outcome(Status.APPLIED,changed.party()),done);});return done.minimalCompletionStage();}
private void finish(PartyOperationStore.Plan plan,Context context,Consumer<Outcome> result,CharacterStorage.CoinSpend spent,Outcome outcome,java.util.concurrent.CompletableFuture<Void> done){operations.complete(plan.id()).whenComplete((ignored,failure)->{if(failure!=null){if(result!=null)fail(failure,result);else report(failure);done.completeExceptionally(failure);return;}if(result!=null)publish(()->{if(context!=null&&spent!=null&&spent.record().isPresent())sessions.acknowledgeProgress(context.active(),context.record().revision(),spent.record().orElseThrow());result.accept(outcome);});done.complete(null);});}
private static Status map(CharacterPartyDirectory.Status status){return switch(status){case NAME_TAKEN->Status.NAME_TAKEN;case ALREADY_MEMBER->Status.ALREADY_MEMBER;default->Status.FAILED;};}
public void invite(Player player,UUID registrarId,UUID target,Consumer<Outcome> result){var context=context(player,registrarId);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var active=sessions.activeCharacter(target).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);if(record==null||parties.partyOf(target).isPresent()){result.accept(new Outcome(Status.FAILED,Optional.empty()));return;}var party=parties.partyOf(context.orElseThrow().record().id()).orElse(null);if(party==null||!party.leader().equals(context.orElseThrow().record().id())){result.accept(new Outcome(Status.FAILED,Optional.empty()));return;}parties.invite(context.orElseThrow().record().id(),party.id(),target).whenComplete((value,failure)->complete(value,failure,result));}
public void accept(Player player,UUID registrarId,UUID invitation,Consumer<Outcome> result){mutate(player,registrarId,c->parties.accept(c.record().id(),invitation),result);}public void decline(Player player,UUID registrarId,UUID invitation,Consumer<Outcome> result){mutate(player,registrarId,c->parties.decline(c.record().id(),invitation),result);}public void cancel(Player player,UUID registrarId,UUID invitation,Consumer<Outcome> result){mutateParty(player,registrarId,(c,p)->parties.cancel(c.record().id(),p.id(),invitation),result);}public void leave(Player player,UUID registrarId,Consumer<Outcome> result){mutateParty(player,registrarId,(c,p)->parties.leave(c.record().id(),p.id()),result);}public void kick(Player player,UUID registrarId,UUID target,Consumer<Outcome> result){mutateParty(player,registrarId,(c,p)->parties.kick(c.record().id(),p.id(),target),result);}public void disband(Player player,UUID registrarId,Consumer<Outcome> result){mutateParty(player,registrarId,(c,p)->parties.disband(c.record().id(),p.id()),result);}
public void invite(Player player,UUID registrarId,UUID target,Consumer<Outcome> result){var context=context(player,registrarId);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var captured=context.orElseThrow();var party=parties.partyOf(captured.record().id()).orElse(null);var audit=audit(player,captured,"invite",target,null,party,BigInteger.ZERO,null);var audited=wrap(audit,result);var active=sessions.activeCharacter(target).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);if(record==null||parties.partyOf(target).isPresent()||party==null||!party.leader().equals(captured.record().id())){audited.accept(new Outcome(Status.FAILED,Optional.empty()));return;}parties.invite(captured.record().id(),party.id(),target).whenComplete((value,failure)->complete(value,failure,audited));}
public void accept(Player player,UUID registrarId,UUID invitation,Consumer<Outcome> result){var c=context(player,registrarId);var before=partyForInvitation(invitation);var audit=c.map(value->audit(player,value,"accept",value.record().id(),invitation,before,BigInteger.ZERO,null)).orElse(null);mutate(player,registrarId,value->parties.accept(value.record().id(),invitation),wrap(audit,result));}
public void decline(Player player,UUID registrarId,UUID invitation,Consumer<Outcome> result){var c=context(player,registrarId);var before=partyForInvitation(invitation);var audit=c.map(value->audit(player,value,"decline",value.record().id(),invitation,before,BigInteger.ZERO,null)).orElse(null);mutate(player,registrarId,value->parties.decline(value.record().id(),invitation),wrap(audit,result));}
public void cancel(Player player,UUID registrarId,UUID invitation,Consumer<Outcome> result){var c=context(player,registrarId);var before=c.flatMap(value->parties.partyOf(value.record().id())).orElse(null);var invited=before==null?null:before.invitations().stream().filter(value->value.id().equals(invitation)).map(CharacterParty.Invitation::character).findFirst().orElse(null);var audit=c.map(value->audit(player,value,"cancel",invited,invitation,before,BigInteger.ZERO,null)).orElse(null);mutateParty(player,registrarId,(value,p)->parties.cancel(value.record().id(),p.id(),invitation),wrap(audit,result));}
public void leave(Player player,UUID registrarId,Consumer<Outcome> result){var c=context(player,registrarId);var before=c.flatMap(value->parties.partyOf(value.record().id())).orElse(null);boolean leaderLeave=before!=null&&c.isPresent()&&before.leader().equals(c.orElseThrow().record().id());String kind=leaderLeave?"leader_leave":"leave";var audit=c.map(value->audit(player,value,kind,value.record().id(),null,before,BigInteger.ZERO,null,leaderLeave?"leader_leave":"requested")).orElse(null);mutateParty(player,registrarId,(value,p)->parties.leave(value.record().id(),p.id()),wrap(audit,result));}
public void kick(Player player,UUID registrarId,UUID target,Consumer<Outcome> result){var c=context(player,registrarId);var before=c.flatMap(value->parties.partyOf(value.record().id())).orElse(null);var audit=c.map(value->audit(player,value,"kick",target,null,before,BigInteger.ZERO,null)).orElse(null);mutateParty(player,registrarId,(value,p)->parties.kick(value.record().id(),p.id(),target),wrap(audit,result));}
public void disband(Player player,UUID registrarId,Consumer<Outcome> result){var c=context(player,registrarId);var before=c.flatMap(value->parties.partyOf(value.record().id())).orElse(null);var audit=c.map(value->audit(player,value,"disband",value.record().id(),null,before,BigInteger.ZERO,null)).orElse(null);mutateParty(player,registrarId,(value,p)->parties.disband(value.record().id(),p.id()),wrap(audit,result));}
public Optional<CharacterParty> party(Player player,UUID registrar){return context(player,registrar).flatMap(value->parties.partyOf(value.record().id()));}public List<CharacterPartyDirectory.Offer> invitations(Player player,UUID registrar){return context(player,registrar).map(value->parties.invitationsFor(value.record().id())).orElse(List.of());}public List<CharacterPartyDirectory.Offer> outgoingInvitations(Player player,UUID registrar){var context=context(player,registrar);if(context.isEmpty())return List.of();var party=parties.partyOf(context.orElseThrow().record().id()).filter(value->value.leader().equals(context.orElseThrow().record().id())).orElse(null);return party==null?List.of():party.invitations().stream().map(value->new CharacterPartyDirectory.Offer(value.id(),party.id(),value.character())).toList();}
private void mutate(Player player,UUID id,java.util.function.Function<Context,java.util.concurrent.CompletableFuture<CharacterPartyDirectory.Result>> action,Consumer<Outcome> result){var context=context(player,id);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}try{action.apply(context.orElseThrow()).whenComplete((value,failure)->complete(value,failure,result));}catch(RuntimeException failure){fail(failure,result);}}
private void mutateParty(Player player,UUID id,java.util.function.BiFunction<Context,CharacterParty,java.util.concurrent.CompletableFuture<CharacterPartyDirectory.Result>> action,Consumer<Outcome> result){var context=context(player,id);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var party=parties.partyOf(context.orElseThrow().record().id()).orElse(null);if(party==null){result.accept(new Outcome(Status.FAILED,Optional.empty()));return;}try{action.apply(context.orElseThrow(),party).whenComplete((value,failure)->complete(value,failure,result));}catch(RuntimeException failure){fail(failure,result);}}
@@ -29,7 +35,12 @@ public final class PartyManagement implements AutoCloseable {
public int registrationPrice(){return prices.registrationPrice();}public int leaderChangePrice(){return prices.leaderChangePrice();}
public Optional<CharacterRecord> active(Player player,UUID registrar){return context(player,registrar).map(Context::record);}public Optional<UUID> activeSession(Player player,UUID registrar){return context(player,registrar).map(value->value.active().session());}public boolean eligibleInvitationTarget(UUID character){return parties.partyOf(character).isEmpty()&&sessions.activeCharacter(character).filter(sessions::current).flatMap(sessions::currentRecord).filter(value->!value.dead()).isPresent();}public Optional<CharacterRecord> activeCharacter(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);return active==null?Optional.empty():sessions.currentRecord(active).filter(value->!value.dead());}
private Optional<Context> context(Player player,UUID registrarId){if(!parties.ready()||!recovered)return Optional.empty();var registrar=registrars.find(registrarId).orElse(null);var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);if(registrar==null||record==null)return Optional.empty();var location=player.getLocation();if(location.getWorld()==null||!location.getWorld().getUID().equals(registrar.world()))return Optional.empty();double dx=location.getX()-registrar.x(),dy=location.getY()-registrar.y(),dz=location.getZ()-registrar.z();if(dx*dx+dy*dy+dz*dz>36)return Optional.empty();return Optional.of(new Context(active,record,registrar));}
private void fail(Throwable failure,Consumer<Outcome> result){report(failure);publish(()->result.accept(new Outcome(Status.FAILED,Optional.empty())));}private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}private void publish(Runnable action){if(closed)return;try{nextTick.accept(action);}catch(RuntimeException rejected){report(rejected);}}
private CharacterParty partyForInvitation(UUID invitation){return parties.all().stream().filter(party->party.invitations().stream().anyMatch(value->value.id().equals(invitation))).findFirst().orElse(null);}
private games.dmg.fantasystick.events.PartyEventBridge.Action audit(Player player,Context context,String kind,UUID target,UUID invitation,CharacterParty before,BigInteger charge,UUID operation){return audit(player,context,kind,target,invitation,before,charge,operation,"requested");}
private games.dmg.fantasystick.events.PartyEventBridge.Action audit(Player player,Context context,String kind,UUID target,UUID invitation,CharacterParty before,BigInteger charge,UUID operation,String reason){return events==null?null:events.begin(player,context.active(),context.record().name(),context.registrar(),kind,target,targetContext(player,context,target),invitation,before,charge,operation,reason);}
private games.dmg.fantasystick.events.EventInput.Context targetContext(Player actor,Context context,UUID target){if(target==null||events==null)return null;if(target.equals(context.record().id()))return events.context(actor,context.active(),context.record().name());var active=sessions.activeCharacter(target).orElse(null);if(active==null)return null;var player=sessions.currentPlayer(active).orElse(null);var record=sessions.currentRecord(active).orElse(null);return player==null||record==null?null:events.context(player,active,record.name());}
private Consumer<Outcome> wrap(games.dmg.fantasystick.events.PartyEventBridge.Action audit,Consumer<Outcome> target){return outcome->{if(audit!=null){if(outcome.status()==Status.ERROR)events.failed(audit,"mutation_failed_or_uncertain");else events.resolved(audit,outcome);}target.accept(outcome);};}
private void fail(Throwable failure,Consumer<Outcome> result){report(failure);publish(()->result.accept(new Outcome(Status.ERROR,Optional.empty())));}private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}private void publish(Runnable action){if(closed)return;try{nextTick.accept(action);}catch(RuntimeException rejected){report(rejected);}}
public java.util.concurrent.CompletionStage<Void> ready(){return recovery.minimalCompletionStage();}
public java.util.concurrent.CompletionStage<Void> drained(){return operations.drained();}
@Override public void close(){closed=true;operations.close();}
@@ -27,6 +27,7 @@ public final class PartyRegistrarController implements Listener, AutoCloseable {
public void install(){for(var world:plugin.getServer().getWorlds()){var chunks=world.getLoadedChunks();if(chunks!=null)for(var chunk:chunks)reconcile(chunk);}}
public boolean tagged(Entity entity){return "party_registrar".equals(entity.getPersistentDataContainer().get(typeKey,PersistentDataType.STRING));}
public void reconcile(Chunk chunk){if(closed)return;var desired=new HashMap<UUID,PartyRegistrar>();for(var value:directory.all())if(value.world().equals(chunk.getWorld().getUID())&&chunk(value.x())==chunk.getX()&&chunk(value.z())==chunk.getZ())desired.put(value.id(),value);var retained=new HashSet<UUID>();for(var entity:chunk.getEntities()){if(!tagged(entity))continue;UUID id=id(entity);var target=id==null?null:desired.get(id);if(!(entity instanceof Villager villager)||target==null||!retained.add(id)){entity.remove();continue;}configure(villager,target);var current=villager.getLocation();var expected=location(target);if(current.distanceSquared(expected)>0.000001||Math.abs(current.getYaw()-expected.getYaw())>0.001||Math.abs(current.getPitch()-expected.getPitch())>0.001){relocating.add(villager);boolean moved;try{moved=villager.teleport(expected);}finally{relocating.remove(villager);}if(!moved){villager.remove();spawn(target);}}}for(var value:desired.values())if(!retained.contains(value.id()))spawn(value);}
public java.util.Optional<UUID> loadedEntity(UUID id){for(var world:plugin.getServer().getWorlds()){var entities=world.getEntities();if(entities!=null)for(var entity:entities)if(tagged(entity)&&id.equals(id(entity)))return java.util.Optional.ofNullable(entity.getUniqueId());}return java.util.Optional.empty();}
public void removeLoaded(UUID id){for(var world:plugin.getServer().getWorlds()){var entities=world.getEntities();if(entities!=null)for(var entity:entities)if(tagged(entity)&&id.equals(id(entity)))entity.remove();}}
private void spawn(PartyRegistrar value){var world=plugin.getServer().getWorld(value.world());if(world==null)return;int x=chunk(value.x()),z=chunk(value.z());if(!world.isChunkLoaded(x,z))return;world.spawn(location(value),Villager.class,villager->configure(villager,value));}
private void configure(Villager villager,PartyRegistrar value){villager.getPersistentDataContainer().set(typeKey,PersistentDataType.STRING,"party_registrar");villager.getPersistentDataContainer().set(idKey,PersistentDataType.STRING,value.id().toString());villager.setAI(false);villager.setGravity(false);villager.setSilent(true);villager.setInvulnerable(true);villager.setCollidable(false);villager.setCanPickupItems(false);villager.setLeashHolder(null);villager.setRemoveWhenFarAway(false);villager.setAdult();villager.setAgeLock(true);villager.customName(net.kyori.adventure.text.Component.text(value.name().isBlank()?"Party Registrar":value.name()));villager.setCustomNameVisible(true);}
@@ -72,10 +72,11 @@ public final class ChainLightningSettings implements AutoCloseable {
public String value(String key) { return effectiveValues.get(key); }
public ChainLightning.Settings settings() { return parse(effectiveValues); }
public synchronized void publish(Update update) {
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
if (update.sequence() <= publishedSequence) return false;
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
return true;
}
public CompletionStage<Update> set(String key, String value) {
@@ -27,6 +27,7 @@ public final class CharacterStorage implements AutoCloseable {
private final java.util.function.Supplier<games.dmg.fantasystick.progression.CoinRules> coinRules;
private final java.util.random.RandomGenerator coinRandom;
private final CharacterRecoveryStore recoveries;
private games.dmg.fantasystick.events.CoinEventBridge coinEvents;
private final ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-character-persistence"));
@@ -47,6 +48,7 @@ public final class CharacterStorage implements AutoCloseable {
}
public void attachUnavailableCharacter(java.util.function.Predicate<UUID> predicate){repository.attachUnavailable(predicate);}
public void attachCoinEvents(games.dmg.fantasystick.events.CoinEventBridge value){if(worker.isShutdown()||coinEvents!=null)throw new IllegalStateException("Coin events already attached or storage closed");coinEvents=Objects.requireNonNull(value);}
public CompletableFuture<Void> create(CharacterRecord character) {
return submit(() -> {
@@ -60,7 +62,8 @@ public final class CharacterStorage implements AutoCloseable {
* starting points and retains the returned handle through success, cancellation and disable.
* No inventory access occurs here; abandoning an observer is not abandoning the attempt.
*/
public CraftPreparation prepareCraft(List<CharacterRecord> records) {
public CraftPreparation prepareCraft(List<CharacterRecord> records) {return prepareCraft(records,"craft");}
public CraftPreparation prepareCraft(List<CharacterRecord> records,String source) {
var snapshot = List.copyOf(records);
var ids = new HashSet<UUID>();
if (snapshot.isEmpty()) throw new IllegalArgumentException("Craft batch must not be empty");
@@ -75,17 +78,19 @@ public final class CharacterStorage implements AutoCloseable {
throw new IllegalArgumentException("Craft batch requires distinct new characters");
}
}
return new CraftPreparation(snapshot);
return new CraftPreparation(snapshot,Objects.requireNonNull(source),coinRules.get());
}
public final class CraftPreparation {
private final List<CharacterRecord> requested;
private final CompletableFuture<CraftBatch> completion;
private final String source;
private final games.dmg.fantasystick.progression.CoinRules configuredRules;
private volatile boolean abandoned;
private CompletableFuture<CraftCleanup> cleanup;
private CraftPreparation(List<CharacterRecord> records) {
requested = records;
private CraftPreparation(List<CharacterRecord> records,String source,games.dmg.fantasystick.progression.CoinRules configuredRules) {
requested = records;this.source=source;this.configuredRules=Objects.requireNonNull(configuredRules);
completion = writeCraftBatch(records, this);
}
@@ -173,6 +178,7 @@ public final class CharacterStorage implements AutoCloseable {
if (resolved || owner.abandoned) return Optional.empty();
resolved = true;
if (worker.isShutdown()) return Optional.empty();
var bridge=coinEvents;if(bridge!=null)for(var record:records)bridge.initialized(record,owner.source,owner.configuredRules);
return Optional.of(records);
}
@@ -311,11 +317,11 @@ public final class CharacterStorage implements AutoCloseable {
reconcileRecovery(found.orElseThrow());
if (found.orElseThrow().coinsInitialized()) return found;
var old = found.orElseThrow();
var migrated = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
var configuredRules=coinRules.get();var migrated = 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(), old.dead(), old.deathId(), old.diedAt(),
coinRules.get().starting(coinRandom), true, old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
repository.replace(migrated, old.revision());
configuredRules.starting(coinRandom), true, old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
repository.replace(migrated, old.revision());var bridge=coinEvents;if(bridge!=null)bridge.initialized(migrated,"legacy_migration",configuredRules);
return Optional.of(migrated);
}
@@ -801,7 +807,7 @@ public final class CharacterStorage implements AutoCloseable {
}
/** Finalize one claimed downed outcome with its stable death identity and original resolution time. */
public CompletableFuture<Optional<CharacterRecord>> recordFinalDeath(UUID characterId,BigInteger expectedRevision,UUID deathId,Instant diedAt,boolean loseCoins){Objects.requireNonNull(characterId);Objects.requireNonNull(expectedRevision);Objects.requireNonNull(deathId);Objects.requireNonNull(diedAt);return submit(()->{var found=repository.load(characterId);if(found.isEmpty())return Optional.empty();var old=found.orElseThrow();if(old.dead())return old.deathId().equals(deathId)&&old.diedAt().equals(diedAt)?Optional.of(old):Optional.empty();if(!old.revision().equals(expectedRevision))return Optional.empty();var dead=new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),java.math.BigDecimal.ZERO,old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),true,deathId,diedAt,loseCoins?BigInteger.ZERO:old.coins(),old.coinsInitialized(),old.rewardReceipts(),loseCoins?old.coins():BigInteger.ZERO,old.gear(),old.sheetGeneration());repository.replace(dead,old.revision());return Optional.of(dead);});}
public CompletableFuture<Optional<CharacterRecord>> recordFinalDeath(UUID characterId,BigInteger expectedRevision,UUID deathId,Instant diedAt,boolean loseCoins){Objects.requireNonNull(characterId);Objects.requireNonNull(expectedRevision);Objects.requireNonNull(deathId);Objects.requireNonNull(diedAt);return submit(()->{var found=repository.load(characterId);if(found.isEmpty())return Optional.empty();var old=found.orElseThrow();if(old.dead())return old.deathId().equals(deathId)&&old.diedAt().equals(diedAt)?Optional.of(old):Optional.empty();if(!old.revision().equals(expectedRevision))return Optional.empty();var dead=new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),java.math.BigDecimal.ZERO,old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),true,deathId,diedAt,loseCoins?BigInteger.ZERO:old.coins(),old.coinsInitialized(),old.rewardReceipts(),loseCoins?old.coins():BigInteger.ZERO,old.gear(),old.sheetGeneration());repository.replace(dead,old.revision());var bridge=coinEvents;if(bridge!=null&&loseCoins&&old.coins().signum()>0)bridge.lost(old,dead,deathId,diedAt);return Optional.of(dead);});}
private CompletableFuture<Optional<CharacterRecord>> death(UUID characterId, BigInteger expectedRevision, boolean loseCoins) {
Objects.requireNonNull(characterId);
@@ -71,10 +71,11 @@ public final class FrostNovaSettings implements AutoCloseable {
public String value(String key) { return effectiveValues.get(key); }
public FrostNova.Settings settings() { return parse(effectiveValues); }
public synchronized void publish(Update update) {
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
if (update.sequence() <= publishedSequence) return false;
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
return true;
}
public CompletionStage<Update> set(String key, String value) {
@@ -70,10 +70,11 @@ public final class GuardianBondSettings implements AutoCloseable {
public String value(String key) { return effectiveValues.get(key); }
public GuardianBond.Settings settings() { return parse(effectiveValues); }
public synchronized void publish(Update update) {
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
if (update.sequence() <= publishedSequence) return false;
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
return true;
}
public CompletionStage<Update> set(String key, String value) {
@@ -69,10 +69,11 @@ public final class MendingLightSettings implements AutoCloseable {
public String value(String key) { return effectiveValues.get(key); }
public MendingLight.Settings settings() { return parse(effectiveValues); }
public synchronized void publish(Update update) {
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
if (update.sequence() <= publishedSequence) return false;
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
return true;
}
public CompletionStage<Update> set(String key, String value) {
@@ -72,10 +72,11 @@ public final class MeteorSettings implements AutoCloseable {
public String value(String key) { return effectiveValues.get(key); }
public Meteor.Settings settings() { return parse(effectiveValues); }
public synchronized void publish(Update update) {
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
if (update.sequence() <= publishedSequence) return false;
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
return true;
}
public CompletionStage<Update> set(String key, String value) {
@@ -42,29 +42,81 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
private AccountPreference { Objects.requireNonNull(account); Objects.requireNonNull(preference); }
}
public record Update(UUID account, Preference preference, boolean enabled, long sequence) {
public record EventIntent(UUID actionId, Map<String, String> captured, long sequence) {
public EventIntent { Objects.requireNonNull(actionId); captured = Map.copyOf(captured); }
public EventIntent(UUID actionId, Map<String, String> captured) { this(actionId, captured, 0); }
}
public record Update(UUID account, Preference preference, boolean enabled, long sequence, EventIntent intent) {
public Update { Objects.requireNonNull(account); Objects.requireNonNull(preference); }
public Update(UUID account, Preference preference, boolean enabled, long sequence) {
this(account, preference, enabled, sequence, null);
}
}
private final Path directory;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-player-feedback-preferences"));
private final Map<AccountPreference, Boolean> effective = new java.util.HashMap<>();
private final Map<AccountPreference, Boolean> saved = new java.util.HashMap<>();
private final Map<AccountPreference, Long> published = new java.util.HashMap<>();
private long persistedSequence;
private final Map<UUID, EventIntent> pendingEvents = new LinkedHashMap<>();
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public PlayerFeedbackPreferences(Path directory) throws IOException {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
effective.putAll(loadEnabled());
var loaded = loadEnabled();
effective.putAll(loaded);
saved.putAll(loaded);
var root = readRoot();
var persisted = root.get("preference-sequence");
if (persisted != null) {
try { persistedSequence = Long.parseLong(String.valueOf(persisted)); }
catch (NumberFormatException invalid) { throw new IOException("Invalid preference sequence", invalid); }
if (persistedSequence < 0) throw new IOException("Invalid preference sequence");
}
var pending = stringMap(root.get("pending-preference-events"), "pending preference events");
for (var entry : pending.entrySet()) {
UUID id;
try { id = UUID.fromString(entry.getKey()); }
catch (IllegalArgumentException invalid) { throw new IOException("Invalid pending preference event identity", invalid); }
var row = stringMap(entry.getValue(), "pending preference event");
var captured = new LinkedHashMap<String, String>();
for (var field : row.entrySet()) {
if (!(field.getValue() instanceof String text)) throw new IOException("Invalid pending preference field");
captured.put(field.getKey(), text);
}
long sequence;
try { sequence = Long.parseLong(captured.remove("preference_sequence")); }
catch (RuntimeException invalid) { throw new IOException("Invalid pending preference sequence", invalid); }
if (sequence <= 0) throw new IOException("Invalid pending preference sequence");
pendingEvents.put(id, new EventIntent(id, captured, sequence));
persistedSequence = Math.max(persistedSequence, sequence);
}
}
/** Existing routine combat/effect feedback preference. */
public synchronized boolean enabled(UUID account) { return value(account, Preference.COMBAT_FEEDBACK); }
public synchronized boolean savedEnabled(UUID account) {
Objects.requireNonNull(account);
return saved.getOrDefault(new AccountPreference(account, Preference.COMBAT_FEEDBACK),
Preference.COMBAT_FEEDBACK.defaultValue);
}
public synchronized boolean playerAurasEnabled(UUID account) { return value(account, Preference.PLAYER_AURAS); }
public synchronized boolean allParticlesEnabled(UUID account) { return value(account, Preference.ALL_PARTICLES); }
public synchronized boolean savedPlayerAurasEnabled(UUID account) {
Objects.requireNonNull(account);
return saved.getOrDefault(new AccountPreference(account, Preference.PLAYER_AURAS),
Preference.PLAYER_AURAS.defaultValue);
}
public synchronized boolean savedAllParticlesEnabled(UUID account) {
Objects.requireNonNull(account);
return saved.getOrDefault(new AccountPreference(account, Preference.ALL_PARTICLES),
Preference.ALL_PARTICLES.defaultValue);
}
public synchronized boolean auraParticlesEnabled(UUID account) {
return value(account, Preference.ALL_PARTICLES) && value(account, Preference.PLAYER_AURAS);
}
@@ -78,16 +130,94 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
return set(account, Preference.COMBAT_FEEDBACK, enabled);
}
public CompletionStage<Update> set(UUID account, boolean enabled, EventIntent intent) {
return set(account, Preference.COMBAT_FEEDBACK, enabled, intent);
}
public CompletionStage<Update> setPlayerAuras(UUID account, boolean enabled) {
return set(account, Preference.PLAYER_AURAS, enabled);
}
public CompletionStage<Update> setPlayerAuras(UUID account, boolean enabled, EventIntent intent) {
return set(account, Preference.PLAYER_AURAS, enabled, intent);
}
/** Shared persisted precedence for the separately delivered all-particles command. */
public CompletionStage<Update> setAllParticles(UUID account, boolean enabled) {
return set(account, Preference.ALL_PARTICLES, enabled);
}
public CompletionStage<Update> setAllParticles(UUID account, boolean enabled, EventIntent intent) {
return set(account, Preference.ALL_PARTICLES, enabled, intent);
}
public synchronized java.util.List<EventIntent> pendingEvents() { return java.util.List.copyOf(pendingEvents.values()); }
/** Persist native publication proof independently of the event writer before a recovered success is claimed. */
public CompletionStage<Void> markPublished(UUID action) {
Objects.requireNonNull(action);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Player preferences closed"));
pending++;
}
try {
return CompletableFuture.runAsync(() -> {
try (var channel = FileChannel.open(directory.resolve(".player-preferences.lock"),
StandardOpenOption.CREATE, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS);
var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Player preferences busy");
var root = readRoot();
var all = stringMap(root.get("pending-preference-events"), "pending preference events");
var found = stringMap(all.get(action.toString()), "pending preference event");
if (found.isEmpty()) return; // Already accepted by the event writer.
found.put("published", "true");
all.put(action.toString(), found);
root.put("pending-preference-events", all);
writeRoot(root);
synchronized (this) {
var previous = pendingEvents.get(action);
if (previous != null) {
var snapshot = new LinkedHashMap<>(previous.captured());
snapshot.put("published", "true");
pendingEvents.put(action, new EventIntent(action, snapshot, previous.sequence()));
}
}
} catch (IOException | OverlappingFileLockException failure) {
throw new java.util.concurrent.CompletionException(failure);
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(rejected);
}
}
public CompletionStage<Void> acknowledgeEvent(UUID action) {
Objects.requireNonNull(action);
try {
return CompletableFuture.runAsync(() -> {
try (var channel = FileChannel.open(directory.resolve(".player-preferences.lock"),
StandardOpenOption.CREATE, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS);
var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Player preferences busy");
Map<String,Object> root = readRoot();
var pending = stringMap(root.get("pending-preference-events"), "pending preference events");
if (pending.remove(action.toString()) == null) return;
root.put("pending-preference-events", pending);
writeRoot(root);
synchronized (this) { pendingEvents.remove(action); }
} catch (IOException | OverlappingFileLockException failure) {
throw new java.util.concurrent.CompletionException(failure);
}
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException closedWorker) { return CompletableFuture.failedStage(closedWorker); }
}
private CompletionStage<Update> set(UUID account, Preference preference, boolean enabled) {
return set(account, preference, enabled, null);
}
private CompletionStage<Update> set(UUID account, Preference preference, boolean enabled, EventIntent intent) {
Objects.requireNonNull(account); Objects.requireNonNull(preference);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Player preference store is closed"));
@@ -96,8 +226,9 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
try {
return CompletableFuture.supplyAsync(() -> {
try {
save(account, preference, enabled);
return new Update(account, preference, enabled, ++persistedSequence);
long sequence = ++persistedSequence;
var accepted = save(account, preference, enabled, intent, sequence);
return new Update(account, preference, enabled, sequence, accepted);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(new IOException(
"Player preference save refused or failed; effective setting unchanged", failure));
@@ -142,7 +273,8 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
return result;
}
private void save(UUID account, Preference preference, boolean enabled) throws IOException {
private EventIntent save(UUID account, Preference preference, boolean enabled, EventIntent intent,
long sequence) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe data directory");
Path lockFile = directory.resolve(".player-preferences.lock");
@@ -155,9 +287,19 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
Map<String, Object> root = readRoot();
Map<String, Object> players = stringMap(root.get(PLAYERS), "player preferences");
Map<String, Object> values = stringMap(players.get(account.toString()), "player preference entry");
boolean before = values.get(preference.key) instanceof Boolean stored ? stored : preference.defaultValue;
values.put(preference.key, enabled);
players.put(account.toString(), values);
root.put(PLAYERS, players);
root.put("preference-sequence", Long.toString(sequence));
EventIntent accepted = intent != null && before != enabled ? new EventIntent(intent.actionId(), intent.captured(), sequence) : null;
if (accepted != null) {
var pending = stringMap(root.get("pending-preference-events"), "pending preference events");
var captured = new LinkedHashMap<String,Object>(accepted.captured());
captured.put("preference_sequence", Long.toString(sequence));
pending.put(accepted.actionId().toString(), captured);
root.put("pending-preference-events", pending);
}
byte[] bytes = yaml().dump(root).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Player preferences too large");
Path temporary = Files.createTempFile(directory, ".player-preferences-", ".tmp");
@@ -171,11 +313,32 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
StandardCopyOption.REPLACE_EXISTING);
DurableFiles.forceDirectory(directory);
} finally { Files.deleteIfExists(temporary); }
synchronized (this) {
saved.put(new AccountPreference(account, preference), enabled);
if (accepted != null) pendingEvents.put(accepted.actionId(), accepted);
}
return accepted;
} catch (OverlappingFileLockException busy) {
throw new IOException("Player preferences busy", busy);
}
}
private void writeRoot(Map<String,Object> root) throws IOException {
byte[] bytes = yaml().dump(root).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Player preferences too large");
Path temporary = Files.createTempFile(directory, ".player-preferences-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes);
while (buffer.hasRemaining()) output.write(buffer);
output.force(true);
}
Files.move(temporary, directory.resolve(FILE), StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
DurableFiles.forceDirectory(directory);
} finally { Files.deleteIfExists(temporary); }
}
private Map<String, Object> readRoot() throws IOException {
Path file = directory.resolve(FILE);
if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return new LinkedHashMap<>();
@@ -67,10 +67,11 @@ public final class PurifySettings implements AutoCloseable {
public String value(String key) { return effectiveValues.get(key); }
public Purify.Settings settings() { return parse(effectiveValues); }
public synchronized void publish(Update update) {
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
if (update.sequence() <= publishedSequence) return false;
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
return true;
}
public CompletionStage<Update> set(String key, String value) {
@@ -71,10 +71,11 @@ public final class RallyingCrySettings implements AutoCloseable {
public String value(String key) { return effectiveValues.get(key); }
public RallyingCry.Settings settings() { return parse(effectiveValues); }
public synchronized void publish(Update update) {
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
if (update.sequence() <= publishedSequence) return false;
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
return true;
}
public CompletionStage<Update> set(String key, String value) {
@@ -17,17 +17,20 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Save-before-publication configuration for rare catalogs and future potion uses. */
public final class RareSettings implements AutoCloseable {
public static final String VARIANCE="rare-price-variance-percent";
public static final String PHOENIX_PROTECTION="rare-phoenix-down-invulnerability-seconds";
private static final int MAX_DEADLINE_SECONDS=365*24*60*60;
public record Update(String key,String value,Map<String,String> values,long sequence){public Update{values=Map.copyOf(values);}}
private static final Map<String,String> DEFAULTS=defaults();private static final List<String> KEYS=DEFAULTS.keySet().stream().sorted().toList();
private final Path directory;private final ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-rare-configuration"));private volatile Map<String,String> effective;private Map<String,String> persisted;private long savedSequence,publishedSequence;private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
public RareSettings(Path directory,Map<String,String> initial){this.directory=directory.toAbsolutePath();var values=new LinkedHashMap<String,String>();for(var key:KEYS)values.put(key,Objects.requireNonNull(initial.getOrDefault(key,DEFAULTS.get(key))));validate(values);effective=Map.copyOf(values);persisted=effective;}
public static List<String> keys(){return KEYS;}public static String defaultValue(String key){return DEFAULTS.get(key);}public String value(String key){return effective.get(key);}
public RareMarketRules rules(){var values=effective;var prices=new EnumMap<RareProduct,BigInteger>(RareProduct.class);var stock=new EnumMap<RareProduct,RareMarketRules.StockRange>(RareProduct.class);var healing=new EnumMap<RareProduct,Integer>(RareProduct.class);for(var product:RareProduct.values()){prices.put(product,new BigInteger(values.get(priceKey(product))));stock.put(product,new RareMarketRules.StockRange(whole(values.get(stockKey(product,true)),0,Integer.MAX_VALUE),whole(values.get(stockKey(product,false)),0,Integer.MAX_VALUE)));if(product.heals())healing.put(product,whole(values.get(healingKey(product)),0,100));}return new RareMarketRules(prices,whole(values.get(VARIANCE),0,100),stock,healing);}
public CompletionStage<Update> set(String key,String raw){if(!KEYS.contains(key))throw new IllegalArgumentException("Unknown rare setting");String accepted=Integer.toString(whole(raw,0,key.equals(VARIANCE)||key.endsWith("-healing-percent")?100:Integer.MAX_VALUE));var preview=new LinkedHashMap<>(effective);preview.put(key,accepted);validate(preview);synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Configuration closed"));pending++;}return CompletableFuture.supplyAsync(()->{try{var next=new LinkedHashMap<>(persisted);next.put(key,accepted);validate(next);save(key,accepted);persisted=Map.copyOf(next);return new Update(key,accepted,persisted,++savedSequence);}catch(IOException failure){throw new CompletionException(failure);}finally{finished();}},worker).minimalCompletionStage();}
public java.time.Duration phoenixProtection(){return java.time.Duration.ofSeconds(whole(effective.get(PHOENIX_PROTECTION),0,MAX_DEADLINE_SECONDS));}
public CompletionStage<Update> set(String key,String raw){if(!KEYS.contains(key))throw new IllegalArgumentException("Unknown rare setting");int maximum=key.equals(VARIANCE)||key.endsWith("-healing-percent")?100:key.equals(PHOENIX_PROTECTION)?MAX_DEADLINE_SECONDS:Integer.MAX_VALUE;String accepted=Integer.toString(whole(raw,0,maximum));var preview=new LinkedHashMap<>(effective);preview.put(key,accepted);validate(preview);synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Configuration closed"));pending++;}return CompletableFuture.supplyAsync(()->{try{var next=new LinkedHashMap<>(persisted);next.put(key,accepted);validate(next);save(key,accepted);persisted=Map.copyOf(next);return new Update(key,accepted,persisted,++savedSequence);}catch(IOException failure){throw new CompletionException(failure);}finally{finished();}},worker).minimalCompletionStage();}
public synchronized boolean publish(Update update){if(update.sequence()<=publishedSequence)return false;validate(update.values());effective=update.values();publishedSequence=update.sequence();return true;}
public static String priceKey(RareProduct product){return "rare-price-"+product.id();}public static String stockKey(RareProduct product,boolean minimum){return "rare-stock-"+product.id()+(minimum?"-min":"-max");}public static String healingKey(RareProduct product){return "rare-"+product.id()+"-healing-percent";}
private static Map<String,String> defaults(){var values=new LinkedHashMap<String,String>();values.put(VARIANCE,"10");for(var product:RareProduct.values()){values.put(priceKey(product),Integer.toString(product.basePrice()));values.put(stockKey(product,true),"0");values.put(stockKey(product,false),Integer.toString(product.maximumStock()));if(product.heals())values.put(healingKey(product),Integer.toString(product.defaultHealingPercent()));}return Map.copyOf(values);}
private static void validate(Map<String,String> values){whole(values.get(VARIANCE),0,100);for(var product:RareProduct.values()){whole(values.get(priceKey(product)),0,Integer.MAX_VALUE);int min=whole(values.get(stockKey(product,true)),0,Integer.MAX_VALUE),max=whole(values.get(stockKey(product,false)),0,Integer.MAX_VALUE);if(min>max)throw new IllegalArgumentException("Stock minimum exceeds maximum");if(product.heals())whole(values.get(healingKey(product)),0,100);}}
private static Map<String,String> defaults(){var values=new LinkedHashMap<String,String>();values.put(VARIANCE,"10");for(var product:RareProduct.values()){values.put(priceKey(product),Integer.toString(product.basePrice()));values.put(stockKey(product,true),"0");values.put(stockKey(product,false),Integer.toString(product.maximumStock()));if(product.heals())values.put(healingKey(product),Integer.toString(product.defaultHealingPercent()));}values.put(PHOENIX_PROTECTION,"5");return Map.copyOf(values);}
private static void validate(Map<String,String> values){whole(values.get(VARIANCE),0,100);for(var product:RareProduct.values()){whole(values.get(priceKey(product)),0,Integer.MAX_VALUE);int min=whole(values.get(stockKey(product,true)),0,Integer.MAX_VALUE),max=whole(values.get(stockKey(product,false)),0,Integer.MAX_VALUE);if(min>max)throw new IllegalArgumentException("Stock minimum exceeds maximum");if(product.heals())whole(values.get(healingKey(product)),0,100);}whole(values.get(PHOENIX_PROTECTION),0,MAX_DEADLINE_SECONDS);}
private static int whole(String value,int minimum,int maximum){var number=new BigInteger(Objects.requireNonNull(value));if(number.compareTo(BigInteger.valueOf(minimum))<0||number.compareTo(BigInteger.valueOf(maximum))>0)throw new IllegalArgumentException();return number.intValueExact();}
private void save(String key,String value)throws IOException{Files.createDirectories(directory);var file=directory.resolve("config.yml");var lockFile=directory.resolve(".config.lock");try(var channel=FileChannel.open(lockFile,StandardOpenOption.CREATE,StandardOpenOption.WRITE,LinkOption.NOFOLLOW_LINKS);var lock=channel.tryLock()){if(lock==null)throw new IOException("Configuration busy");var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(20);options.setCodePointLimit(1_048_576);var yaml=new Yaml(new SafeConstructor(options));Map<String,Object> document=new LinkedHashMap<>();if(Files.exists(file,LinkOption.NOFOLLOW_LINKS)){if(!Files.isRegularFile(file,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe configuration");byte[] bytes;try(var input=Files.newInputStream(file,LinkOption.NOFOLLOW_LINKS)){bytes=input.readNBytes(1_048_577);}if(bytes.length>1_048_576)throw new IOException("Configuration too large");Object loaded=yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());if(loaded instanceof Map<?,?> map)for(var entry:map.entrySet()){if(!(entry.getKey() instanceof String savedKey))throw new IOException("Invalid key");document.put(savedKey,entry.getValue());}}document.put(key,Integer.parseInt(value));byte[] bytes=yaml.dump(document).getBytes(StandardCharsets.UTF_8);Path temporary=Files.createTempFile(directory,".config-rare-",".tmp");try{Files.write(temporary,bytes,StandardOpenOption.TRUNCATE_EXISTING);try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE)){output.force(true);}Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);DurableFiles.forceDirectory(directory);}finally{Files.deleteIfExists(temporary);}}catch(OverlappingFileLockException busy){throw new IOException("Configuration busy",busy);}}
private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
@@ -73,10 +73,11 @@ public final class SanctuarySettings implements AutoCloseable {
public String value(String key) { return effectiveValues.get(key); }
public Sanctuary.Settings settings() { return parse(effectiveValues); }
public synchronized void publish(Update update) {
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
if (update.sequence() <= publishedSequence) return false;
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
return true;
}
public CompletionStage<Update> set(String key, String value) {
@@ -75,10 +75,11 @@ public final class WarBannerSettings implements AutoCloseable {
public String value(String key) { return effectiveValues.get(key); }
public WarBanner.Settings settings() { return parse(effectiveValues); }
public synchronized void publish(Update update) {
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
if (update.sequence() <= publishedSequence) return false;
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
return true;
}
public CompletionStage<Update> set(String key, String value) {
@@ -41,7 +41,7 @@ public final class AdminCharacterIssuer implements AutoCloseable {
if (pending.putIfAbsent(attempt.playerId, attempt) != null) { response.accept(Outcome.BUSY); return; }
try {
attempt.record = allocate.get();
attempt.preparation = storage.prepareCraft(List.of(attempt.record));
attempt.preparation = storage.prepareCraft(List.of(attempt.record),"admin_issue");
attempt.preparation.ready().whenComplete((batch, failure) -> schedule(attempt, batch, failure));
} catch (RuntimeException failure) {
pending.remove(attempt.playerId, attempt);
@@ -20,12 +20,14 @@ import org.bukkit.plugin.Plugin;
public final class DownedController implements Listener,AutoCloseable{
public enum RescueStatus{APPLIED,APPLIED_PENDING_RECOVERY,STALE,EXPIRED,FAILED}
private record Holder(UUID episode)implements InventoryHolder{public Inventory getInventory(){return null;}}
private final Plugin plugin;private final EquipmentSessions sessions;private final DownedDirectory directory;private final Clock clock;private final Consumer<Throwable> failures;private final java.util.Map<UUID,Boolean> gravity=new java.util.HashMap<>();private final java.util.Set<UUID> revertingSlots=new java.util.HashSet<>();private final java.util.Set<UUID> endedProtection=new java.util.HashSet<>();private games.dmg.fantasystick.events.DownedEventBridge events;private org.bukkit.scheduler.BukkitTask pulse;private boolean closed;
private final Plugin plugin;private final EquipmentSessions sessions;private final DownedDirectory directory;private final Clock clock;private final Consumer<Throwable> failures;private final java.util.Map<UUID,Boolean> gravity=new java.util.HashMap<>();private final java.util.Set<UUID> revertingSlots=new java.util.HashSet<>();private final java.util.Set<UUID> endedProtection=new java.util.HashSet<>();private games.dmg.fantasystick.events.DownedEventBridge events;private games.dmg.fantasystick.shop.PhoenixDownService phoenix;private org.bukkit.scheduler.BukkitTask pulse;private boolean closed;
public DownedController(Plugin plugin,EquipmentSessions sessions,DownedDirectory directory,Clock clock,Consumer<Throwable> failures){this.plugin=Objects.requireNonNull(plugin);this.sessions=Objects.requireNonNull(sessions);this.directory=Objects.requireNonNull(directory);this.clock=Objects.requireNonNull(clock);this.failures=Objects.requireNonNull(failures);}
public void attachEvents(games.dmg.fantasystick.events.DownedEventBridge events){if(pulse!=null||this.events!=null)throw new IllegalStateException("Downed events already attached or controller installed");this.events=Objects.requireNonNull(events);}
public void attachPhoenix(games.dmg.fantasystick.shop.PhoenixDownService phoenix){if(pulse!=null||this.phoenix!=null)throw new IllegalStateException("Phoenix Down already attached or controller installed");this.phoenix=Objects.requireNonNull(phoenix);}
public void install(){if(pulse!=null)throw new IllegalStateException("Downed controller already installed");pulse=plugin.getServer().getScheduler().runTaskTimer(plugin,this::pulse,20L,20L);var existing=directory.all();if(events!=null)events.reconcile(existing);for(var episode:existing){if(episode.outcome()==DownedEpisode.Outcome.REVIVED&&episode.protectionUntil()!=null&&!clock.instant().isBefore(episode.protectionUntil()))endedProtection.add(episode.id());if(episode.phase()==DownedEpisode.Phase.PENDING){if(clock.instant().isBefore(episode.expiresAt()))open(episode);else finalizeDeath(episode,"timeout");}else if(episode.outcome()==DownedEpisode.Outcome.FINAL_DEATH&&!episode.published())sessions.finalizeDowned(episode);}}
public void open(DownedEpisode episode){if(closed||episode.phase()!=DownedEpisode.Phase.PENDING)return;var player=plugin.getServer().getPlayer(episode.player());if(player==null||!player.isOnline())return;freeze(player);var inventory=plugin.getServer().createInventory(new Holder(episode.id()),27,Component.text("Downed — "+remaining(episode)+"s"));inventory.setItem(11,item(Material.SKELETON_SKULL,"Accept Death",true));inventory.setItem(13,item(Material.GRAY_DYE,"Use Phoenix Down — unavailable",false));inventory.setItem(15,item(Material.GRAY_DYE,"Wait for Revive",false));player.openInventory(inventory);}
public void open(DownedEpisode episode){if(closed||episode.phase()!=DownedEpisode.Phase.PENDING)return;var player=plugin.getServer().getPlayer(episode.player());if(player==null||!player.isOnline())return;freeze(player);var inventory=plugin.getServer().createInventory(new Holder(episode.id()),27,Component.text("Downed — "+remaining(episode)+"s"));inventory.setItem(11,item(Material.SKELETON_SKULL,"Accept Death",true));var quantity=phoenix==null?java.math.BigInteger.ZERO:phoenix.quantity(episode.character());inventory.setItem(13,quantity.signum()>0?phoenix(quantity):item(Material.GRAY_DYE,"Use Phoenix Down — unavailable",false));inventory.setItem(15,item(Material.GRAY_DYE,"Wait for Revive",false));player.openInventory(inventory);}
private static ItemStack item(Material material,String name,boolean action){var item=new ItemStack(material);var meta=item.getItemMeta();meta.displayName(Component.text(name));if(!action)meta.lore(java.util.List.of(Component.text("Waiting does not pause the countdown.")));item.setItemMeta(meta);return item;}
private static ItemStack phoenix(java.math.BigInteger quantity){var item=new ItemStack(Material.FEATHER);var meta=item.getItemMeta();meta.displayName(Component.text("Use Phoenix Down — "+quantity+" available"));meta.lore(java.util.List.of(Component.text("Consumes one only if this rescue wins.")));item.setItemMeta(meta);return item;}
private long remaining(DownedEpisode episode){var remaining=java.time.Duration.between(clock.instant(),episode.expiresAt());if(remaining.isNegative()||remaining.isZero())return 0;return remaining.getSeconds()+(remaining.getNano()==0?0:1);}
private void pulse(){if(closed)return;for(var episode:directory.all()){if(episode.phase()!=DownedEpisode.Phase.PENDING){if(episode.outcome()==DownedEpisode.Outcome.REVIVED&&episode.protectionUntil()!=null&&!clock.instant().isBefore(episode.protectionUntil())&&endedProtection.add(episode.id())&&events!=null)events.protectionEnded(episode);continue;}if(!clock.instant().isBefore(episode.expiresAt()))finalizeDeath(episode,"timeout");else{var player=plugin.getServer().getPlayer(episode.player());if(player!=null&&player.isOnline()){if(player.getOpenInventory().getTopInventory().getHolder() instanceof Holder holder&&holder.episode().equals(episode.id()))player.getOpenInventory().setTitle("Downed — "+remaining(episode)+"s");else open(episode);}}}}
public boolean downed(Player player){return episode(player)!=null;}
@@ -33,12 +35,12 @@ public final class DownedController implements Listener,AutoCloseable{
private void freeze(Player player){gravity.putIfAbsent(player.getUniqueId(),player.hasGravity());player.setGravity(false);player.setFallDistance(0);player.setVelocity(new org.bukkit.util.Vector());if(player.isInsideVehicle())player.leaveVehicle();}
private void release(Player player){var original=gravity.remove(player.getUniqueId());if(original!=null)player.setGravity(original);revertingSlots.remove(player.getUniqueId());}
public void revive(Player target,Duration protection,Consumer<RescueStatus> result){revive(target,protection,null,result);}
public void revive(Player target,Duration protection,DownedEpisode.Decision decision,Consumer<RescueStatus> result){var episode=sessions.downed(target.getUniqueId()).orElse(null);if(episode==null){result.accept(RescueStatus.STALE);return;}var operation=decision==null?UUID.randomUUID():decision.actionId();directory.claim(episode.id(),DownedEpisode.Outcome.REVIVED,operation,decision,protection,java.util.List.of()).whenComplete((claim,failure)->schedule(()->{if(failure!=null){report(failure);result.accept(RescueStatus.FAILED);return;}if(claim.status()==DownedDirectory.ClaimStatus.EXPIRED){result.accept(RescueStatus.EXPIRED);return;}if(claim.status()!=DownedDirectory.ClaimStatus.APPLIED){result.accept(RescueStatus.STALE);return;}var resolved=claim.episode().orElseThrow();if(events!=null){events.resolved(resolved);events.abilityResolved(resolved);}sessions.reviveDowned(resolved,applied->{if(!applied){result.accept(RescueStatus.FAILED);return;}release(target);if(events!=null)events.outcomePublished(resolved);directory.publish(resolved.id(),resolved.outcomeOperation()).whenComplete((published,publishFailure)->schedule(()->{if(publishFailure!=null){report(publishFailure);result.accept(RescueStatus.APPLIED_PENDING_RECOVERY);}else{if(events!=null)events.outcomePublished(published);result.accept(RescueStatus.APPLIED);}}));});}));}
public void revive(Player target,Duration protection,DownedEpisode.Decision decision,Consumer<RescueStatus> result){var episode=sessions.downed(target.getUniqueId()).orElse(null);if(episode==null){result.accept(RescueStatus.STALE);return;}var operation=decision==null?UUID.randomUUID():decision.actionId();directory.claim(episode.id(),DownedEpisode.Outcome.REVIVED,operation,decision,protection,java.util.List.of()).whenComplete((claim,failure)->schedule(()->{if(failure!=null){report(failure);result.accept(RescueStatus.FAILED);return;}if(claim.status()==DownedDirectory.ClaimStatus.EXPIRED){result.accept(RescueStatus.EXPIRED);return;}if(claim.status()!=DownedDirectory.ClaimStatus.APPLIED){result.accept(RescueStatus.STALE);return;}var resolved=claim.episode().orElseThrow();if(events!=null&&!"phoenix_down".equals(resolved.decision()==null?null:resolved.decision().source())){events.resolved(resolved);events.abilityResolved(resolved);}sessions.reviveDowned(resolved,applied->{if(!applied){result.accept(RescueStatus.FAILED);return;}release(target);if(events!=null)events.outcomePublished(resolved);directory.publish(resolved.id(),resolved.outcomeOperation()).whenComplete((published,publishFailure)->schedule(()->{if(publishFailure!=null){report(publishFailure);result.accept(RescueStatus.APPLIED_PENDING_RECOVERY);}else{if(events!=null)events.outcomePublished(published);result.accept(RescueStatus.APPLIED);}}));});}));}
private void finalizeDeath(DownedEpisode episode,String source){java.util.List<DownedEpisode.Reward> rewards;try{rewards=sessions.finalRewards(episode);}catch(RuntimeException failure){report(failure);return;}var operation=UUID.randomUUID();var decision=new DownedEpisode.Decision(source,"accept_death".equals(source)?episode.player():null,"accept_death".equals(source)?episode.playerName():null,"accept_death".equals(source)?episode.character():null,"accept_death".equals(source)?episode.characterName():null,"accept_death".equals(source)?episode.session():null,operation,null,null,null,null);directory.claim(episode.id(),DownedEpisode.Outcome.FINAL_DEATH,operation,decision,Duration.ZERO,rewards).whenComplete((claim,failure)->schedule(()->{if(failure!=null){report(failure);return;}if(claim.status()==DownedDirectory.ClaimStatus.APPLIED){var resolved=claim.episode().orElseThrow();if(events!=null)events.resolved(resolved);var player=plugin.getServer().getPlayer(episode.player());if(player!=null)release(player);sessions.finalizeDowned(resolved);}}));}
@EventHandler(priority=EventPriority.HIGHEST)public void click(org.bukkit.event.inventory.InventoryClickEvent event){if(event.getWhoClicked() instanceof Player actor&&sessions.downed(actor.getUniqueId()).isPresent())event.setCancelled(true);if(!(event.getView().getTopInventory().getHolder() instanceof Holder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getRawSlot()!=11)return;var episode=directory.all().stream().filter(value->value.id().equals(holder.episode())&&value.player().equals(player.getUniqueId())&&value.phase()==DownedEpisode.Phase.PENDING).findFirst().orElse(null);if(episode!=null)finalizeDeath(episode,"accept_death");}
@EventHandler(priority=EventPriority.HIGHEST)public void click(org.bukkit.event.inventory.InventoryClickEvent event){if(event.getWhoClicked() instanceof Player actor&&sessions.downed(actor.getUniqueId()).isPresent())event.setCancelled(true);if(!(event.getView().getTopInventory().getHolder() instanceof Holder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player))return;if(event.getRawSlot()==13&&phoenix!=null){phoenix.use(player,holder.episode(),outcome->{if(outcome.status()!=games.dmg.fantasystick.shop.PhoenixDownService.Status.APPLIED&&outcome.status()!=games.dmg.fantasystick.shop.PhoenixDownService.Status.APPLIED_PENDING_RECOVERY)player.sendMessage("Phoenix Down use refused: "+outcome.status().name().toLowerCase(java.util.Locale.ROOT).replace('_',' ')+".");});return;}if(event.getRawSlot()!=11)return;var episode=directory.all().stream().filter(value->value.id().equals(holder.episode())&&value.player().equals(player.getUniqueId())&&value.phase()==DownedEpisode.Phase.PENDING).findFirst().orElse(null);if(episode!=null)finalizeDeath(episode,"accept_death");}
@EventHandler(priority=EventPriority.HIGHEST)public void drag(org.bukkit.event.inventory.InventoryDragEvent event){if(event.getWhoClicked() instanceof Player actor&&sessions.downed(actor.getUniqueId()).isPresent()||event.getView().getTopInventory().getHolder() instanceof Holder)event.setCancelled(true);}
@EventHandler(priority=EventPriority.MONITOR)public void closeMenu(org.bukkit.event.inventory.InventoryCloseEvent event){if(!(event.getView().getTopInventory().getHolder() instanceof Holder holder)||closed)return;plugin.getServer().getScheduler().runTask(plugin,()->directory.all().stream().filter(value->value.id().equals(holder.episode())&&value.phase()==DownedEpisode.Phase.PENDING).findFirst().ifPresent(this::open));}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void damage(org.bukkit.event.entity.EntityDamageEvent event){if(!(event.getEntity() instanceof Player player))return;var episode=sessions.downed(player.getUniqueId()).orElse(null);boolean protectedRevive=episode==null&&sessions.downedProtected(player.getUniqueId());if(episode!=null||protectedRevive){event.setCancelled(true);if(events!=null){var authority=episode!=null?episode:directory.all().stream().filter(value->value.player().equals(player.getUniqueId())&&value.outcome()==DownedEpisode.Outcome.REVIVED&&value.protectionUntil()!=null&&clock.instant().isBefore(value.protectionUntil())).max(java.util.Comparator.comparing(DownedEpisode::resolvedAt)).orElse(null);if(authority!=null)events.prevented(authority,episode!=null?"downed_waiting":"revive_protection",Math.max(0,event.getFinalDamage()));}}}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void damage(org.bukkit.event.entity.EntityDamageEvent event){if(!(event.getEntity() instanceof Player player))return;var episode=sessions.downed(player.getUniqueId()).orElse(null);boolean protectedRevive=episode==null&&sessions.downedProtected(player.getUniqueId());if(episode!=null||protectedRevive){event.setCancelled(true);if(events!=null){var authority=episode!=null?episode:directory.all().stream().filter(value->value.player().equals(player.getUniqueId())&&value.outcome()==DownedEpisode.Outcome.REVIVED&&value.protectionUntil()!=null&&clock.instant().isBefore(value.protectionUntil())).max(java.util.Comparator.comparing(DownedEpisode::resolvedAt)).orElse(null);if(authority!=null)events.prevented(authority,episode!=null?"downed_waiting":authority.decision()!=null&&"phoenix_down".equals(authority.decision().source())?"phoenix_down_protection":"revive_protection",Math.max(0,event.getFinalDamage()));}}}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void heal(org.bukkit.event.entity.EntityRegainHealthEvent event){if(event.getEntity() instanceof Player player&&sessions.downed(player.getUniqueId()).isPresent())event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void move(org.bukkit.event.player.PlayerMoveEvent event){var episode=sessions.downed(event.getPlayer().getUniqueId()).orElse(null);if(episode==null||event.getTo()==null)return;if(event.getFrom().getX()!=event.getTo().getX()||event.getFrom().getY()!=event.getTo().getY()||event.getFrom().getZ()!=event.getTo().getZ())event.setTo(event.getFrom());}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void teleport(org.bukkit.event.player.PlayerTeleportEvent event){if(sessions.downed(event.getPlayer().getUniqueId()).isPresent()&&!sessions.downedRelocation(event.getPlayer().getUniqueId()))event.setCancelled(true);}
@@ -33,14 +33,15 @@ public final class DownedDirectory implements AutoCloseable{
private Map<UUID,DownedEpisode> load()throws IOException{if(Files.notExists(file))return Map.of();try{if(!Files.isRegularFile(file,LinkOption.NOFOLLOW_LINKS)||Files.isSymbolicLink(file)||Files.size(file)>1_048_576)throw new IllegalArgumentException();Object decoded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));if(!(decoded instanceof Map<?,?> root)||!"1".equals(String.valueOf(root.get("schema-version"))))throw new IllegalArgumentException();var result=new LinkedHashMap<UUID,DownedEpisode>();for(Object raw:list(root.get("episodes"))){var row=map(raw);var value=decode(row);if(result.put(value.character(),value)!=null)throw new IllegalArgumentException();}return Map.copyOf(result);}catch(RuntimeException invalid){throw new IOException("Invalid downed state",invalid);}}
private void save(Map<UUID,DownedEpisode> state)throws IOException{Files.createDirectories(file.getParent());var root=new LinkedHashMap<String,Object>();root.put("schema-version","1");root.put("episodes",state.values().stream().sorted(Comparator.comparing(DownedEpisode::character)).map(this::encode).toList());Path temporary=Files.createTempFile(file.getParent(),".downed-",".tmp");try{byte[] bytes=yaml().dump(root).getBytes(StandardCharsets.UTF_8);try(var out=FileChannel.open(temporary,StandardOpenOption.WRITE)){var buffer=ByteBuffer.wrap(bytes);while(buffer.hasRemaining())out.write(buffer);out.force(true);}Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);try(var directory=FileChannel.open(file.getParent(),StandardOpenOption.READ)){directory.force(true);}snapshot=Map.copyOf(state);}finally{Files.deleteIfExists(temporary);}}
private Map<String,Object> encode(DownedEpisode value){var row=new LinkedHashMap<String,Object>();row.put("id",value.id().toString());row.put("character",value.character().toString());row.put("player",value.player().toString());row.put("session",value.session().toString());row.put("world",value.world().toString());row.put("x",BigDecimal.valueOf(value.x()).toPlainString());row.put("y",BigDecimal.valueOf(value.y()).toPlainString());row.put("z",BigDecimal.valueOf(value.z()).toPlainString());row.put("yaw",Float.toString(value.yaw()));row.put("pitch",Float.toString(value.pitch()));row.put("entry-world",value.entryWorld().toString());row.put("entry-x",BigDecimal.valueOf(value.entryX()).toPlainString());row.put("entry-y",BigDecimal.valueOf(value.entryY()).toPlainString());row.put("entry-z",BigDecimal.valueOf(value.entryZ()).toPlainString());row.put("entry-yaw",Float.toString(value.entryYaw()));row.put("entry-pitch",Float.toString(value.entryPitch()));row.put("downed-at",value.downedAt().toString());row.put("expires-at",value.expiresAt().toString());row.put("attacker",value.attribution().attacker()==null?null:value.attribution().attacker().toString());row.put("cause",value.attribution().cause());row.put("player-defeat",value.attribution().playerDefeat());row.put("contributors",value.attribution().contributors().stream().map(UUID::toString).collect(java.util.stream.Collectors.toCollection(ArrayList::new)));row.put("victim-level",value.attribution().victimLevel());row.put("victim-coins",value.attribution().victimCoins().toString());row.put("character-name",value.characterName());row.put("player-name",value.playerName());row.put("character-revision",value.characterRevision()==null?null:value.characterRevision().toString());row.put("phase",value.phase().name());row.put("outcome",value.outcome()==null?null:value.outcome().name());row.put("outcome-operation",value.outcomeOperation()==null?null:value.outcomeOperation().toString());row.put("resolved-at",value.resolvedAt()==null?null:value.resolvedAt().toString());row.put("protection-until",value.protectionUntil()==null?null:value.protectionUntil().toString());var decision=value.decision();row.put("decision-source",decision==null?null:decision.source());row.put("decision-actor-player",decision==null||decision.actorPlayer()==null?null:decision.actorPlayer().toString());row.put("decision-actor-player-name",decision==null?null:decision.actorPlayerName());row.put("decision-actor-character",decision==null||decision.actorCharacter()==null?null:decision.actorCharacter().toString());row.put("decision-actor-character-name",decision==null?null:decision.actorCharacterName());row.put("decision-actor-session",decision==null||decision.actorSession()==null?null:decision.actorSession().toString());row.put("decision-action",decision==null?null:decision.actionId().toString());row.put("decision-cast-at",decision==null||decision.castAt()==null?null:decision.castAt().toString());row.put("decision-cooldown-until",decision==null||decision.cooldownUntil()==null?null:decision.cooldownUntil().toString());row.put("decision-final-range",decision==null||decision.finalRange()==null?null:Double.toString(decision.finalRange()));row.put("decision-line-of-sight",decision==null?null:decision.lineOfSight());row.put("final-rewards",value.finalRewards().stream().map(reward->{var saved=new LinkedHashMap<String,Object>();saved.put("character",reward.character().toString());saved.put("experience",reward.experience().toString());saved.put("coins",reward.coins().toString());return saved;}).collect(java.util.stream.Collectors.toCollection(ArrayList::new)));row.put("published",value.published());return row;}
private Map<String,Object> encode(DownedEpisode value){var row=new LinkedHashMap<String,Object>();row.put("id",value.id().toString());row.put("character",value.character().toString());row.put("player",value.player().toString());row.put("session",value.session().toString());row.put("world",value.world().toString());row.put("x",BigDecimal.valueOf(value.x()).toPlainString());row.put("y",BigDecimal.valueOf(value.y()).toPlainString());row.put("z",BigDecimal.valueOf(value.z()).toPlainString());row.put("yaw",Float.toString(value.yaw()));row.put("pitch",Float.toString(value.pitch()));row.put("entry-world",value.entryWorld().toString());row.put("entry-x",BigDecimal.valueOf(value.entryX()).toPlainString());row.put("entry-y",BigDecimal.valueOf(value.entryY()).toPlainString());row.put("entry-z",BigDecimal.valueOf(value.entryZ()).toPlainString());row.put("entry-yaw",Float.toString(value.entryYaw()));row.put("entry-pitch",Float.toString(value.entryPitch()));row.put("downed-at",value.downedAt().toString());row.put("expires-at",value.expiresAt().toString());row.put("attacker",value.attribution().attacker()==null?null:value.attribution().attacker().toString());row.put("cause",value.attribution().cause());row.put("player-defeat",value.attribution().playerDefeat());row.put("contributors",value.attribution().contributors().stream().map(UUID::toString).collect(java.util.stream.Collectors.toCollection(ArrayList::new)));row.put("victim-level",value.attribution().victimLevel());row.put("victim-coins",value.attribution().victimCoins().toString());row.put("character-name",value.characterName());row.put("player-name",value.playerName());row.put("character-revision",value.characterRevision()==null?null:value.characterRevision().toString());row.put("phase",value.phase().name());row.put("outcome",value.outcome()==null?null:value.outcome().name());row.put("outcome-operation",value.outcomeOperation()==null?null:value.outcomeOperation().toString());row.put("resolved-at",value.resolvedAt()==null?null:value.resolvedAt().toString());row.put("protection-until",value.protectionUntil()==null?null:value.protectionUntil().toString());var decision=value.decision();row.put("decision-source",decision==null?null:decision.source());row.put("decision-actor-player",decision==null||decision.actorPlayer()==null?null:decision.actorPlayer().toString());row.put("decision-actor-player-name",decision==null?null:decision.actorPlayerName());row.put("decision-actor-character",decision==null||decision.actorCharacter()==null?null:decision.actorCharacter().toString());row.put("decision-actor-character-name",decision==null?null:decision.actorCharacterName());row.put("decision-actor-session",decision==null||decision.actorSession()==null?null:decision.actorSession().toString());row.put("decision-action",decision==null?null:decision.actionId().toString());row.put("decision-cast-at",decision==null||decision.castAt()==null?null:decision.castAt().toString());row.put("decision-cooldown-until",decision==null||decision.cooldownUntil()==null?null:decision.cooldownUntil().toString());row.put("decision-final-range",decision==null||decision.finalRange()==null?null:Double.toString(decision.finalRange()));row.put("decision-line-of-sight",decision==null?null:decision.lineOfSight());var phoenix=decision==null?null:decision.phoenix();row.put("decision-phoenix-quantity-before",phoenix==null?null:phoenix.quantityBefore().toString());row.put("decision-phoenix-quantity-after",phoenix==null?null:phoenix.quantityAfter().toString());row.put("decision-phoenix-health-before",phoenix==null?null:phoenix.healthBefore().toPlainString());row.put("decision-phoenix-health-after",phoenix==null?null:phoenix.healthAfter().toPlainString());row.put("decision-phoenix-maximum",phoenix==null?null:phoenix.maximum().toPlainString());row.put("decision-phoenix-entry-point",phoenix==null?null:phoenix.entryPoint());row.put("final-rewards",value.finalRewards().stream().map(reward->{var saved=new LinkedHashMap<String,Object>();saved.put("character",reward.character().toString());saved.put("experience",reward.experience().toString());saved.put("coins",reward.coins().toString());saved.put("unit-id",reward.unitId());saved.put("unit-count",reward.unitCount());return saved;}).collect(java.util.stream.Collectors.toCollection(ArrayList::new)));row.put("published",value.published());return row;}
private DownedEpisode decode(Map<?,?> row){
String attacker=optional(row,"attacker"),outcome=optional(row,"outcome"),operation=optional(row,"outcome-operation"),resolved=optional(row,"resolved-at"),protection=optional(row,"protection-until"),revision=optional(row,"character-revision"),characterName=optional(row,"character-name"),playerName=optional(row,"player-name"),decisionSource=optional(row,"decision-source"),decisionPlayer=optional(row,"decision-actor-player"),decisionPlayerName=optional(row,"decision-actor-player-name"),decisionCharacter=optional(row,"decision-actor-character"),decisionCharacterName=optional(row,"decision-actor-character-name"),decisionSession=optional(row,"decision-actor-session"),decisionAction=optional(row,"decision-action"),decisionCastAt=optional(row,"decision-cast-at"),decisionCooldown=optional(row,"decision-cooldown-until"),decisionRange=optional(row,"decision-final-range");
UUID world=uuid(row,"world"),entryWorld=row.containsKey("entry-world")?uuid(row,"entry-world"):world;
double x=Double.parseDouble(text(row,"x")),y=Double.parseDouble(text(row,"y")),z=Double.parseDouble(text(row,"z"));
float yaw=Float.parseFloat(text(row,"yaw")),pitch=Float.parseFloat(text(row,"pitch"));
var decision=decisionSource==null?null:new DownedEpisode.Decision(decisionSource,decisionPlayer==null?null:UUID.fromString(decisionPlayer),decisionPlayerName,decisionCharacter==null?null:UUID.fromString(decisionCharacter),decisionCharacterName,decisionSession==null?null:UUID.fromString(decisionSession),UUID.fromString(decisionAction),decisionCastAt==null?null:Instant.parse(decisionCastAt),decisionCooldown==null?null:Instant.parse(decisionCooldown),decisionRange==null?null:Double.valueOf(decisionRange),row.get("decision-line-of-sight")==null?null:Boolean.valueOf(String.valueOf(row.get("decision-line-of-sight"))));
return new DownedEpisode(uuid(row,"id"),uuid(row,"character"),uuid(row,"player"),uuid(row,"session"),world,x,y,z,yaw,pitch,entryWorld,row.containsKey("entry-x")?Double.parseDouble(text(row,"entry-x")):x,row.containsKey("entry-y")?Double.parseDouble(text(row,"entry-y")):y,row.containsKey("entry-z")?Double.parseDouble(text(row,"entry-z")):z,row.containsKey("entry-yaw")?Float.parseFloat(text(row,"entry-yaw")):yaw,row.containsKey("entry-pitch")?Float.parseFloat(text(row,"entry-pitch")):pitch,Instant.parse(text(row,"downed-at")),Instant.parse(text(row,"expires-at")),new DownedEpisode.Attribution(attacker==null?null:UUID.fromString(attacker),text(row,"cause"),Boolean.parseBoolean(String.valueOf(row.containsKey("player-defeat")?row.get("player-defeat"):false)),row.containsKey("contributors")?list(row.get("contributors")).stream().map(String::valueOf).map(UUID::fromString).toList():List.of(),row.containsKey("victim-level")?Integer.parseInt(String.valueOf(row.get("victim-level"))):0,row.containsKey("victim-coins")?new java.math.BigInteger(String.valueOf(row.get("victim-coins"))):java.math.BigInteger.ZERO),characterName,playerName,revision==null?null:new java.math.BigInteger(revision),DownedEpisode.Phase.valueOf(text(row,"phase")),outcome==null?null:DownedEpisode.Outcome.valueOf(outcome),operation==null?null:UUID.fromString(operation),resolved==null?null:Instant.parse(resolved),protection==null?null:Instant.parse(protection),decision,list(row.get("final-rewards")).stream().map(DownedDirectory::map).map(reward->new DownedEpisode.Reward(uuid(reward,"character"),new java.math.BigInteger(text(reward,"experience")),new java.math.BigInteger(text(reward,"coins")))).toList(),Boolean.parseBoolean(String.valueOf(row.containsKey("published")?row.get("published"):false)));
var phoenixQuantity=optional(row,"decision-phoenix-quantity-before");var phoenix=phoenixQuantity==null?null:new DownedEpisode.Phoenix(new java.math.BigInteger(phoenixQuantity),new java.math.BigInteger(text(row,"decision-phoenix-quantity-after")),new java.math.BigDecimal(text(row,"decision-phoenix-health-before")),new java.math.BigDecimal(text(row,"decision-phoenix-health-after")),new java.math.BigDecimal(text(row,"decision-phoenix-maximum")),text(row,"decision-phoenix-entry-point"));
var decision=decisionSource==null?null:new DownedEpisode.Decision(decisionSource,decisionPlayer==null?null:UUID.fromString(decisionPlayer),decisionPlayerName,decisionCharacter==null?null:UUID.fromString(decisionCharacter),decisionCharacterName,decisionSession==null?null:UUID.fromString(decisionSession),UUID.fromString(decisionAction),decisionCastAt==null?null:Instant.parse(decisionCastAt),decisionCooldown==null?null:Instant.parse(decisionCooldown),decisionRange==null?null:Double.valueOf(decisionRange),row.get("decision-line-of-sight")==null?null:Boolean.valueOf(String.valueOf(row.get("decision-line-of-sight"))),phoenix);
return new DownedEpisode(uuid(row,"id"),uuid(row,"character"),uuid(row,"player"),uuid(row,"session"),world,x,y,z,yaw,pitch,entryWorld,row.containsKey("entry-x")?Double.parseDouble(text(row,"entry-x")):x,row.containsKey("entry-y")?Double.parseDouble(text(row,"entry-y")):y,row.containsKey("entry-z")?Double.parseDouble(text(row,"entry-z")):z,row.containsKey("entry-yaw")?Float.parseFloat(text(row,"entry-yaw")):yaw,row.containsKey("entry-pitch")?Float.parseFloat(text(row,"entry-pitch")):pitch,Instant.parse(text(row,"downed-at")),Instant.parse(text(row,"expires-at")),new DownedEpisode.Attribution(attacker==null?null:UUID.fromString(attacker),text(row,"cause"),Boolean.parseBoolean(String.valueOf(row.containsKey("player-defeat")?row.get("player-defeat"):false)),row.containsKey("contributors")?list(row.get("contributors")).stream().map(String::valueOf).map(UUID::fromString).toList():List.of(),row.containsKey("victim-level")?Integer.parseInt(String.valueOf(row.get("victim-level"))):0,row.containsKey("victim-coins")?new java.math.BigInteger(String.valueOf(row.get("victim-coins"))):java.math.BigInteger.ZERO),characterName,playerName,revision==null?null:new java.math.BigInteger(revision),DownedEpisode.Phase.valueOf(text(row,"phase")),outcome==null?null:DownedEpisode.Outcome.valueOf(outcome),operation==null?null:UUID.fromString(operation),resolved==null?null:Instant.parse(resolved),protection==null?null:Instant.parse(protection),decision,list(row.get("final-rewards")).stream().map(DownedDirectory::map).map(reward->new DownedEpisode.Reward(uuid(reward,"character"),new java.math.BigInteger(text(reward,"experience")),new java.math.BigInteger(text(reward,"coins")),reward.containsKey("unit-id")?text(reward,"unit-id"):"legacy:"+text(reward,"character"),reward.containsKey("unit-count")?Integer.parseInt(String.valueOf(reward.get("unit-count"))):1)).toList(),Boolean.parseBoolean(String.valueOf(row.containsKey("published")?row.get("published"):false)));
}
private static Yaml yaml(){var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(0);options.setCodePointLimit(1_048_576);return new Yaml(new SafeConstructor(options));}private static List<?> list(Object value){return value==null?List.of():(List<?>)value;}private static Map<?,?> map(Object value){return(Map<?,?>)value;}private static String text(Map<?,?> map,String key){Object value=map.get(key);if(!(value instanceof String text)||text.isBlank())throw new IllegalArgumentException(key);return text;}private static String optional(Map<?,?> map,String key){Object value=map.get(key);return value==null?null:text(map,key);}private static UUID uuid(Map<?,?> map,String key){return UUID.fromString(text(map,key));}
private <T>CompletableFuture<T> submit(Callable<T> work){var result=new CompletableFuture<T>();synchronized(this){if(closed){result.completeExceptionally(new IOException("Downed directory closed"));return result;}pending++;}worker.execute(()->{try{result.complete(work.call());}catch(Throwable failure){result.completeExceptionally(failure);}finally{finished();}});return result;}private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
@@ -19,12 +19,14 @@ public record DownedEpisode(UUID id, UUID character, UUID player, UUID session,
public enum Phase { PENDING, RESOLVED }
public enum Outcome { REVIVED, FINAL_DEATH }
public record Reward(UUID character, BigInteger experience, BigInteger coins) {
public record Reward(UUID character, BigInteger experience, BigInteger coins,String unitId,int unitCount) {
public Reward(UUID character,BigInteger experience,BigInteger coins){this(character,experience,coins,"legacy:"+character,1);}
public Reward {
Objects.requireNonNull(character);
Objects.requireNonNull(experience);
Objects.requireNonNull(coins);
if (experience.signum() < 0 || coins.signum() < 0) {
Objects.requireNonNull(unitId);
if (experience.signum() < 0 || coins.signum() < 0 || unitId.isBlank() || unitCount < 1) {
throw new IllegalArgumentException("Invalid downed reward");
}
}
@@ -45,19 +47,34 @@ public record DownedEpisode(UUID id, UUID character, UUID player, UUID session,
}
}
public record Phoenix(BigInteger quantityBefore, BigInteger quantityAfter,
java.math.BigDecimal healthBefore, java.math.BigDecimal healthAfter,
java.math.BigDecimal maximum, String entryPoint) {
public Phoenix {
Objects.requireNonNull(quantityBefore);Objects.requireNonNull(quantityAfter);
Objects.requireNonNull(healthBefore);Objects.requireNonNull(healthAfter);Objects.requireNonNull(maximum);
if(quantityBefore.signum()<=0||!quantityAfter.equals(quantityBefore.subtract(BigInteger.ONE))
||healthBefore.signum()<0||maximum.signum()<=0||healthAfter.compareTo(maximum)!=0
||healthBefore.compareTo(maximum)>0||!"downed_menu".equals(entryPoint))throw new IllegalArgumentException("Invalid Phoenix Down decision");
}
}
/** Durable winning decision context; nullable actor fields represent an automatic system timeout. */
public record Decision(String source, UUID actorPlayer, String actorPlayerName,
UUID actorCharacter, String actorCharacterName, UUID actorSession,
UUID actionId, Instant castAt, Instant cooldownUntil,
Double finalRange, Boolean lineOfSight) {
Double finalRange, Boolean lineOfSight, Phoenix phoenix) {
public Decision(String source,UUID actorPlayer,String actorPlayerName,UUID actorCharacter,String actorCharacterName,UUID actorSession,UUID actionId,Instant castAt,Instant cooldownUntil,Double finalRange,Boolean lineOfSight){this(source,actorPlayer,actorPlayerName,actorCharacter,actorCharacterName,actorSession,actionId,castAt,cooldownUntil,finalRange,lineOfSight,null);}
public Decision {
if (source == null || !List.of("revive", "accept_death", "timeout").contains(source)
if (source == null || !List.of("revive", "phoenix_down", "accept_death", "timeout").contains(source)
|| actionId == null || (actorPlayer == null) != (actorPlayerName == null)
|| (actorCharacter == null) != (actorCharacterName == null)
|| (actorCharacter == null) != (actorSession == null)
|| finalRange != null && (!Double.isFinite(finalRange) || finalRange < 0)
|| "revive".equals(source) && (actorPlayer == null || actorCharacter == null
|| castAt == null || cooldownUntil == null || finalRange == null || lineOfSight == null)) {
|| castAt == null || cooldownUntil == null || finalRange == null || lineOfSight == null)
|| "phoenix_down".equals(source) && (actorPlayer == null || actorCharacter == null || castAt == null || phoenix == null)
|| !"phoenix_down".equals(source) && phoenix != null) {
throw new IllegalArgumentException("Invalid downed decision");
}
}
@@ -27,6 +27,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private volatile boolean closed;
private games.dmg.fantasystick.persistence.AdventureSettings settings;
private games.dmg.fantasystick.persistence.FireballSettings fireballSettings;
private games.dmg.fantasystick.events.AbilityConfigEventBridge abilityConfigEvents;
private games.dmg.fantasystick.ability.Fireball fireball;
private games.dmg.fantasystick.persistence.FrostShardSettings frostSettings;
private games.dmg.fantasystick.ability.FrostShard frostShard;
@@ -73,6 +74,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.persistence.PartyEconomySettings partyEconomySettings;
private games.dmg.fantasystick.persistence.GearSettings gearSettings;
private games.dmg.fantasystick.persistence.CoinSettings coinSettings;
private games.dmg.fantasystick.events.CoinEventBridge coinEvents;
private games.dmg.fantasystick.persistence.HardMobXpSettings hardMobXpSettings;
private games.dmg.fantasystick.persistence.AdventureQuestSettings adventureQuestSettings;
private games.dmg.fantasystick.adventure.AdventureQuestCommands adventureQuestCommands;
@@ -84,6 +86,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private OutOfCombatRecovery outOfCombatRecovery;
private games.dmg.fantasystick.persistence.QuickerRegenerationSettings quickerRegenerationSettings;
private games.dmg.fantasystick.passive.QuickerRegenerationI quickerRegeneration;
private games.dmg.fantasystick.events.PassiveEventBridge passiveEvents;
private games.dmg.fantasystick.persistence.QuickerRegenerationIISettings quickerRegenerationIISettings;
private games.dmg.fantasystick.passive.QuickerRegenerationII quickerRegenerationII;
private games.dmg.fantasystick.persistence.HealthStealSettings healthStealSettings;
@@ -91,6 +94,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.passive.PassiveRegistry passiveRegistry;
private games.dmg.fantasystick.party.PartyRegistrarDirectory partyRegistrars;
private games.dmg.fantasystick.party.PartyRegistrarController partyRegistrarController;
private games.dmg.fantasystick.events.PartyEventBridge partyEvents;
private games.dmg.fantasystick.training.TrainingDummyDirectory trainingDummies;
private games.dmg.fantasystick.training.TrainingDummyController trainingDummyController;
private games.dmg.fantasystick.shop.FantasyArmorerDirectory armorers;
@@ -334,6 +338,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
public void attachPartyEconomySettings(games.dmg.fantasystick.persistence.PartyEconomySettings settings){if(closed||partyEconomySettings!=null)throw new IllegalStateException("Party economy settings already attached or command closed");partyEconomySettings=Objects.requireNonNull(settings);}
public void attachGearSettings(games.dmg.fantasystick.persistence.GearSettings settings){if(closed||gearSettings!=null)throw new IllegalStateException("Gear settings already attached or command closed");gearSettings=Objects.requireNonNull(settings);}
public void attachCoinSettings(games.dmg.fantasystick.persistence.CoinSettings settings){if(closed||coinSettings!=null)throw new IllegalStateException("Coin settings already attached or command closed");coinSettings=Objects.requireNonNull(settings);}
public void attachCoinEvents(games.dmg.fantasystick.events.CoinEventBridge events){if(closed||coinEvents!=null)throw new IllegalStateException("Coin events already attached or command closed");coinEvents=Objects.requireNonNull(events);}
public void attachAbilityConfigEvents(games.dmg.fantasystick.events.AbilityConfigEventBridge events){if(closed||abilityConfigEvents!=null)throw new IllegalStateException("Ability config events already attached or command closed");abilityConfigEvents=Objects.requireNonNull(events);}
public void attachHardMobXpSettings(games.dmg.fantasystick.persistence.HardMobXpSettings settings,
games.dmg.fantasystick.party.CombatExperience experience) {
if (closed || hardMobXpSettings != null) throw new IllegalStateException("Hard-mob XP settings already attached or command closed");
@@ -353,6 +359,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
outOfCombatRecovery = Objects.requireNonNull(recovery);
}
public void attachPassiveEvents(games.dmg.fantasystick.events.PassiveEventBridge events){if(closed||passiveEvents!=null)throw new IllegalStateException("Passive events already attached or command closed");passiveEvents=Objects.requireNonNull(events);}
public void attachQuickerRegeneration(games.dmg.fantasystick.persistence.QuickerRegenerationSettings settings,
games.dmg.fantasystick.passive.QuickerRegenerationI passive,
games.dmg.fantasystick.passive.PassiveRegistry registry) {
@@ -404,6 +412,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
characterRecovery = Objects.requireNonNull(recovery);
}
public void attachPartyEvents(games.dmg.fantasystick.events.PartyEventBridge events){if(closed||partyEvents!=null)throw new IllegalStateException("Party events already attached or command closed");partyEvents=Objects.requireNonNull(events);}
public void attachPartyRegistrars(games.dmg.fantasystick.party.PartyRegistrarDirectory directory,
games.dmg.fantasystick.party.PartyRegistrarController controller) {
if (closed || partyRegistrars != null) throw new IllegalStateException("Party Registrars already attached or command closed");
@@ -882,8 +892,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
});
}
private void placeRegistrarResponse(org.bukkit.entity.Player player,org.bukkit.Location location,games.dmg.fantasystick.party.PartyRegistrar saved,Throwable failure){if(failure!=null)report(player.getUniqueId(),"party-registrar-place",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure!=null){if(player.isOnline())player.sendMessage("Party Registrar placement failed; no registration or entity changed.");return;}try{int chunkX=location.getBlockX()>>4,chunkZ=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(chunkX,chunkZ))partyRegistrarController.reconcile(location.getWorld().getChunkAt(chunkX,chunkZ));if(player.isOnline())player.sendMessage("Placed Party Registrar "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"party-registrar-entity",entityFailure);partyRegistrars.remove(saved.id()).whenComplete((ignored,rollbackFailure)->{if(rollbackFailure!=null)report(player.getUniqueId(),"party-registrar-rollback",rollbackFailure);if(closed)return;nextTick.accept(()->{partyRegistrarController.removeLoaded(saved.id());if(player.isOnline())player.sendMessage("Party Registrar placement failed; no registration or entity changed.");});});}});}catch(RuntimeException rejected){report(player.getUniqueId(),"party-registrar-response",rejected);}}
private void removeRegistrarResponse(CommandSender sender,UUID id,java.util.Optional<games.dmg.fantasystick.party.PartyRegistrar> removed,Throwable failure){if(failure!=null)report(null,"party-registrar-remove",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure!=null)sender.sendMessage("Party Registrar removal failed; registration and entity are unchanged.");else if(removed.isEmpty())sender.sendMessage("Party Registrar not found.");else try{partyRegistrarController.removeLoaded(id);sender.sendMessage("Removed Party Registrar "+id+".");}catch(RuntimeException entityFailure){report(null,"party-registrar-entity",entityFailure);partyRegistrars.restore(removed.orElseThrow()).whenComplete((restored,rollbackFailure)->{if(rollbackFailure!=null)report(null,"party-registrar-rollback",rollbackFailure);if(closed)return;nextTick.accept(()->{if(rollbackFailure==null){var world=server.getWorld(restored.world());int chunkX=((int)Math.floor(restored.x()))>>4,chunkZ=((int)Math.floor(restored.z()))>>4;if(world!=null&&world.isChunkLoaded(chunkX,chunkZ))partyRegistrarController.reconcile(world.getChunkAt(chunkX,chunkZ));}sender.sendMessage("Party Registrar removal failed; registration and entity are unchanged.");});});}});}catch(RuntimeException rejected){report(null,"party-registrar-response",rejected);}}
private void placeRegistrarResponse(org.bukkit.entity.Player player,org.bukkit.Location location,games.dmg.fantasystick.party.PartyRegistrar saved,Throwable failure){if(failure!=null)report(player.getUniqueId(),"party-registrar-place",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure!=null){if(player.isOnline())player.sendMessage("Party Registrar placement failed; no registration or entity changed.");return;}try{int chunkX=location.getBlockX()>>4,chunkZ=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(chunkX,chunkZ))partyRegistrarController.reconcile(location.getWorld().getChunkAt(chunkX,chunkZ));if(partyEvents!=null)partyEvents.registrarPlaced("player",player.getUniqueId(),player.getName(),saved,partyRegistrarController.loadedEntity(saved.id()).orElse(null),partyRegistrarController.loadedEntity(saved.id()).isPresent());if(player.isOnline())player.sendMessage("Placed Party Registrar "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"party-registrar-entity",entityFailure);partyRegistrars.remove(saved.id()).whenComplete((ignored,rollbackFailure)->{if(rollbackFailure!=null)report(player.getUniqueId(),"party-registrar-rollback",rollbackFailure);if(closed)return;nextTick.accept(()->{partyRegistrarController.removeLoaded(saved.id());if(player.isOnline())player.sendMessage("Party Registrar placement failed; no registration or entity changed.");});});}});}catch(RuntimeException rejected){report(player.getUniqueId(),"party-registrar-response",rejected);}}
private void removeRegistrarResponse(CommandSender sender,UUID id,java.util.Optional<games.dmg.fantasystick.party.PartyRegistrar> removed,Throwable failure){if(failure!=null)report(null,"party-registrar-remove",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure!=null)sender.sendMessage("Party Registrar removal failed; registration and entity are unchanged.");else if(removed.isEmpty())sender.sendMessage("Party Registrar not found.");else try{var loaded=partyRegistrarController.loadedEntity(id);partyRegistrarController.removeLoaded(id);if(partyEvents!=null){String actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;partyEvents.registrarRemoved(actorKind,actorPlayer,actorName,removed.orElseThrow(),loaded.orElse(null),false);}sender.sendMessage("Removed Party Registrar "+id+".");}catch(RuntimeException entityFailure){report(null,"party-registrar-entity",entityFailure);partyRegistrars.restore(removed.orElseThrow()).whenComplete((restored,rollbackFailure)->{if(rollbackFailure!=null)report(null,"party-registrar-rollback",rollbackFailure);if(closed)return;nextTick.accept(()->{if(rollbackFailure==null){var world=server.getWorld(restored.world());int chunkX=((int)Math.floor(restored.x()))>>4,chunkZ=((int)Math.floor(restored.z()))>>4;if(world!=null&&world.isChunkLoaded(chunkX,chunkZ))partyRegistrarController.reconcile(world.getChunkAt(chunkX,chunkZ));}sender.sendMessage("Party Registrar removal failed; registration and entity are unchanged.");});});}});}catch(RuntimeException rejected){report(null,"party-registrar-response",rejected);}}
private void partyRegistrar(CommandSender sender,String[] args){
if(partyRegistrars==null||partyRegistrarController==null||args.length<2){sender.sendMessage("Usage: /fstickadmin party-registrar <place [name...]|remove <registrar-uuid>|list>");return;}
if(args[1].equals("list")&&args.length==2){var values=partyRegistrars.all();if(values.isEmpty())sender.sendMessage("No Party Registrars are registered.");else for(var value:values)sender.sendMessage(value.id()+" "+(value.name().isBlank()?"Party Registrar":value.name())+" @ "+value.world()+" "+value.x()+" "+value.y()+" "+value.z());return;}
@@ -1037,8 +1047,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void fireballConfiguration(CommandSender sender, String[] args) {
String key = args[1];
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
String before = fireballSettings.value(key);
if (args.length == 2) {
sender.sendMessage(key + "=" + fireballSettings.value(key) + " (applies to future Fireball casts only)"); return;
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "fireball", key, before);
sender.sendMessage(key + "=" + before + " (applies to future Fireball casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.FireballSettings.Update> operation;
try { operation = fireballSettings.set(key, args[2]); }
@@ -1052,6 +1065,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed) return;
if (failure == null && fireballSettings.publish(saved)) {
fireball.configure(fireballSettings.settings()); abilities.replace(fireball.definition());
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "fireball", key, before, saved.value());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
@@ -1096,9 +1110,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
}
private void quickerRegenerationConfiguration(CommandSender sender, String[] args) {
String key=games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY;String before=quickerRegenerationSettings.value();String actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;
if (args.length == 2) {
sender.sendMessage(games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY + "="
+ quickerRegenerationSettings.value() + " (applies to future recovery pulses only)"); return;
if(passiveEvents!=null)passiveEvents.configRead(actorKind,actorPlayer,actorName,key,before);
sender.sendMessage(key + "=" + before + " (applies to future recovery pulses only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.QuickerRegenerationSettings.Update> operation;
try { operation = quickerRegenerationSettings.set(args[2]); }
@@ -1111,7 +1126,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed) return; boolean applied = false;
if (failure == null) {
applied = quickerRegenerationSettings.publish(saved);
if (applied) { quickerRegeneration.configure(saved.settings()); passiveRegistry.replace(quickerRegeneration.definition()); }
if (applied) { quickerRegeneration.configure(saved.settings()); passiveRegistry.replace(quickerRegeneration.definition());if(passiveEvents!=null)passiveEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value()); }
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
@@ -1249,8 +1264,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void mendingLightConfiguration(CommandSender sender, String[] args) {
String key = args[1];
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
String before = mendingLightSettings.value(key);
if (args.length == 2) {
sender.sendMessage(key + "=" + mendingLightSettings.value(key) + " (applies to future Mending Light casts only)");
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "mending_light", key, before);
sender.sendMessage(key + "=" + before + " (applies to future Mending Light casts only)");
return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.MendingLightSettings.Update> operation;
@@ -1263,8 +1281,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
mendingLight.configure(saved.settings()); abilities.replace(mendingLight.definition()); mendingLightSettings.publish(saved);
if (failure == null && mendingLightSettings.publish(saved)) {
mendingLight.configure(saved.settings()); abilities.replace(mendingLight.definition());
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "mending_light", key, before, saved.value());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
@@ -1277,8 +1296,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void purifyConfiguration(CommandSender sender, String[] args) {
String key = args[1];
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
String before = purifySettings.value(key);
if (args.length == 2) {
sender.sendMessage(key + "=" + purifySettings.value(key) + " (applies to future Purify casts only)"); return;
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "purify", key, before);
sender.sendMessage(key + "=" + before + " (applies to future Purify casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PurifySettings.Update> operation;
try { operation = purifySettings.set(key, args[2]); }
@@ -1290,8 +1312,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
purify.configure(saved.settings()); abilities.replace(purify.definition()); purifySettings.publish(saved);
if (failure == null && purifySettings.publish(saved)) {
purify.configure(saved.settings()); abilities.replace(purify.definition());
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "purify", key, before, saved.value());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
@@ -1304,8 +1327,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void guardianBondConfiguration(CommandSender sender, String[] args) {
String key = args[1];
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
String before = guardianBondSettings.value(key);
if (args.length == 2) {
sender.sendMessage(key + "=" + guardianBondSettings.value(key) + " (applies to future Guardian Bond casts only)"); return;
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "guardian_bond", key, before);
sender.sendMessage(key + "=" + before + " (applies to future Guardian Bond casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.GuardianBondSettings.Update> operation;
try { operation = guardianBondSettings.set(key, args[2]); }
@@ -1317,8 +1343,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
guardianBond.configure(saved.settings()); abilities.replace(guardianBond.definition()); guardianBondSettings.publish(saved);
if (failure == null && guardianBondSettings.publish(saved)) {
guardianBond.configure(saved.settings()); abilities.replace(guardianBond.definition());
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "guardian_bond", key, before, saved.value());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
@@ -1331,8 +1358,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void rallyingCryConfiguration(CommandSender sender, String[] args) {
String key = args[1];
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
String before = rallyingCrySettings.value(key);
if (args.length == 2) {
sender.sendMessage(key + "=" + rallyingCrySettings.value(key) + " (applies to future Rallying Cry casts only)"); return;
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "rallying_cry", key, before);
sender.sendMessage(key + "=" + before + " (applies to future Rallying Cry casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RallyingCrySettings.Update> operation;
try { operation = rallyingCrySettings.set(key, args[2]); }
@@ -1344,8 +1374,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
rallyingCry.configure(saved.settings()); abilities.replace(rallyingCry.definition()); rallyingCrySettings.publish(saved);
if (failure == null && rallyingCrySettings.publish(saved)) {
rallyingCry.configure(saved.settings()); abilities.replace(rallyingCry.definition());
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "rallying_cry", key, before, saved.value());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
@@ -1358,8 +1389,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void frostNovaConfiguration(CommandSender sender, String[] args) {
String key = args[1];
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
String before = frostNovaSettings.value(key);
if (args.length == 2) {
sender.sendMessage(key + "=" + frostNovaSettings.value(key) + " (applies to future Frost Nova casts only)"); return;
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "frost_nova", key, before);
sender.sendMessage(key + "=" + before + " (applies to future Frost Nova casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.FrostNovaSettings.Update> operation;
try { operation = frostNovaSettings.set(key, args[2]); }
@@ -1371,12 +1405,14 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
frostNova.configure(saved.settings()); abilities.replace(frostNova.definition()); frostNovaSettings.publish(saved);
boolean published = failure == null && frostNovaSettings.publish(saved);
if (published) {
frostNova.configure(saved.settings()); abilities.replace(frostNova.definition());
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "frost_nova", key, before, saved.value());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
sender.sendMessage(published
? "Saved " + key + "=" + saved.value() + " for future Frost Nova casts; active slows and deadlines are unchanged."
: "Frost Nova configuration save failed; the effective setting is unchanged.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
@@ -1385,8 +1421,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void chainLightningConfiguration(CommandSender sender, String[] args) {
String key = args[1];
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
String before = chainLightningSettings.value(key);
if (args.length == 2) {
sender.sendMessage(key + "=" + chainLightningSettings.value(key) + " (applies to future Chain Lightning casts only)"); return;
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "chain_lightning", key, before);
sender.sendMessage(key + "=" + before + " (applies to future Chain Lightning casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.ChainLightningSettings.Update> operation;
try { operation = chainLightningSettings.set(key, args[2]); }
@@ -1398,12 +1437,14 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
chainLightning.configure(saved.settings()); abilities.replace(chainLightning.definition()); chainLightningSettings.publish(saved);
boolean published = failure == null && chainLightningSettings.publish(saved);
if (published) {
chainLightning.configure(saved.settings()); abilities.replace(chainLightning.definition());
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "chain_lightning", key, before, saved.value());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
sender.sendMessage(published
? "Saved " + key + "=" + saved.value() + " for future Chain Lightning casts; active chains and deadlines are unchanged."
: "Chain Lightning configuration save failed; the effective setting is unchanged.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
@@ -1412,8 +1453,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void sanctuaryConfiguration(CommandSender sender, String[] args) {
String key = args[1];
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
String before = sanctuarySettings.value(key);
if (args.length == 2) {
sender.sendMessage(key + "=" + sanctuarySettings.value(key) + " (applies to future Sanctuary casts only)"); return;
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "sanctuary", key, before);
sender.sendMessage(key + "=" + before + " (applies to future Sanctuary casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SanctuarySettings.Update> operation;
try { operation = sanctuarySettings.set(key, args[2]); }
@@ -1425,12 +1469,14 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
sanctuary.configure(saved.settings()); abilities.replace(sanctuary.definition()); sanctuarySettings.publish(saved);
boolean published = failure == null && sanctuarySettings.publish(saved);
if (published) {
sanctuary.configure(saved.settings()); abilities.replace(sanctuary.definition());
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "sanctuary", key, before, saved.value());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
sender.sendMessage(published
? "Saved " + key + "=" + saved.value() + " for future Sanctuary casts; active areas and deadlines are unchanged."
: "Sanctuary configuration save failed; the effective setting is unchanged.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
@@ -1439,7 +1485,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void warBannerConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) { sender.sendMessage(key + "=" + warBannerSettings.value(key) + " (applies to future War Banner casts only)"); return; }
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
String before = warBannerSettings.value(key);
if (args.length == 2) {
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "war_banner", key, before);
sender.sendMessage(key + "=" + before + " (applies to future War Banner casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.WarBannerSettings.Update> operation;
try { operation = warBannerSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) { sender.sendMessage("War Banner setting is invalid; the effective value is unchanged."); return; }
@@ -1447,9 +1498,13 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (failure != null) report(null, "config-" + key, failure); if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure == null) { warBanner.configure(saved.settings()); abilities.replace(warBanner.definition()); warBannerSettings.publish(saved); }
boolean published = failure == null && warBannerSettings.publish(saved);
if (published) {
warBanner.configure(saved.settings()); abilities.replace(warBanner.definition());
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "war_banner", key, before, saved.value());
}
if (!sender.hasPermission(PERMISSION) || sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
sender.sendMessage(published
? "Saved " + key + "=" + saved.value() + " for future War Banner casts; active areas and deadlines are unchanged."
: "War Banner configuration save failed; the effective setting is unchanged.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
@@ -1457,10 +1512,35 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
}
private void meteorConfiguration(CommandSender sender, String[] args) {
String key=args[1];if(args.length==2){sender.sendMessage(key+"="+meteorSettings.value(key)+" (applies to future Meteor casts only)");return;}
String key = args[1];
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
String before = meteorSettings.value(key);
if (args.length == 2) {
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "meteor", key, before);
sender.sendMessage(key + "=" + before + " (applies to future Meteor casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.MeteorSettings.Update> operation;
try{operation=meteorSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Meteor setting is invalid; the effective value is unchanged.");return;}
operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure==null){meteor.configure(saved.settings());abilities.replace(meteor.definition());meteorSettings.publish(saved);}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Saved "+key+"="+saved.value()+" for future Meteor casts; active warnings and deadlines are unchanged.":"Meteor configuration save failed; the effective setting is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});
try { operation = meteorSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) {
sender.sendMessage("Meteor setting is invalid; the effective value is unchanged."); return;
}
operation.whenComplete((saved, failure) -> {
if (failure != null) report(null, "config-" + key, failure);
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
boolean published = failure == null && meteorSettings.publish(saved);
if (published) {
meteor.configure(saved.settings()); abilities.replace(meteor.definition());
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "meteor", key, before, saved.value());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(published
? "Saved " + key + "=" + saved.value() + " for future Meteor casts; active warnings and deadlines are unchanged."
: "Meteor configuration save failed; the effective setting is unchanged.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void hexConfiguration(CommandSender sender, String[] args) {
@@ -1555,13 +1635,13 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void sheetResourcePackConfiguration(CommandSender sender,String[] args){if(sheetResourcePackSettings==null){configurationUsage(sender);return;}String key=args[1];if(args.length==2){sender.sendMessage(key+"="+sheetResourcePackSettings.value(key));return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SheetResourcePackSettings.Update> operation;try{operation=sheetResourcePackSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Sheet resource-pack value is invalid; use an absolute HTTPS URL or 40 hexadecimal SHA-1 characters.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-sheet-resource-pack",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null){applied=sheetResourcePackSettings.publish(saved);if(applied)sheetResourcePack.deliverAll(server.getOnlinePlayers());}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Sheet resource-pack configuration save failed; effective values are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+sheetResourcePackSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+sheetResourcePackSettings.value(key)+"; optional delivery was requested for online players.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void partyEconomyConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+partyEconomySettings.value(key)+" (applies to future party transactions only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PartyEconomySettings.Update> operation;try{operation=partyEconomySettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Party price is invalid; enter a whole number from 0 through 2147483647.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=failure==null&&partyEconomySettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Party price save failed; the effective price is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+partyEconomySettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for future party transactions.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void partyEconomyConfiguration(CommandSender sender,String[] args){String key=args[1],before=partyEconomySettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;if(args.length==2){if(partyEvents!=null)partyEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+" (applies to future party transactions only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PartyEconomySettings.Update> operation;try{operation=partyEconomySettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Party price is invalid; enter a whole number from 0 through 2147483647.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=failure==null&&partyEconomySettings.publish(saved);if(applied&&partyEvents!=null)partyEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Party price save failed; the effective price is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+partyEconomySettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for future party transactions.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void gearConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+gearSettings.value(key)+" (applies to the next armor restock only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.GearSettings.Update> operation;try{operation=gearSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Armor market setting is invalid; variance is 0–100, prices are nonnegative, and positive stock minima cannot exceed maxima.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;nextTick.accept(()->{boolean applied=failure==null&&gearSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Armor market setting save failed; effective settings and the current catalog are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+gearSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for the next automatic or administrative restock; the current catalog is unchanged.");});});}
private void rareConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+rareSettings.value(key)+(key.endsWith("-healing-percent")?" (applies to future accepted uses)":" (applies to the next rare restock only)"));return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RareSettings.Update> operation;try{operation=rareSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Rare setting is invalid; percentages are 0–100, values are nonnegative, and stock minima cannot exceed maxima.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;nextTick.accept(()->{boolean applied=failure==null&&rareSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Rare setting save failed; prior effective state is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+rareSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+"; current catalog and accepted uses are unchanged.");});});}
private void rareConfiguration(CommandSender sender,String[] args){String key=args[1],before=rareSettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;boolean phoenix=key.contains("phoenix-down");if(args.length==2){if(phoenix&&downedEvents!=null)downedEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+(key.endsWith("-healing-percent")||key.equals(games.dmg.fantasystick.persistence.RareSettings.PHOENIX_PROTECTION)?" (applies to future accepted uses)":" (applies to the next rare restock only)"));return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RareSettings.Update> operation;try{operation=rareSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Rare setting is invalid; percentages are 0–100, values are nonnegative, and stock minima cannot exceed maxima.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;nextTick.accept(()->{boolean applied=failure==null&&rareSettings.publish(saved);if(applied&&phoenix&&downedEvents!=null)downedEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Rare setting save failed; prior effective state is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+rareSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+"; current catalog and accepted uses are unchanged.");});});}
private void downedConfiguration(CommandSender sender,String[] args){String key=args[1],before=downedSettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;if(args.length==2){if(downedEvents!=null)downedEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+" (applies to future downed outcomes only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.DownedSettings.Update> operation;try{operation=downedSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Downed setting is invalid; timeout must be positive and protection may be zero, both at most 365 days.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;nextTick.accept(()->{boolean applied=failure==null&&downedSettings.publish(saved);if(applied&&downedEvents!=null)downedEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Downed setting save failed; prior effective state is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+downedSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+"; existing deadlines are unchanged.");});});}
private void coinConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+coinSettings.value(key)+" (applies to future creation or defeats only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.CoinSettings.Update> operation;try{operation=coinSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Coin setting is invalid; ranges must be ordered whole numbers from 0 through 2147483647.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=failure==null&&coinSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Coin configuration save failed; effective ranges are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+coinSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for future creation or defeats; existing balances and captured rewards are unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void coinConfiguration(CommandSender sender,String[] args){String key=args[1],before=coinSettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;if(args.length==2){if(coinEvents!=null)coinEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+" (applies to future creation or defeats only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.CoinSettings.Update> operation;try{operation=coinSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Coin setting is invalid; ranges must be ordered whole numbers from 0 through 2147483647.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=failure==null&&coinSettings.publish(saved);if(applied&&coinEvents!=null)coinEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Coin configuration save failed; effective ranges are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+coinSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for future creation or defeats; existing balances and captured rewards are unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void hardMobXpConfiguration(CommandSender sender, String[] args) {
String key = games.dmg.fantasystick.persistence.HardMobXpSettings.KEY;
@@ -47,6 +47,7 @@ public final class EquipmentSessions implements AutoCloseable {
private java.util.function.Function<UUID,DefeatCapture> defeatCapture=ignored->new DefeatCapture(false,List.of(),0,BigInteger.ZERO);
private java.util.function.Function<DownedEpisode,List<DownedEpisode.Reward>> downedRewardAllocation=ignored->List.of();
private games.dmg.fantasystick.events.DownedEventBridge downedEvents;
private games.dmg.fantasystick.events.SessionEventBridge sessionEvents;
private java.util.function.Function<DownedEpisode,CompletionStage<?>> downedDefeatRewards=ignored->CompletableFuture.completedStage(null);
private java.util.function.Predicate<UUID> unavailableCharacter = ignored -> false;
private java.util.function.Function<UUID,java.util.concurrent.CompletionStage<?>> partyDefeat =
@@ -114,6 +115,8 @@ public final class EquipmentSessions implements AutoCloseable {
boolean uncertainSave;
boolean nativeUncertain;
boolean exitRequested;
games.dmg.fantasystick.events.SessionEventBridge.ExitCapture exitEvent;
boolean exitEventWritten;
Duration exitDelay;
boolean arrival;
Attempt recoveryOwner;
@@ -206,6 +209,7 @@ public final class EquipmentSessions implements AutoCloseable {
public synchronized void attachDefeatCapture(java.util.function.Function<UUID,DefeatCapture> capture){if(!owned.isEmpty())throw new IllegalStateException("Sessions already started");defeatCapture=Objects.requireNonNull(capture);}
public synchronized void attachDownedRewardAllocation(java.util.function.Function<DownedEpisode,List<DownedEpisode.Reward>> allocation){if(!owned.isEmpty())throw new IllegalStateException("Sessions already started");downedRewardAllocation=Objects.requireNonNull(allocation);}
public synchronized void attachDownedEvents(games.dmg.fantasystick.events.DownedEventBridge events){if(!owned.isEmpty()||downedEvents!=null)throw new IllegalStateException("Downed events already attached or sessions started");downedEvents=Objects.requireNonNull(events);}
public synchronized void attachSessionEvents(games.dmg.fantasystick.events.SessionEventBridge events){if(!owned.isEmpty()||sessionEvents!=null)throw new IllegalStateException("Session events already attached or sessions started");sessionEvents=Objects.requireNonNull(events);}
public synchronized void attachDownedDefeatRewards(java.util.function.Function<DownedEpisode,CompletionStage<?>> settlement){if(!owned.isEmpty())throw new IllegalStateException("Sessions already started");downedDefeatRewards=Objects.requireNonNull(settlement);}
public synchronized List<DownedEpisode.Reward> finalRewards(DownedEpisode episode){return List.copyOf(downedRewardAllocation.apply(Objects.requireNonNull(episode)));}
@@ -703,6 +707,7 @@ public final class EquipmentSessions implements AutoCloseable {
if(attempt.resumedRevive!=null){var recovered=attempt.resumedRevive;if(downedEvents!=null)downedEvents.outcomePublished(recovered);downedDirectory.publish(recovered.id(),recovered.outcomeOperation()).whenComplete((published,publishFailure)->{if(publishFailure!=null)diagnostic(attempt,publishFailure);else if(downedEvents!=null)downedEvents.outcomePublished(published);});}
}
attempt.player.updateInventory();
if(active.get(attempt.owner)==attempt&&sessionEvents!=null)sessionEvents.entered(attempt.player,attempt.session,record,attempt.entryLocation,attempt.resumedDowned!=null,attempt.previousGameMode!=null);
} catch (RuntimeException error) {
fail(attempt, error); // Effects may have started: never roll back or replay them.
notice(attempt.owner, "NATIVE_UNCERTAIN", "Equipment mutation failed; items and receipt require inspection.");
@@ -849,7 +854,7 @@ public final class EquipmentSessions implements AutoCloseable {
public synchronized void reviveDowned(DownedEpisode episode,Consumer<Boolean> completion){Objects.requireNonNull(episode);Objects.requireNonNull(completion);if(episode.phase()!=DownedEpisode.Phase.RESOLVED||episode.outcome()!=DownedEpisode.Outcome.REVIVED){completion.accept(false);return;}var attempt=active.get(episode.player());if(attempt==null||attempt.downed==null||!attempt.downed.id().equals(episode.id())||!attempt.session.equals(episode.session())||!attempt.character.equals(episode.character())){completion.accept(false);return;}var expected=attempt.record.revision();var maximum=attempt.record.maximumHealth().min(NativeHealthLimit.read());characters.saveHealth(attempt.character,expected,maximum,maximum).whenComplete((saved,failure)->persistReturn(()->{synchronized(EquipmentSessions.this){if(failure!=null||saved==null||saved.isEmpty()||active.get(attempt.owner)!=attempt||attempt.downed==null||!attempt.downed.id().equals(episode.id())){if(failure!=null)diagnostic(attempt,failure);completion.accept(false);return;}var record=saved.orElseThrow();try{if(vitals!=null&&!record.revision().equals(attempt.record.revision()))vitals.updateProgress(attempt.player,attempt.session,attempt.record,record);attempt.record=record;attempt.downed=null;attempt.downing=false;attempt.downedProtectionUntil=episode.protectionUntil();double nativeMaximum=Objects.requireNonNull(attempt.player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).getValue();attempt.player.setHealth(Math.min(nativeMaximum,record.health().doubleValue()));refreshControls(attempt,new Active(attempt.owner,attempt.session,attempt.character));attempt.player.closeInventory();notice(attempt.owner,"REVIVED","§aRevive restored your downed character.");completion.accept(true);}catch(RuntimeException publicationFailure){diagnostic(attempt,publicationFailure);completion.accept(false);}}}));}
/** Publish one already-durable final-death outcome through the existing restoration/Tail custody path. */
public synchronized boolean finalizeDowned(DownedEpisode episode){Objects.requireNonNull(episode);if(episode.phase()!=DownedEpisode.Phase.RESOLVED||episode.outcome()!=DownedEpisode.Outcome.FINAL_DEATH)return false;var attempt=active.get(episode.player());if(attempt==null){persist(()->recordOfflineFinalDeath(episode),(dead,failure)->{if(failure!=null)diagnostic(new Failure(episode.player(),episode.session(),failure));else if(dead.isEmpty())diagnostic(new Failure(episode.player(),episode.session(),new IllegalStateException("Offline final death conflicted with saved character")));});return true;}if(attempt.downed==null||!attempt.downed.id().equals(episode.id())||!attempt.session.equals(episode.session())||!attempt.character.equals(episode.character()))return false;active.remove(attempt.owner,attempt);attempt.finalDowned=episode;attempt.downed=null;withdrawPresentation(attempt);attempt.defeated=true;boolean reserved=transitioning.putIfAbsent(attempt.owner,attempt)==null;boolean loseCoins=episode.attribution().playerDefeat();persist(()->characters.recordFinalDeath(attempt.character,attempt.record.revision(),episode.id(),episode.resolvedAt(),loseCoins),(dead,failure)->{if(failure!=null){diagnostic(attempt,failure);conversionUncertain(attempt,"Final death save failed or is uncertain");return;}if(dead.isEmpty()){conversionUncertain(attempt,"Character was missing or final death conflicted; no Tail authorized");return;}attempt.deathResolved=true;attempt.deathRecord=dead.orElseThrow();if(downedEvents!=null)downedEvents.finalDeathRecorded(episode);if(closed){conversionUncertain(attempt,"Shutdown before reward, party defeat and Tail conversion");return;}java.util.concurrent.CompletionStage<?> partyMutation=finalConsequences(episode);partyMutation.whenComplete((ignored,partyFailure)->{synchronized(EquipmentSessions.this){if(partyFailure!=null){diagnostic(attempt,partyFailure);conversionUncertain(attempt,"Party defeat mutation failed");return;}attempt.partyDefeatAcknowledged=true;queueTail(attempt);}});});try{var restored=restoreExit(attempt);if(restored==EquipmentStorage.RestoreResult.RESTORED){var retained=attempt.player.getInventory().getItemInOffHand();attempt.deathReference=copy(retained);attempt.deathNativeReference=nativeIdentity(retained);attempt.deathView=attempt.player.getOpenInventory();}double maximum=Objects.requireNonNull(attempt.player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).getValue();attempt.player.setHealth(maximum);notice(attempt.owner,"DEFEAT","§cYour character was defeated.");attempt.defeatReturnTeleport=true;try{if(!attempt.player.teleport(attempt.entryLocation.clone()))notice(attempt.owner,"DEFEAT_RETURN_FAILED","Return to the session entry location was refused; you remain here.");}finally{attempt.defeatReturnTeleport=false;if(!attempt.deliveryInvalidated&&owned.get(attempt.owner)==attempt&&attempt.player.isOnline())attempt.deathView=attempt.player.getOpenInventory();}}catch(RuntimeException failure){diagnostic(attempt,failure);conversionUncertain(attempt,"Native final-death transition failed; inspect retained equipment and death state");}finally{if(reserved)transitioning.remove(attempt.owner,attempt);finishDrain();}return true;}
public synchronized boolean finalizeDowned(DownedEpisode episode){Objects.requireNonNull(episode);if(episode.phase()!=DownedEpisode.Phase.RESOLVED||episode.outcome()!=DownedEpisode.Outcome.FINAL_DEATH)return false;var attempt=active.get(episode.player());if(attempt==null){persist(()->recordOfflineFinalDeath(episode),(dead,failure)->{if(failure!=null)diagnostic(new Failure(episode.player(),episode.session(),failure));else if(dead.isEmpty())diagnostic(new Failure(episode.player(),episode.session(),new IllegalStateException("Offline final death conflicted with saved character")));});return true;}if(attempt.downed==null||!attempt.downed.id().equals(episode.id())||!attempt.session.equals(episode.session())||!attempt.character.equals(episode.character()))return false;active.remove(attempt.owner,attempt);attempt.finalDowned=episode;attempt.downed=null;withdrawPresentation(attempt);attempt.defeated=true;boolean reserved=transitioning.putIfAbsent(attempt.owner,attempt)==null;boolean loseCoins=episode.attribution().playerDefeat();persist(()->characters.recordFinalDeath(attempt.character,attempt.record.revision(),episode.id(),episode.resolvedAt(),loseCoins),(dead,failure)->{if(failure!=null){diagnostic(attempt,failure);conversionUncertain(attempt,"Final death save failed or is uncertain");return;}if(dead.isEmpty()){conversionUncertain(attempt,"Character was missing or final death conflicted; no Tail authorized");return;}attempt.deathResolved=true;attempt.deathRecord=dead.orElseThrow();if(downedEvents!=null)downedEvents.finalDeathRecorded(episode);if(closed){conversionUncertain(attempt,"Shutdown before reward, party defeat and Tail conversion");return;}java.util.concurrent.CompletionStage<?> partyMutation=finalConsequences(episode);partyMutation.whenComplete((ignored,partyFailure)->{synchronized(EquipmentSessions.this){if(partyFailure!=null){diagnostic(attempt,partyFailure);conversionUncertain(attempt,"Party defeat mutation failed");return;}attempt.partyDefeatAcknowledged=true;queueTail(attempt);}});});try{var restored=restoreExit(attempt);if(restored==EquipmentStorage.RestoreResult.RESTORED){var retained=attempt.player.getInventory().getItemInOffHand();attempt.deathReference=copy(retained);attempt.deathNativeReference=nativeIdentity(retained);attempt.deathView=attempt.player.getOpenInventory();}double maximum=Objects.requireNonNull(attempt.player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).getValue();attempt.player.setHealth(maximum);notice(attempt.owner,"DEFEAT","§cYour character was defeated.");attempt.defeatReturnTeleport=true;try{boolean returned;try{returned=attempt.player.teleport(attempt.entryLocation.clone());}catch(RuntimeException uncertain){if(downedEvents!=null)downedEvents.returnAttempted(episode,"uncertain");throw uncertain;}if(downedEvents!=null)downedEvents.returnAttempted(episode,returned?"returned":"refused");if(!returned)notice(attempt.owner,"DEFEAT_RETURN_FAILED","Return to the session entry location was refused; you remain here.");}finally{attempt.defeatReturnTeleport=false;if(!attempt.deliveryInvalidated&&owned.get(attempt.owner)==attempt&&attempt.player.isOnline())attempt.deathView=attempt.player.getOpenInventory();}}catch(RuntimeException failure){diagnostic(attempt,failure);conversionUncertain(attempt,"Native final-death transition failed; inspect retained equipment and death state");}finally{if(reserved)transitioning.remove(attempt.owner,attempt);finishDrain();}return true;}
private CompletionStage<Void> finalConsequences(DownedEpisode episode){var result=new CompletableFuture<Void>();try{nextTick.accept(()->{CompletionStage<?> work;try{work=downedDefeatRewards.apply(episode).thenCompose(ignored->partyDefeat.apply(episode.character()));}catch(RuntimeException failure){result.completeExceptionally(failure);return;}work.whenComplete((ignored,failure)->{if(failure==null)result.complete(null);else result.completeExceptionally(failure);});});}catch(RuntimeException rejected){result.completeExceptionally(rejected);}return result;}
@@ -985,6 +990,7 @@ public final class EquipmentSessions implements AutoCloseable {
private void withdrawPresentation(Attempt attempt) {
if (!attempt.presentationInstalled) return;
attempt.presentationInstalled = false;
if(sessionEvents!=null)sessionEvents.forget(attempt.owner,attempt.session);
try { presentation.withdraw(attempt.owner, attempt.session); }
catch (RuntimeException | LinkageError failure) { diagnostic(attempt, failure); }
}
@@ -1002,6 +1008,8 @@ public final class EquipmentSessions implements AutoCloseable {
}
private EquipmentStorage.RestoreResult restoreExit(Attempt attempt) {
if(sessionEvents!=null&&attempt.exitEvent==null&&!attempt.defeated&&attempt.downed==null&&!closed&&attempt.player.isOnline()&&attempt.record!=null)
attempt.exitEvent=sessionEvents.captureExit(attempt.player,attempt.session,attempt.record,attempt.entryLocation);
captureExitDelay(attempt);
active.remove(attempt.owner, attempt);
withdrawPresentation(attempt);
@@ -1122,6 +1130,10 @@ public final class EquipmentSessions implements AutoCloseable {
"Equipment restoration persistence failed before Tail conversion");
} else if (complete) {
attempt.gearAcknowledged = true;
if (!attempt.defeated && !attempt.exitEventWritten && attempt.exitEvent != null && sessionEvents != null) {
attempt.exitEventWritten = true;
sessionEvents.exited(attempt.session,attempt.exitEvent);
}
if (attempt.defeated) queueTail(attempt);
release(attempt);
} else if (attempt.defeated) {
@@ -15,8 +15,13 @@ public final class FantasyFeedbackCommand implements TabExecutor, AutoCloseable
private final PlayerFeedbackPreferences preferences;
private final Consumer<Runnable> nextTick;
private final Consumer<Throwable> failures;
private games.dmg.fantasystick.events.PreferenceEventBridge events;
private boolean closed;
public void attachEvents(games.dmg.fantasystick.events.PreferenceEventBridge bridge) {
this.events = Objects.requireNonNull(bridge);
}
public FantasyFeedbackCommand(PlayerFeedbackPreferences preferences, Consumer<Runnable> nextTick,
Consumer<Throwable> failures) {
this.preferences = Objects.requireNonNull(preferences);
@@ -26,32 +31,51 @@ public final class FantasyFeedbackCommand implements TabExecutor, AutoCloseable
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!(sender instanceof Player player)) {
if (events != null) try { events.refuseNonPlayer(sender, "combat_feedback"); }
catch (RuntimeException ignored) { }
sender.sendMessage("Fantasy feedback is a player-account preference.");
return true;
}
if (args.length == 0) {
var action = observe(player, null);
player.sendMessage("Fantasy feedback is " + state(preferences.enabled(player.getUniqueId())) + ".");
if (action != null) events.read(action);
return true;
}
if (args.length != 1 || !(args[0].equals("on") || args[0].equals("off"))) return false;
if (args.length != 1 || !(args[0].equals("on") || args[0].equals("off"))) {
var action = observe(player, null);
if (action != null) events.refused(action, "invalid_arguments");
return false;
}
boolean enabled = args[0].equals("on");
var account = player.getUniqueId();
preferences.set(account, enabled).whenComplete((saved, failure) -> {
var action = observe(player, enabled);
var save = action == null ? preferences.set(account, enabled)
: preferences.set(account, enabled, events.intent(action));
save.whenComplete((saved, failure) -> {
if (failure != null) report(failure);
try {
nextTick.accept(() -> {
if (closed) return;
if (closed) {
if (action != null) events.failed(action, "closed");
return;
}
if (failure != null) {
if (action != null) events.failed(action, "save_failed");
if (player.isOnline()) player.sendMessage(
"Fantasy feedback preference could not be saved; it remains "
+ state(preferences.enabled(account)) + ".");
return;
}
boolean published = preferences.publish(saved);
if (action != null) events.saved(action, saved, published, preferences.enabled(account));
if (player.isOnline()) player.sendMessage("Fantasy feedback is "
+ (published ? "now " : "already ") + state(preferences.enabled(account)) + ".");
});
} catch (RuntimeException rejected) { report(rejected); }
} catch (RuntimeException rejected) {
if (action != null) events.failed(action, "schedule_failed");
report(rejected);
}
});
return true;
}
@@ -62,6 +86,13 @@ public final class FantasyFeedbackCommand implements TabExecutor, AutoCloseable
return List.of("off", "on").stream().filter(value -> value.startsWith(prefix)).toList();
}
private games.dmg.fantasystick.events.PreferenceEventBridge.Action observe(Player player, Boolean requested) {
var bridge = events;
if (bridge == null) return null;
try { return bridge.begin(player, "combat_feedback", preferences.enabled(player.getUniqueId()), requested); }
catch (RuntimeException ignored) { return null; } // Auditing cannot refuse a player preference.
}
private static String state(boolean enabled) { return enabled ? "on" : "off"; }
private void report(Throwable failure) { try { failures.accept(failure); } catch (RuntimeException ignored) { } }
@Override public void close() { closed = true; }
@@ -16,8 +16,13 @@ public final class FantasyParticlesCommand implements TabExecutor, AutoCloseable
private final PlayerFeedbackPreferences preferences;
private final Consumer<Runnable> nextTick;
private final Consumer<Throwable> failures;
private games.dmg.fantasystick.events.PreferenceEventBridge events;
private boolean closed;
public void attachEvents(games.dmg.fantasystick.events.PreferenceEventBridge events) {
this.events = Objects.requireNonNull(events);
}
public FantasyParticlesCommand(PlayerFeedbackPreferences preferences, Consumer<Runnable> nextTick,
Consumer<Throwable> failures) {
this.preferences = Objects.requireNonNull(preferences);
@@ -27,35 +32,60 @@ public final class FantasyParticlesCommand implements TabExecutor, AutoCloseable
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!(sender instanceof Player player)) {
if (args.length > 0 && (args[0].equals("all") || args[0].equals("players")) && events != null) {
try { events.refuseNonPlayer(sender, key(args[0])); } catch (RuntimeException ignored) { }
}
sender.sendMessage("Fantasy particles are a player-account preference.");
return true;
}
if (args.length == 1 && (args[0].equals("players") || args[0].equals("all"))) {
var action = observe(player, args[0], null);
player.sendMessage(status(player.getUniqueId(), args[0]));
if (action != null) events.read(action);
return true;
}
if (args.length != 2 || !(args[0].equals("players") || args[0].equals("all"))
|| !(args[1].equals("on") || args[1].equals("off"))) return false;
|| !(args[1].equals("on") || args[1].equals("off"))) {
if (args.length > 0 && (args[0].equals("all") || args[0].equals("players"))) {
var action = observe(player, args[0], null);
if (action != null) events.refused(action, "invalid_arguments");
}
return false;
}
String route = args[0];
boolean enabled = args[1].equals("on");
UUID account = player.getUniqueId();
var action = observe(player, route, enabled);
var save = route.equals("players")
? preferences.setPlayerAuras(account, enabled) : preferences.setAllParticles(account, enabled);
? action == null ? preferences.setPlayerAuras(account, enabled)
: preferences.setPlayerAuras(account, enabled, events.intent(action))
: action == null ? preferences.setAllParticles(account, enabled)
: preferences.setAllParticles(account, enabled, events.intent(action));
save.whenComplete((saved, failure) -> {
if (failure != null) report(failure);
try {
nextTick.accept(() -> {
if (closed) return;
if (closed) {
if (action != null) events.failed(action, "closed");
return;
}
if (failure != null) {
if (action != null) events.failed(action, "save_failed");
if (player.isOnline()) player.sendMessage(
(route.equals("players") ? "Active-character aura" : "All-particles")
+ " preference could not be saved; " + status(account, route));
return;
}
boolean published = preferences.publish(saved);
if (action != null) events.saved(action, saved, published,
route.equals("players") ? preferences.auraParticlesEnabled(account)
: preferences.allParticlesEnabled(account));
if (player.isOnline()) player.sendMessage(acknowledgement(account, route, published));
});
} catch (RuntimeException rejected) { report(rejected); }
} catch (RuntimeException rejected) {
if (action != null) events.failed(action, "schedule_failed");
report(rejected);
}
});
return true;
}
@@ -68,6 +98,18 @@ public final class FantasyParticlesCommand implements TabExecutor, AutoCloseable
return List.of();
}
private games.dmg.fantasystick.events.PreferenceEventBridge.Action observe(
Player player, String route, Boolean requested) {
var bridge = events;
if (bridge == null) return null;
try { return bridge.begin(player, key(route), route.equals("players")
? preferences.auraParticlesEnabled(player.getUniqueId())
: preferences.allParticlesEnabled(player.getUniqueId()), requested); }
catch (RuntimeException ignored) { return null; } // Capture failure cannot refuse gameplay.
}
private static String key(String route) { return route.equals("players") ? "player-aura-particles" : "all-particles"; }
private String status(UUID account, String route) {
if (route.equals("all"))
return "All Fantasy Stick particles are " + state(preferences.allParticlesEnabled(account)) + ".";
@@ -66,6 +66,8 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
private final CombatFeedback feedback;
private final CombatSources sources;
private java.util.function.Function<CharacterRecord, BigInteger> bonusHealing = ignored -> BigInteger.ZERO;
private java.util.function.Function<CharacterRecord, BigInteger> quickerRegenerationContribution = ignored -> BigInteger.ZERO;
private games.dmg.fantasystick.events.RecoveryEventBridge recoveryEvents;
private final Map<UUID, State> states = new HashMap<>();
private final ArrayDeque<UUID> order = new ArrayDeque<>();
private final Set<UUID> queued = new HashSet<>();
@@ -96,6 +98,7 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
if (closed || task != null || !states.isEmpty()) throw new IllegalStateException("Recovery evaluation already started");
bonusHealing = Objects.requireNonNull(bonus);
}
public synchronized void attachEvents(games.dmg.fantasystick.events.RecoveryEventBridge events,java.util.function.Function<CharacterRecord,BigInteger> quickerContribution){if(closed||recoveryEvents!=null)throw new IllegalStateException("Recovery events already attached or recovery closed");recoveryEvents=Objects.requireNonNull(events);quickerRegenerationContribution=Objects.requireNonNull(quickerContribution);}
public synchronized void install(org.bukkit.plugin.Plugin plugin) {
Objects.requireNonNull(plugin);
@@ -177,18 +180,19 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
state.lastAttempt = now; // Full-health checks are not banked for later replay.
return;
}
BigInteger exactAmount;
try { exactAmount = configured.amount().add(Objects.requireNonNull(bonusHealing.apply(record))); }
BigInteger exactAmount;BigInteger quickerContribution;
try { exactAmount = configured.amount().add(Objects.requireNonNull(bonusHealing.apply(record)));quickerContribution=Objects.requireNonNull(quickerRegenerationContribution.apply(record)); }
catch (RuntimeException failure) { state.lastAttempt = now; report(record.id(), failure); return; }
if (exactAmount.signum() < 0) { state.lastAttempt = now; report(record.id(), new IllegalStateException("Negative recovery bonus")); return; }
double amount = exactAmount.bitLength() > 1023 ? maximum - current : exactAmount.doubleValue();
double proposed = Math.min(maximum - current, amount);
if (!Double.isFinite(proposed) || proposed <= 0) return;
var audit=recoveryEvents==null?null:recoveryEvents.begin(player,state.active,record,current,proposed,configured.amount(),quickerContribution);
var event = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
healingEvents.accept(event);
try{healingEvents.accept(event);}catch(RuntimeException failure){if(audit!=null)recoveryEvents.failed(audit,"native_healing_event_failed");throw failure;}
double accepted = Math.min(proposed, Math.min(maximum - current, event.getAmount()));
state.lastAttempt = now;
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) return;
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) {if(audit!=null)recoveryEvents.refused(audit,event.isCancelled()?"native_event_cancelled":"no_positive_healing_accepted");return;}
Object token = new Object();
state.pending = token;
long generation = state.generation;
@@ -196,44 +200,45 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
BigDecimal target = BigDecimal.valueOf(current + accepted);
BigDecimal limit;
try { limit = nativeLimit.get(); }
catch (RuntimeException failure) { state.pending = null; state.lastAttempt = clock.instant(); report(record.id(), failure); return; }
catch (RuntimeException failure) { state.pending = null; state.lastAttempt = clock.instant();if(audit!=null)recoveryEvents.failed(audit,"native_health_limit_unavailable"); report(record.id(), failure); return; }
characters.saveHealth(record.id(), expected, target, limit).whenComplete((saved, failure) -> {
try {
nextTick.accept(() -> complete(state, token, generation, expected, current, saved, failure));
nextTick.accept(() -> complete(state, token, generation, expected, current, configured.amount(), quickerContribution, audit, saved, failure));
} catch (RuntimeException rejected) {
synchronized (OutOfCombatRecovery.this) {
if (state.pending == token) { state.pending = null; state.lastAttempt = clock.instant(); }
}
report(record.id(), rejected);
if(audit!=null)recoveryEvents.failed(audit,"scheduler_rejected_after_save_uncertain");report(record.id(), rejected);
}
});
}
private synchronized void complete(State state, Object token, long generation, BigInteger expected,
double nativeBefore, java.util.Optional<CharacterRecord> saved, Throwable failure) {
double nativeBefore, BigInteger configuredBase,BigInteger quickerConfigured,games.dmg.fantasystick.events.RecoveryEventBridge.Action audit,java.util.Optional<CharacterRecord> saved, Throwable failure) {
if (state.pending != token) return;
state.pending = null;
if (failure != null || saved == null || saved.isEmpty()) {
state.lastAttempt = clock.instant();
report(state.active.character(), failure != null ? failure : new IllegalStateException("Character unavailable for recovery save"));
if(audit!=null)recoveryEvents.failed(audit,failure!=null?"health_save_failed":"character_unavailable");report(state.active.character(), failure != null ? failure : new IllegalStateException("Character unavailable for recovery save"));
return;
}
var persisted = saved.orElseThrow();
boolean acknowledged = sessions.acknowledgeProgress(state.active, expected, persisted);
if (!acknowledged) {
report(state.active.character(), new IllegalStateException("Saved recovery could not synchronize with its active session"));
if(audit!=null)recoveryEvents.failed(audit,"saved_but_session_publication_refused");report(state.active.character(), new IllegalStateException("Saved recovery could not synchronize with its active session"));
return;
}
if (closed || generation != state.generation || states.get(state.active.player()) != state
|| !sessions.current(state.active)) return;
|| !sessions.current(state.active)) {if(audit!=null)recoveryEvents.failed(audit,"saved_but_session_changed_before_native_heal");return;}
var player = sessions.currentPlayer(state.active).orElse(null);
if (player == null || Double.compare(player.getHealth(), nativeBefore) != 0) return;
if (player == null || Double.compare(player.getHealth(), nativeBefore) != 0) {if(audit!=null)recoveryEvents.failed(audit,"saved_but_native_health_changed");return;}
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
if (attribute == null) return;
if (attribute == null) {if(audit!=null)recoveryEvents.failed(audit,"saved_but_maximum_health_unavailable");return;}
double maximum = Math.min(attribute.getValue(), persisted.maximumHealth().doubleValue());
double publish = Math.min(maximum, persisted.health().doubleValue());
if (!Double.isFinite(maximum) || !Double.isFinite(publish) || publish <= nativeBefore) return;
player.setHealth(publish);
if (!Double.isFinite(maximum) || !Double.isFinite(publish) || publish <= nativeBefore) {if(audit!=null)recoveryEvents.failed(audit,"saved_but_no_native_heal_available");return;}
try{player.setHealth(publish);}catch(RuntimeException nativeFailure){if(audit!=null)recoveryEvents.failed(audit,"native_health_publication_failed");throw nativeFailure;}
if(audit!=null){var total=BigDecimal.valueOf(publish-nativeBefore);var base=total.min(new BigDecimal(configuredBase));var passive=total.subtract(base).min(new BigDecimal(quickerConfigured)).max(BigDecimal.ZERO);recoveryEvents.healed(audit,BigDecimal.valueOf(publish),total,base,passive);}
if (feedback != null) feedback.report("Out-of-Combat Recovery", player, AbilityReport.Type.HEALING, player,
BigDecimal.valueOf(publish - nativeBefore).stripTrailingZeros());
}
@@ -0,0 +1,94 @@
package games.dmg.fantasystick.shop;
import games.dmg.fantasystick.persistence.RareSettings;
import games.dmg.fantasystick.session.DownedController;
import games.dmg.fantasystick.session.DownedDirectory;
import games.dmg.fantasystick.session.DownedEpisode;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.time.Clock;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.entity.Player;
/** Explicit exact-episode Phoenix Down reservation, rescue and restart reconciliation. */
public final class PhoenixDownService {
public enum Status { APPLIED, APPLIED_PENDING_RECOVERY, NOT_AVAILABLE, STALE, EXPIRED, FAILED }
public record Outcome(Status status) { }
@FunctionalInterface public interface Rescue {
void revive(Player player, java.time.Duration protection, DownedEpisode.Decision decision,
Consumer<DownedController.RescueStatus> result);
}
private final RareInventoryDirectory inventory;
private final RareSettings settings;
private final DownedDirectory downed;
private final EquipmentSessions sessions;
private final Clock clock;
private final Consumer<Runnable> nextTick;
private final Rescue rescue;
private final Consumer<Throwable> failures;
public PhoenixDownService(RareInventoryDirectory inventory, RareSettings settings,
DownedDirectory downed, EquipmentSessions sessions, Clock clock,
Consumer<Runnable> nextTick, Rescue rescue, Consumer<Throwable> failures) {
this.inventory=Objects.requireNonNull(inventory);this.settings=Objects.requireNonNull(settings);
this.downed=Objects.requireNonNull(downed);this.sessions=Objects.requireNonNull(sessions);
this.clock=Objects.requireNonNull(clock);this.nextTick=Objects.requireNonNull(nextTick);
this.rescue=Objects.requireNonNull(rescue);this.failures=Objects.requireNonNull(failures);
}
public BigInteger quantity(UUID character) { return inventory.quantity(character, RareProduct.PHOENIX_DOWN); }
/** A resolved matching outcome consumed the item; every provably unclaimed reservation is refunded. */
public void install() {
for (var use : inventory.unresolvedUses()) {
if (!RareProduct.PHOENIX_DOWN.id().equals(use.product())) continue;
var episode=downed.all().stream().filter(value->use.operation().equals(value.outcomeOperation())
&& value.outcome()==DownedEpisode.Outcome.REVIVED && value.decision()!=null
&& "phoenix_down".equals(value.decision().source())).findFirst();
var recovery=episode.isPresent()?inventory.commitUse(use.operation()):inventory.refundUse(use.operation());
recovery.exceptionally(failure->{report(failure);return RareInventoryDirectory.UseStatus.RESERVED;});
}
}
public void use(Player player, UUID episodeId, Consumer<Outcome> result) {
Objects.requireNonNull(player);Objects.requireNonNull(episodeId);Objects.requireNonNull(result);
var episode=current(player,episodeId);
if(episode==null){result.accept(new Outcome(Status.STALE));return;}
if(!clock.instant().isBefore(episode.expiresAt())){result.accept(new Outcome(Status.EXPIRED));return;}
var expected=quantity(episode.character());
if(expected.signum()<=0){result.accept(new Outcome(Status.NOT_AVAILABLE));return;}
var attribute=player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH);double health=player.getHealth(),maximum=attribute==null?Double.NaN:attribute.getValue();
if(!Double.isFinite(health)||!Double.isFinite(maximum)||health<0||maximum<=0||health>maximum){result.accept(new Outcome(Status.FAILED));return;}
var operation=UUID.randomUUID();
inventory.reserveUse(episode.character(),RareProduct.PHOENIX_DOWN,operation,expected).whenComplete((reserved,failure)->publish(()->{
if(failure!=null){report(failure);result.accept(new Outcome(Status.FAILED));return;}
if(reserved.status()!=RareInventoryDirectory.UseStatus.RESERVED){result.accept(new Outcome(reserved.status()==RareInventoryDirectory.UseStatus.NOT_AVAILABLE?Status.NOT_AVAILABLE:Status.STALE));return;}
var accepted=current(player,episodeId);
if(accepted==null||!clock.instant().isBefore(accepted.expiresAt())){refund(operation,result,accepted==null?Status.STALE:Status.EXPIRED);return;}
var proof=new DownedEpisode.Phoenix(expected,expected.subtract(BigInteger.ONE),java.math.BigDecimal.valueOf(health),java.math.BigDecimal.valueOf(maximum),java.math.BigDecimal.valueOf(maximum),"downed_menu");
var decision=new DownedEpisode.Decision("phoenix_down",accepted.player(),accepted.playerName(),accepted.character(),accepted.characterName(),accepted.session(),operation,clock.instant(),null,null,null,proof);
rescue.revive(player,settings.phoenixProtection(),decision,status->complete(operation,status,result));
}));
}
private DownedEpisode current(Player player,UUID episodeId){
var episode=downed.all().stream().filter(value->value.id().equals(episodeId)&&value.phase()==DownedEpisode.Phase.PENDING).findFirst().orElse(null);
if(episode==null||!player.isOnline()||!episode.player().equals(player.getUniqueId()))return null;
var active=sessions.active(player.getUniqueId()).orElse(null);
return active!=null&&active.session().equals(episode.session())&&active.character().equals(episode.character())?episode:null;
}
private void complete(UUID operation,DownedController.RescueStatus status,Consumer<Outcome> result){
boolean won=downed.all().stream().anyMatch(value->operation.equals(value.outcomeOperation())&&value.outcome()==DownedEpisode.Outcome.REVIVED&&value.decision()!=null&&"phoenix_down".equals(value.decision().source()));
if(won){inventory.commitUse(operation).whenComplete((ignored,failure)->publish(()->{if(failure!=null){report(failure);result.accept(new Outcome(Status.APPLIED_PENDING_RECOVERY));}else result.accept(new Outcome(status==DownedController.RescueStatus.APPLIED?Status.APPLIED:Status.APPLIED_PENDING_RECOVERY));}));return;}
var mapped=switch(status){case EXPIRED->Status.EXPIRED;case STALE->Status.STALE;case FAILED->Status.FAILED;case APPLIED,APPLIED_PENDING_RECOVERY->Status.APPLIED_PENDING_RECOVERY;};
refund(operation,result,mapped);
}
private void refund(UUID operation,Consumer<Outcome> result,Status status){inventory.refundUse(operation).whenComplete((ignored,failure)->publish(()->{if(failure!=null){report(failure);result.accept(new Outcome(Status.FAILED));}else result.accept(new Outcome(status));}));}
private void publish(Runnable action){try{nextTick.accept(()->{try{action.run();}catch(RuntimeException failure){report(failure);}});}catch(RuntimeException failure){report(failure);}}
private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}
}
@@ -12,7 +12,8 @@ import java.util.function.Supplier;
/** Serialized recoverable authority for the separate shared rare catalog. */
public final class RareMarket implements AutoCloseable {
public enum PurchaseStatus{APPLIED,SOLD_OUT,STALE,INSUFFICIENT,UNAVAILABLE,FAILED}
public record PurchaseOutcome(PurchaseStatus status,Optional<CharacterRecord> record,RareCatalog catalog){}
public record PurchaseProof(UUID operation,RareProduct product,BigInteger price,int stockAfter){}
public record PurchaseOutcome(PurchaseStatus status,Optional<CharacterRecord> record,RareCatalog catalog,PurchaseProof proof){public PurchaseOutcome(PurchaseStatus status,Optional<CharacterRecord> record,RareCatalog catalog){this(status,record,catalog,null);}}
private final RareCatalogRepository catalogs;private final RarePurchaseOperationStore operations;private final CharacterStorage characters;private final RareInventoryDirectory inventory;private final Supplier<RareMarketRules> rules;private final Clock clock;private final RareCatalogGenerator.InclusiveRoll random;private final ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-rare-market"));private final CompletionStage<Void> readiness;private volatile RareCatalog snapshot;private volatile boolean initialized;private volatile Consumer<RareCatalog> listener=ignored->{};private boolean closed,operationsClosing;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
public RareMarket(RareCatalogRepository catalogs,RarePurchaseOperationStore operations,CharacterStorage characters,RareInventoryDirectory inventory,Supplier<RareMarketRules> rules,Clock clock,RareCatalogGenerator.InclusiveRoll random){this.catalogs=Objects.requireNonNull(catalogs);this.operations=Objects.requireNonNull(operations);this.characters=Objects.requireNonNull(characters);this.inventory=Objects.requireNonNull(inventory);this.rules=Objects.requireNonNull(rules);this.clock=Objects.requireNonNull(clock);this.random=Objects.requireNonNull(random);readiness=submit(()->{initialize();return null;});}
public void attachListener(Consumer<RareCatalog> listener){this.listener=Objects.requireNonNull(listener);}public Optional<RareCatalog> snapshot(){return Optional.ofNullable(snapshot);}public Instant now(){return clock.instant();}public CompletionStage<Void> ready(){return readiness;}public CompletionStage<RareCatalog> current(){return readiness.thenCompose(ignored->submit(()->ensureCurrent(RareCatalog.RefreshReason.SCHEDULED)));}
@@ -21,7 +22,7 @@ public final class RareMarket implements AutoCloseable {
public CompletionStage<PurchaseOutcome> purchase(UUID character,BigInteger expectedCharacterRevision,UUID expectedCatalogRevision,RareProduct product){return readiness.thenCompose(ignored->submit(()->purchaseBlocking(character,expectedCharacterRevision,expectedCatalogRevision,product)));}
private void initialize()throws Exception{snapshot=catalogs.load().orElse(null);var plans=operations.load().toCompletableFuture().join();if(snapshot==null&&!plans.isEmpty())throw new IOException("Rare catalog missing for pending purchases");for(var plan:plans)execute(plan);if(snapshot!=null&&!snapshot.transactions().isEmpty()){var next=snapshot;for(var entry:snapshot.transactions().entrySet()){if(entry.getValue().state()==RareCatalog.TransactionState.RESERVED)throw new IOException("Orphan reserved rare purchase "+entry.getKey());next=next.prune(entry.getKey());}persist(next,false);}ensureCurrent(RareCatalog.RefreshReason.STARTUP);initialized=true;}
private PurchaseOutcome purchaseBlocking(UUID character,BigInteger expectedCharacterRevision,UUID expectedCatalogRevision,RareProduct product)throws Exception{var catalog=ensureCurrent(RareCatalog.RefreshReason.SCHEDULED);if(!catalog.transactions().isEmpty())return new PurchaseOutcome(PurchaseStatus.FAILED,Optional.empty(),catalog);if(!catalog.revision().equals(expectedCatalogRevision))return new PurchaseOutcome(PurchaseStatus.STALE,Optional.empty(),catalog);if(!catalog.available(product))return new PurchaseOutcome(PurchaseStatus.UNAVAILABLE,Optional.empty(),catalog);if(catalog.offer(product).remaining()==0)return new PurchaseOutcome(PurchaseStatus.SOLD_OUT,Optional.empty(),catalog);var plan=new RarePurchaseOperationStore.Plan(UUID.randomUUID(),character,expectedCharacterRevision,catalog.revision(),product,catalog.offer(product).price());operations.prepare(plan).toCompletableFuture().join();return execute(plan);}
private PurchaseOutcome execute(RarePurchaseOperationStore.Plan plan)throws Exception{var catalog=Objects.requireNonNull(snapshot,"Rare catalog unavailable");if(!catalog.revision().equals(plan.catalogRevision()))throw new IOException("Pending rare purchase belongs to another catalog");var transaction=catalog.transactions().get(plan.id());if(transaction==null){try{catalog=catalog.reserve(plan.id(),plan.product());}catch(IllegalStateException soldOut){operations.complete(plan.id()).toCompletableFuture().join();return new PurchaseOutcome(PurchaseStatus.SOLD_OUT,Optional.empty(),catalog);}persist(catalog,false);transaction=catalog.transactions().get(plan.id());}if(transaction.product()!=plan.product())throw new IOException("Rare reservation product mismatch");if(transaction.state()!=RareCatalog.TransactionState.RESERVED){if(transaction.state()==RareCatalog.TransactionState.CONSUMED)inventory.add(plan.character(),plan.product(),plan.id()).join();operations.complete(plan.id()).toCompletableFuture().join();var pruned=catalog.prune(plan.id());persist(pruned,false);return new PurchaseOutcome(transaction.state()==RareCatalog.TransactionState.CONSUMED?PurchaseStatus.APPLIED:PurchaseStatus.FAILED,characters.find(plan.character()).join(),pruned);}var purchase=characters.purchaseRare(plan.character(),plan.expectedCharacterRevision(),plan.id(),plan.price()).join();boolean consumed=purchase.status()==CharacterStorage.RarePurchaseStatus.APPLIED||purchase.status()==CharacterStorage.RarePurchaseStatus.DUPLICATE;if(consumed)inventory.add(plan.character(),plan.product(),plan.id()).join();catalog=catalog.settle(plan.id(),consumed);persist(catalog,consumed);operations.complete(plan.id()).toCompletableFuture().join();catalog=catalog.prune(plan.id());persist(catalog,false);var status=switch(purchase.status()){case APPLIED,DUPLICATE->PurchaseStatus.APPLIED;case INSUFFICIENT->PurchaseStatus.INSUFFICIENT;case STALE->PurchaseStatus.STALE;case UNAVAILABLE->PurchaseStatus.UNAVAILABLE;};return new PurchaseOutcome(status,purchase.record(),catalog);}
private PurchaseOutcome execute(RarePurchaseOperationStore.Plan plan)throws Exception{var catalog=Objects.requireNonNull(snapshot,"Rare catalog unavailable");if(!catalog.revision().equals(plan.catalogRevision()))throw new IOException("Pending rare purchase belongs to another catalog");var transaction=catalog.transactions().get(plan.id());if(transaction==null){try{catalog=catalog.reserve(plan.id(),plan.product());}catch(IllegalStateException soldOut){operations.complete(plan.id()).toCompletableFuture().join();return new PurchaseOutcome(PurchaseStatus.SOLD_OUT,Optional.empty(),catalog);}persist(catalog,false);transaction=catalog.transactions().get(plan.id());}if(transaction.product()!=plan.product())throw new IOException("Rare reservation product mismatch");if(transaction.state()!=RareCatalog.TransactionState.RESERVED){if(transaction.state()==RareCatalog.TransactionState.CONSUMED)inventory.add(plan.character(),plan.product(),plan.id()).join();operations.complete(plan.id()).toCompletableFuture().join();var pruned=catalog.prune(plan.id());persist(pruned,false);return new PurchaseOutcome(transaction.state()==RareCatalog.TransactionState.CONSUMED?PurchaseStatus.APPLIED:PurchaseStatus.FAILED,characters.find(plan.character()).join(),pruned,transaction.state()==RareCatalog.TransactionState.CONSUMED?new PurchaseProof(plan.id(),plan.product(),plan.price(),pruned.offer(plan.product()).remaining()):null);}var purchase=characters.purchaseRare(plan.character(),plan.expectedCharacterRevision(),plan.id(),plan.price()).join();boolean consumed=purchase.status()==CharacterStorage.RarePurchaseStatus.APPLIED||purchase.status()==CharacterStorage.RarePurchaseStatus.DUPLICATE;if(consumed)inventory.add(plan.character(),plan.product(),plan.id()).join();catalog=catalog.settle(plan.id(),consumed);persist(catalog,consumed);operations.complete(plan.id()).toCompletableFuture().join();catalog=catalog.prune(plan.id());persist(catalog,false);var status=switch(purchase.status()){case APPLIED,DUPLICATE->PurchaseStatus.APPLIED;case INSUFFICIENT->PurchaseStatus.INSUFFICIENT;case STALE->PurchaseStatus.STALE;case UNAVAILABLE->PurchaseStatus.UNAVAILABLE;};return new PurchaseOutcome(status,purchase.record(),catalog,status==PurchaseStatus.APPLIED?new PurchaseProof(plan.id(),plan.product(),plan.price(),catalog.offer(plan.product()).remaining()):null);}
private RareCatalog ensureCurrent(RareCatalog.RefreshReason reason)throws IOException{var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));if(snapshot!=null&&snapshot.day().equals(today))return snapshot;if(snapshot!=null&&!snapshot.transactions().isEmpty())throw new IOException("Rare purchases unresolved at rollover");var generated=RareCatalogGenerator.generate(today,clock.instant().truncatedTo(java.time.temporal.ChronoUnit.SECONDS),reason,rules.get(),random);persist(generated,true);return generated;}
private void persist(RareCatalog catalog,boolean notify)throws IOException{catalogs.save(catalog);snapshot=catalog;if(notify&&initialized)try{listener.accept(catalog);}catch(RuntimeException ignored){}}
private <T>CompletionStage<T> submit(Callable<T> action){synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Rare market closed"));pending++;}return CompletableFuture.supplyAsync(()->{try{return action.call();}catch(Exception failure){throw new CompletionException(failure);}finally{finished();}},worker).minimalCompletionStage();}
@@ -9,7 +9,8 @@ public enum RareProduct {
HEALTH_POTION("health-potion", "Health Potion", 150, 5, 50),
GREATER_HEALTH_POTION("greater-health-potion", "Greater Health Potion", 500, 2, 100),
RESKILL("reskill", "Reskill", 2000, 1, null),
MAGIC_CORE("magic-core", "Magic Core", 3000, 3, null);
MAGIC_CORE("magic-core", "Magic Core", 3000, 3, null),
PHOENIX_DOWN("phoenix-down", "Phoenix Down", 1000, 2, null);
private final String id;
private final String displayName;
@@ -15,11 +15,12 @@ public final class RareShop {
public record Outcome(Status status,Optional<CharacterRecord> record){}
public record View(CharacterRecord record,UUID session,RareCatalog catalog){}
private record Context(EquipmentSessions.Active active,CharacterRecord record,RareMerchant shop){}
private final RareMerchantDirectory shops;private final EquipmentSessions sessions;private final RareMarket market;private final Consumer<Runnable> nextTick;private final Consumer<Throwable> failures;
public RareShop(RareMerchantDirectory shops,EquipmentSessions sessions,RareMarket market,Consumer<Runnable> nextTick,Consumer<Throwable> failures){this.shops=Objects.requireNonNull(shops);this.sessions=Objects.requireNonNull(sessions);this.market=Objects.requireNonNull(market);this.nextTick=Objects.requireNonNull(nextTick);this.failures=Objects.requireNonNull(failures);}
private final RareMerchantDirectory shops;private final EquipmentSessions sessions;private final RareMarket market;private final RareInventoryDirectory inventory;private final Consumer<Runnable> nextTick;private final Consumer<Throwable> failures;private games.dmg.fantasystick.events.RareEventBridge events;
public RareShop(RareMerchantDirectory shops,EquipmentSessions sessions,RareMarket market,RareInventoryDirectory inventory,Consumer<Runnable> nextTick,Consumer<Throwable> failures){this.shops=Objects.requireNonNull(shops);this.sessions=Objects.requireNonNull(sessions);this.market=Objects.requireNonNull(market);this.inventory=Objects.requireNonNull(inventory);this.nextTick=Objects.requireNonNull(nextTick);this.failures=Objects.requireNonNull(failures);}
public void attachEvents(games.dmg.fantasystick.events.RareEventBridge events){if(this.events!=null)throw new IllegalStateException("Rare events already attached");this.events=Objects.requireNonNull(events);}
public Optional<CharacterRecord> active(Player player,UUID shop){return context(player,shop).map(Context::record);}public Optional<UUID> activeSession(Player player,UUID shop){return context(player,shop).map(value->value.active().session());}public Instant now(){return market.now();}
public void view(Player player,UUID shopId,Consumer<Optional<View>> result){var captured=context(player,shopId);if(captured.isEmpty()){result.accept(Optional.empty());return;}market.current().whenComplete((catalog,failure)->{if(failure!=null){report(failure);publish(()->result.accept(Optional.empty()));return;}publish(()->{var current=context(player,shopId);if(current.isEmpty()||!current.orElseThrow().active().session().equals(captured.orElseThrow().active().session()))result.accept(Optional.empty());else result.accept(Optional.of(new View(current.orElseThrow().record(),current.orElseThrow().active().session(),catalog)));});});}
public void buy(Player player,UUID shopId,UUID catalogRevision,RareProduct product,Consumer<Outcome> result){var lease=shops.acquire(shopId);if(lease.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var held=lease.orElseThrow();var context=context(player,shopId);if(context.isEmpty()){held.close();result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var captured=context.orElseThrow();market.purchase(captured.record().id(),captured.record().revision(),catalogRevision,product).whenComplete((purchase,failure)->{if(failure!=null){report(failure);publishHeld(held,()->result.accept(new Outcome(Status.FAILED,Optional.empty())));return;}publishHeld(held,()->{var current=context(player,shopId);if(current.isEmpty()||!current.orElseThrow().active().session().equals(captured.active().session())){result.accept(new Outcome(Status.STALE,purchase.record()));return;}var status=switch(purchase.status()){case APPLIED->Status.APPLIED;case SOLD_OUT->Status.SOLD_OUT;case INSUFFICIENT->Status.INSUFFICIENT;case STALE->Status.STALE;case UNAVAILABLE,FAILED->Status.FAILED;};if(purchase.status()==RareMarket.PurchaseStatus.APPLIED&&purchase.record().isPresent())sessions.acknowledgeProgress(captured.active(),captured.record().revision(),purchase.record().orElseThrow());result.accept(new Outcome(status,purchase.record()));});});}
public void buy(Player player,UUID shopId,UUID catalogRevision,RareProduct product,Consumer<Outcome> result){var lease=shops.acquire(shopId);if(lease.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var held=lease.orElseThrow();var context=context(player,shopId);if(context.isEmpty()){held.close();result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var captured=context.orElseThrow();market.purchase(captured.record().id(),captured.record().revision(),catalogRevision,product).whenComplete((purchase,failure)->{if(failure!=null){report(failure);publishHeld(held,()->result.accept(new Outcome(Status.FAILED,Optional.empty())));return;}publishHeld(held,()->{var current=context(player,shopId);if(current.isEmpty()||!current.orElseThrow().active().session().equals(captured.active().session())){result.accept(new Outcome(Status.STALE,purchase.record()));return;}var status=switch(purchase.status()){case APPLIED->Status.APPLIED;case SOLD_OUT->Status.SOLD_OUT;case INSUFFICIENT->Status.INSUFFICIENT;case STALE->Status.STALE;case UNAVAILABLE,FAILED->Status.FAILED;};if(purchase.status()==RareMarket.PurchaseStatus.APPLIED&&purchase.record().isPresent()){sessions.acknowledgeProgress(captured.active(),captured.record().revision(),purchase.record().orElseThrow());if(product==RareProduct.PHOENIX_DOWN&&purchase.proof()!=null&&events!=null){var after=inventory.quantity(captured.record().id(),product);events.purchased(purchase.proof().operation(),purchase.catalog().revision(),player.getUniqueId(),player.getName(),captured.record().id(),captured.record().name(),captured.active().session(),purchase.proof().price(),purchase.proof().stockAfter()+1,purchase.proof().stockAfter(),after.subtract(java.math.BigInteger.ONE),after);}}result.accept(new Outcome(status,purchase.record()));});});}
private Optional<Context> context(Player player,UUID shopId){var shop=shops.find(shopId).orElse(null);var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);if(shop==null||record==null)return Optional.empty();var location=player.getLocation();if(location.getWorld()==null||!location.getWorld().getUID().equals(shop.world()))return Optional.empty();double x=location.getX()-shop.x(),y=location.getY()-shop.y(),z=location.getZ()-shop.z();return x*x+y*y+z*z<=36?Optional.of(new Context(active,record,shop)):Optional.empty();}
private void publishHeld(RareMerchantDirectory.Lease lease,Runnable action){try{nextTick.accept(()->{try{action.run();}finally{lease.close();}});}catch(RuntimeException failure){lease.close();report(failure);}}private void publish(Runnable action){try{nextTick.accept(action);}catch(RuntimeException failure){report(failure);}}private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}
}
+4
View File
@@ -47,6 +47,10 @@ rare-stock-reskill-max: 1
rare-price-magic-core: 3000
rare-stock-magic-core-min: 0
rare-stock-magic-core-max: 3
rare-price-phoenix-down: 1000
rare-stock-phoenix-down-min: 0
rare-stock-phoenix-down-max: 2
rare-phoenix-down-invulnerability-seconds: 5
downed-timeout-seconds: 300
revive-downed-invulnerability-seconds: 5
sheet-resource-pack-url: ''
@@ -77,6 +77,11 @@ class AbilityEngineNativeTest {
(CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"), sessions, parties,
java.time.Clock.fixed(java.time.Instant.parse("2026-09-19T20:00:00Z"), java.time.ZoneOffset.UTC),
f.tasks::add, scheduler, reports::add, failure -> fail(failure.toString()));
var captured = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
engine.attachCastEvents(new games.dmg.fantasystick.events.AbilityCastEventBridge(
event -> { if (event.type().equals("action.observed")) throw new IllegalStateException("test writer unavailable");
captured.add(event); }, java.time.Clock.systemUTC()));
engine.enableCastEventsFor("area_test"); engine.enableCastEventsFor("selection_test");
var active = sessions.active(a.core.id).orElseThrow();
var hostile = org.mockito.Mockito.mock(org.bukkit.entity.LivingEntity.class);
org.mockito.Mockito.when(hostile.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
@@ -85,6 +90,7 @@ class AbilityEngineNativeTest {
reports.stream().map(games.dmg.fantasystick.ability.AbilityReport::type).collect(java.util.stream.Collectors.toSet()));
assertEquals(5, reports.size());
assertEquals(a.core.record.id(), reports.getFirst().sourceCharacter());
assertTrue(captured.isEmpty(), "Unselected real abilities and reports cannot claim event delivery");
var ordinary = org.mockito.Mockito.mock(org.bukkit.entity.Player.class);
org.mockito.Mockito.when(ordinary.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
@@ -109,11 +115,18 @@ class AbilityEngineNativeTest {
assertEquals(0, delayedEffects.get(), "Party eligibility is rechecked at delayed application");
active = sessions.active(a.core.id).orElseThrow();
cast(engine, f, a, active, 5, "selection_test", b.player);
cast(engine, f, a, active, 5, "selection_test", null);
assertEquals(1, delayed.size());
var selectionCast = captured.stream().filter(event -> event.type().equals("ability.cast")
&& event.data().get("ability_id").equals("selection_test")).findFirst().orElseThrow();
assertEquals(active.session(), selectionCast.context().sessionId());
engine.remove(a.core.record.id(), "selection_test");
delayed.removeFirst().run();
assertEquals(0, selectionEffects.get(), "Removal invalidates pending selection callbacks without clearing cooldowns");
assertEquals(1, captured.stream().filter(event -> event.type().equals("ability.resolved")
&& event.actionId().equals(selectionCast.actionId()) && event.data().get("outcome").equals("interrupted")).count());
assertTrue(captured.stream().noneMatch(event -> event.type().equals("effect.applied")),
"No-op casts and unused reports cannot invent an effect");
var stored = ((CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"))
.find(a.core.record.id()).get().orElseThrow();
assertTrue(stored.cooldowns().containsKey("selection_test"));
@@ -138,7 +151,14 @@ class AbilityEngineNativeTest {
List.of("A registered test effect"), "Use the equipped shard", Map.of(), BigInteger.ONE, AbilityDefinition.Category.BUF,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, Duration.ofSeconds(4),
Map.of("range", BigDecimal.valueOf(12)), AbilityDefinition.TargetPolicy.SELF,
execution -> { assertTrue(execution.stillCurrent()); effects.incrementAndGet(); }));
execution -> {
assertTrue(execution.stillCurrent());
effects.incrementAndGet(); // Test-defined native execution, not a real spell.
execution.effectApplied("test_focus_effect", null, Map.of("native_applications", 1));
}));
var engine = (games.dmg.fantasystick.ability.AbilityEngine)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityEngine");
engine.enableCastEventsFor("test_focus");
var a = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(a.core.id, a.player); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(0);
@@ -201,6 +221,82 @@ class AbilityEngineNativeTest {
assertEquals(1, effects.get(), "Exit/resume cannot reset the persisted character cooldown");
assertEquals(cooled.cooldowns(), storage.find(a.core.record.id()).get().orElseThrow().cooldowns());
}
var events = new java.util.ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(data.resolve("events"))) {
for (var file : files.filter(path -> path.toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(file))
if (!line.isBlank()) events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
for (var suffix : List.of("ability.learned", "ability.assigned", "ability.cast", "ability.resolved",
"effect.applied", "effect.ended")) {
assertEquals(1, events.stream().filter(event -> event.get("type").getAsString().endsWith(suffix)
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("test_focus")).count(), suffix);
}
var castEvent = events.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("test_focus")).findFirst().orElseThrow();
assertEquals("test_focus", castEvent.getAsJsonObject("data").get("ability_id").getAsString());
assertTrue(castEvent.getAsJsonObject("data").has("cooldown_until"));
var applied = events.stream().filter(event -> event.get("type").getAsString().endsWith("effect.applied")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("test_focus")).findFirst().orElseThrow();
var ended = events.stream().filter(event -> event.get("type").getAsString().endsWith("effect.ended")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("test_focus")).findFirst().orElseThrow();
assertEquals(applied.getAsJsonObject("data").get("application_id").getAsString(),
ended.getAsJsonObject("data").get("application_id").getAsString());
assertEquals("1", applied.getAsJsonObject("data").get("native_applications").getAsString());
assertTrue(events.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved"))
.anyMatch(event -> event.getAsJsonObject("data").get("action_id").getAsString()
.equals(castEvent.getAsJsonObject("data").get("action_id").getAsString())));
}
@Test void failedCooldownSaveOfRegisteredDefinitionNeverClaimsACastOrEffect() {
var registry = new AbilityRegistry();
var effects = new AtomicInteger();
registry.register(new AbilityDefinition("test_failure", "Test Failure", Material.AMETHYST_SHARD,
List.of("A test-only ability"), "Use the control", Map.of(), BigInteger.ONE,
AbilityDefinition.Category.BUF, AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE,
Duration.ofSeconds(4), Map.of(), AbilityDefinition.TargetPolicy.SELF,
execution -> effects.incrementAndGet()));
var original = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var record = new games.dmg.fantasystick.persistence.CharacterRecord(original.id(), original.name(),
original.revision(), original.stats(), original.level(), original.experience(), original.unspentPoints(),
original.health(), java.util.Set.of("test_failure"), List.of("test_failure", "", "", "", "", "", ""),
original.cooldowns(), original.restrictedKills(), false, null, null);
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),
java.util.UUID.randomUUID(), record.id());
var player = org.mockito.Mockito.mock(org.bukkit.entity.Player.class);
var world = org.mockito.Mockito.mock(org.bukkit.World.class);
org.mockito.Mockito.when(player.getUniqueId()).thenReturn(active.player());
org.mockito.Mockito.when(player.getName()).thenReturn("Account");
org.mockito.Mockito.when(world.getUID()).thenReturn(java.util.UUID.randomUUID());
org.mockito.Mockito.when(world.getName()).thenReturn("world");
org.mockito.Mockito.when(player.getLocation()).thenReturn(new org.bukkit.Location(world, 1, 64, 2));
var sessions = org.mockito.Mockito.mock(games.dmg.fantasystick.session.EquipmentSessions.class);
org.mockito.Mockito.when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
var storage = org.mockito.Mockito.mock(CharacterStorage.class);
var failed = java.util.concurrent.CompletableFuture
.<CharacterStorage.AbilityMutation>failedFuture(new java.io.IOException("simulated save failure"));
org.mockito.Mockito.when(storage.commitAbilityCooldown(org.mockito.ArgumentMatchers.eq(record.id()),
org.mockito.ArgumentMatchers.eq(record.revision()), org.mockito.ArgumentMatchers.eq("test_failure"),
org.mockito.ArgumentMatchers.any())).thenReturn(failed);
var parties = new games.dmg.fantasystick.party.PartyRoster(java.time.Clock.systemUTC(), ignored -> true);
var events = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
var failures = new java.util.ArrayList<games.dmg.fantasystick.ability.AbilityEngine.Failure>();
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry, storage, sessions, parties,
java.time.Clock.systemUTC(), Runnable::run, (delay, action) -> () -> {}, ignored -> {}, failures::add);
engine.attachCastEvents(new games.dmg.fantasystick.events.AbilityCastEventBridge(events::add,
java.time.Clock.systemUTC()));
engine.enableCastEventsFor("test_failure");
engine.accept(new ControlInput(player, active, 2, "test_failure", ControlInput.Kind.ABILITY,
ControlInput.Gesture.USE, null, 0, 0, () -> true));
assertEquals(List.of("action.observed", "ability.resolved"), events.stream().map(
games.dmg.fantasystick.events.EventInput::type).toList());
assertEquals("failed", events.getLast().data().get("outcome"));
assertEquals(events.getFirst().actionId(), events.getLast().actionId());
assertEquals(0, effects.get());
assertEquals(1, failures.size());
engine.close(); parties.close();
}
private static AbilityDefinition definition(String id, AbilityDefinition.Activation activation,
@@ -26,7 +26,7 @@ class AdminExperienceNativeTest {
var viewer = new FantasyStickLifecycleNativeTest.Actor(data);
viewer.core.inventory.setItemInOffHand(null); viewer.core.location.setWorld(target.world);
f.players.put(viewer.core.id, viewer.player);
f.particlesCommand.execute(target.player, "fantasyparticles", new String[]{"all", "off"}); f.tick();
PartyBookNativeTest.disableAllParticles(f,target);
target.particleCalls.clear(); viewer.particleCalls.clear();
var sender = new EquipmentAdminCommandNativeTest.Sender();
var before = storage.find(target.core.record.id()).get().orElseThrow();
@@ -51,7 +51,7 @@ class ChainLightningNativeTest {
var viewer = new FantasyStickLifecycleNativeTest.Actor(data);
viewer.core.inventory.setItemInOffHand(null); viewer.core.location.setWorld(caster.world);
fixture.players.put(viewer.core.id, viewer.player);
fixture.particlesCommand.execute(caster.player, "fantasyparticles", new String[]{"all", "off"}); PartyBookNativeTest.drain(fixture);
PartyBookNativeTest.disableAllParticles(fixture,caster);
caster.particleCalls.clear(); viewer.particleCalls.clear();
var damage = new LinkedHashMap<UUID, Double>(); nativeDamage(fixture, first, damage); nativeDamage(fixture, second, damage);
use(fixture, caster); drain(fixture, 2);
@@ -67,6 +67,69 @@ class ChainLightningNativeTest {
assertEquals(BigInteger.valueOf(39), stored.unspentPoints()); assertTrue(stored.learned().contains("chain_lightning"));
assertEquals("chain_lightning", stored.loadout().getFirst()); assertNotNull(stored.cooldowns().get("chain_lightning"));
}
var records = events(data);
for (var type : List.of("ability.learned", "ability.assigned", "ability.cast", "ability.resolved"))
assertEquals(1, abilityEvents(records, type).size(), type);
var hits = abilityEvents(records, "combat.damage");
assertEquals(2, hits.size(), "Exactly one positive accepted record per hop");
assertEquals(2, records.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count(),
"The generic native observer must not repeat feature-owned chain hits");
assertEquals(List.of(1, 2), hits.stream().map(event -> event.getAsJsonObject("data").get("hop_ordinal").getAsInt()).toList());
assertEquals(2, hits.stream().map(event -> event.getAsJsonObject("data").get("application_id").getAsString()).distinct().count());
assertEquals(13, hits.getFirst().getAsJsonObject("data").get("actual_amount").getAsDouble(), 1.0e-9);
assertEquals(9.75, hits.getLast().getAsJsonObject("data").get("actual_amount").getAsDouble(), 1.0e-9);
assertTrue(hits.stream().allMatch(event -> event.getAsJsonObject("data").has("target_location")));
assertEquals("hit", abilityEvents(records, "ability.resolved").getFirst().getAsJsonObject("data").get("outcome").getAsString());
}
private static List<com.google.gson.JsonObject> events(Path root) throws Exception {
var values = new ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(root.resolve("events"))) {
for (var file : files.filter(path -> path.toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(file))
if (!line.isBlank()) values.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
return values;
}
private static List<com.google.gson.JsonObject> abilityEvents(List<com.google.gson.JsonObject> events, String suffix) {
return events.stream().filter(event -> event.get("type").getAsString().endsWith(suffix)
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("chain_lightning")).toList();
}
@Test void cancelledMiddleHopContinuesWithoutClaimingItsNativeDamage() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var caster = new FantasyStickLifecycleNativeTest.Actor(data); prepareCaster(data, caster, true);
fixture.players.put(caster.core.id, caster.player); fixture.activate(caster);
var first = mock(LivingEntity.class); var cancelled = mock(LivingEntity.class); var third = mock(LivingEntity.class);
locate(first, caster.world, UUID.randomUUID(), 10);
locate(cancelled, caster.world, UUID.randomUUID(), 14);
locate(third, caster.world, UUID.randomUUID(), 18);
doReturn(new Location(caster.world, 0, 64, 0)).when(caster.player).getLocation();
doReturn(new Location(caster.world, 0, 65, 0)).when(caster.player).getEyeLocation();
doReturn(true).when(caster.player).hasLineOfSight(first);
doReturn(new org.bukkit.util.RayTraceResult(new org.bukkit.util.Vector(10, 64, 0), first))
.when(caster.world).rayTraceEntities(any(Location.class), any(org.bukkit.util.Vector.class), eq(16.0),
org.mockito.ArgumentMatchers.<java.util.function.Predicate<org.bukkit.entity.Entity>>any());
doReturn(List.of(cancelled)).when(first).getNearbyEntities(anyDouble(), anyDouble(), anyDouble());
doReturn(List.of(third)).when(cancelled).getNearbyEntities(anyDouble(), anyDouble(), anyDouble());
doReturn(true).when(first).hasLineOfSight(cancelled); doReturn(true).when(cancelled).hasLineOfSight(third);
var applied = new LinkedHashMap<UUID, Double>();
for (var target : List.of(first, cancelled, third)) nativeDamage(fixture, target, applied);
class Veto implements org.bukkit.event.Listener {
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.LOWEST)
public void cancel(EntityDamageEvent event) { if (event.getEntity() == cancelled) event.setCancelled(true); }
}
fixture.dispatcher.register(new Veto(), fixture.plugin);
use(fixture, caster); drain(fixture, 2);
assertEquals(2, applied.size()); assertFalse(applied.containsKey(cancelled.getUniqueId()));
}
var records = events(data); var hits = abilityEvents(records, "combat.damage");
assertEquals(List.of(1, 3), hits.stream().map(event -> event.getAsJsonObject("data").get("hop_ordinal").getAsInt()).toList());
assertEquals("7.3125", hits.getLast().getAsJsonObject("data").get("defined_amount").getAsString());
assertEquals(3, abilityEvents(records, "ability.resolved").getFirst().getAsJsonObject("data").get("attempted_hops").getAsInt());
assertEquals(2, abilityEvents(records, "ability.resolved").getFirst().getAsJsonObject("data").get("accepted_hops").getAsInt());
}
@SuppressWarnings("unchecked")
@@ -221,6 +284,18 @@ class ChainLightningNativeTest {
assertEquals(0, new BigDecimal("7").compareTo(configured.parameters().get("jump-range")));
assertEquals(new BigDecimal("1"), configured.parameters().get("additional-targets"));
assertEquals(new BigDecimal("0.25"), configured.parameters().get("jump-multiplier"));
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
var lock = channel.lock()) {
assertTrue(lock.isValid());
fixture.command.execute(sender.port, "fstickadmin",
new String[]{"config", "chain-lightning-jump-range-blocks", "8"});
var failed = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(failed); failed.run();
}
assertEquals("7", ((games.dmg.fantasystick.persistence.ChainLightningSettings)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "chainLightningSettings"))
.value("chain-lightning-jump-range-blocks"));
assertTrue(sender.messages.getLast().contains("save failed"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "chain-lightning-jump-range-blocks", "0"});
assertTrue(sender.messages.getLast().contains("invalid"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "chain-lightning-jump-range-blocks", "7", "extra"});
@@ -232,6 +307,11 @@ class ChainLightningNativeTest {
assertEquals(new BigDecimal("0.25"), saved.parameters().get("jump-multiplier"));
assertEquals(java.time.Duration.ofSeconds(3), saved.cooldown());
}
var config = events(data).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().startsWith("chain-lightning-")).toList();
assertEquals(7, config.size(), "One authorized read plus six acknowledged saved settings, no denied/invalid success");
}
private static void nativeDamage(FantasyStickLifecycleNativeTest.Fixture fixture, LivingEntity target, Map<UUID, Double> damage) {
@@ -21,6 +21,16 @@ class CharacterBookNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookEventSinkFailureCannotChangeTheAlreadyAuthorizedNativeBookPath() {
var player=org.mockito.Mockito.mock(org.bukkit.entity.Player.class);var world=org.mockito.Mockito.mock(org.bukkit.World.class);var record=org.mockito.Mockito.mock(games.dmg.fantasystick.persistence.CharacterRecord.class);var playerId=UUID.randomUUID();var active=new games.dmg.fantasystick.session.EquipmentSessions.Active(playerId,UUID.randomUUID(),UUID.randomUUID());org.mockito.Mockito.when(player.getUniqueId()).thenReturn(playerId);org.mockito.Mockito.when(player.getName()).thenReturn("Player");org.mockito.Mockito.when(world.getUID()).thenReturn(UUID.randomUUID());org.mockito.Mockito.when(world.getName()).thenReturn("world");org.mockito.Mockito.when(player.getLocation()).thenReturn(new org.bukkit.Location(world,1,2,3));org.mockito.Mockito.when(record.name()).thenReturn("Hero");org.mockito.Mockito.when(record.revision()).thenReturn(java.math.BigInteger.ONE);org.mockito.Mockito.when(record.stats()).thenReturn(java.util.Map.of(games.dmg.fantasystick.progression.Stat.CON,java.math.BigInteger.ZERO));org.mockito.Mockito.when(record.unspentPoints()).thenReturn(java.math.BigInteger.ONE);org.mockito.Mockito.when(record.maximumHealth()).thenReturn(java.math.BigDecimal.TEN);org.mockito.Mockito.when(record.health()).thenReturn(java.math.BigDecimal.ONE);org.mockito.Mockito.when(record.cooldowns()).thenReturn(java.util.Map.of());org.mockito.Mockito.when(record.loadout()).thenReturn(java.util.List.of("","","","","","",""));var input=new games.dmg.fantasystick.session.ControlInput(player,active,0,"",games.dmg.fantasystick.session.ControlInput.Kind.BOOK,games.dmg.fantasystick.session.ControlInput.Gesture.USE,null,0,0,()->true);var bridge=new games.dmg.fantasystick.events.BookEventBridge(event->{throw new IllegalStateException("disk");},java.time.Clock.systemUTC());var progression=new games.dmg.fantasystick.events.ProgressionEventBridge(event->{throw new IllegalStateException("disk");},java.time.Clock.systemUTC());var abilityEvents=new games.dmg.fantasystick.events.AbilityEventBridge(event->{throw new IllegalStateException("disk");},java.time.Clock.systemUTC());var passiveEvents=new games.dmg.fantasystick.events.PassiveEventBridge(event->{throw new IllegalStateException("disk");},java.time.Clock.systemUTC());assertDoesNotThrow(()->{var request=bridge.request(input,record,0);bridge.opened(request,record,3,1);bridge.refused(request,"retry");var action=progression.begin(input,record,games.dmg.fantasystick.progression.Stat.CON,java.math.BigDecimal.ONE,java.math.BigDecimal.TEN,java.math.BigDecimal.valueOf(1024));progression.increased(action,record,"acknowledged",java.math.BigDecimal.ONE);var unlearn=abilityEvents.begin(input,record,"fireball");abilityEvents.failed(unlearn,"save_failed");var definition=new games.dmg.fantasystick.passive.QuickerRegenerationI(games.dmg.fantasystick.passive.QuickerRegenerationI.Settings.defaults()).definition();var passive=passiveEvents.begin(input,record,definition,true);passiveEvents.failed(passive,"save_failed");});
}
@Test void registeredOpenReachesOneAcknowledgedFeatureEventAfterAuthoritativeReadAndNativeOpen() throws Exception {
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(a.core.id,a.player);f.activate(a);click(f,a);drainReads(f);runQueued(f);assertEquals(1,a.books.size());f.plugin.setEnabled(false);f.awaitWorkers();}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}
var opened=events.stream().filter(event->event.get("type").getAsString().endsWith("character_book.opened")).toList();assertEquals(1,opened.size());var data=opened.getFirst().getAsJsonObject("data");assertEquals("active",data.get("view_kind").getAsString());assertEquals("0",data.get("offset").getAsString());assertTrue(data.get("loaded_revision").getAsBigInteger().signum()>=0);assertTrue(data.get("presented_pages").getAsInt()>0);assertEquals(1,data.get("presented_volumes").getAsInt());assertEquals("acknowledged",data.get("native_open").getAsString());var action=data.get("action_id").getAsString();assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("action.observed")&&event.getAsJsonObject("data").get("action_id").getAsString().equals(action)).count());assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("action.resolved")&&event.getAsJsonObject("data").get("action_id").getAsString().equals(action)).count());
}
@Test void queuedInspectionCannotOpenForAMovedSheetChangedSlotQuitOrDisabledLifetime() throws Exception {
for (int route = 0; route < 4; route++) try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve("route-" + route))) {
f.plugin.setEnabled(true);
@@ -52,6 +62,7 @@ class CharacterBookNativeTest {
assertEquals(1, a.books.size());
assertTrue(text(a.books.getFirst()).contains("Latest book name"));
}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("character_book.opened")).count());assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("action.resolved")&&event.getAsJsonObject("data").has("action_kind")&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("character_book_open")&&event.getAsJsonObject("data").has("reason")&&event.getAsJsonObject("data").get("reason").getAsString().equals("superseded")).count());
}
@Test void cappedProgressAndSavedAbilitiesRenderWithoutCappingStatsOrRewritingProgress() throws Exception {
@@ -101,6 +112,17 @@ class CharacterBookNativeTest {
assertEquals(saved, characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow(),
"A rendered purchase action is single-use");
}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("character_book.opened")).count(),"The automatic post-purchase refresh must not duplicate a user-requested book-open outcome");var increased=events.stream().filter(event->event.get("type").getAsString().endsWith("character.stat_increased")).toList();assertEquals(1,increased.size());var value=increased.getFirst().getAsJsonObject("data");assertEquals("CON",value.get("stat_id").getAsString());assertEquals("0",value.get("stat_before").getAsString());assertEquals("1",value.get("stat_after").getAsString());assertEquals("40",value.get("points_before").getAsString());assertEquals("39",value.get("points_after").getAsString());assertEquals("5.0",value.get("health_before").getAsString());assertEquals("6.0",value.get("health_after").getAsString());assertEquals("10.0",value.get("maximum_health_before").getAsString());assertEquals("11",value.get("maximum_health_after").getAsString());assertEquals("acknowledged",value.get("native_publication").getAsString());var action=value.get("action_id").getAsString();assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("action.observed")&&event.getAsJsonObject("data").has("action_kind")&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("character_stat_increase")&&event.getAsJsonObject("data").get("action_id").getAsString().equals(action)).count());assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("action.resolved")&&event.getAsJsonObject("data").has("action_kind")&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("character_stat_increase")&&event.getAsJsonObject("data").get("action_id").getAsString().equals(action)).count());
}
@Test void everyPermanentStatPublishesExactlyOneAcknowledgedMutation() throws Exception {
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(a.core.id,a.player);f.activate(a);PartyBookNativeTest.open(f,a);for(var stat:games.dmg.fantasystick.progression.Stat.values()){PartyBookNativeTest.click(f,a,PartyBookNativeTest.button(a,"[+1 "+stat+"]"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}f.plugin.setEnabled(false);f.awaitWorkers();}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}var increased=events.stream().filter(event->event.get("type").getAsString().endsWith("character.stat_increased")).toList();assertEquals(6,increased.size());assertEquals(java.util.Arrays.stream(games.dmg.fantasystick.progression.Stat.values()).map(Enum::name).collect(java.util.stream.Collectors.toSet()),increased.stream().map(event->event.getAsJsonObject("data").get("stat_id").getAsString()).collect(java.util.stream.Collectors.toSet()));assertEquals(6,increased.stream().map(event->event.get("id").getAsString()).distinct().count());assertTrue(increased.stream().allMatch(event->event.getAsJsonObject("data").get("native_publication").getAsString().equals("acknowledged")));
}
@Test void conPurchaseAtTheNativeCeilingRecordsStatAndPointChangeWithoutInventingHealing() throws Exception {
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);var old=a.core.record;var stats=new java.util.EnumMap<games.dmg.fantasystick.progression.Stat,java.math.BigInteger>(games.dmg.fantasystick.progression.Stat.class);stats.putAll(old.stats());stats.put(games.dmg.fantasystick.progression.Stat.CON,java.math.BigInteger.valueOf(1014));var prepared=new games.dmg.fantasystick.persistence.CharacterRecord(old.id(),old.name(),old.revision().add(java.math.BigInteger.ONE),stats,old.level(),old.experience(),java.math.BigInteger.ONE,java.math.BigDecimal.TEN,old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),false,old.deathId(),old.diedAt(),old.coins(),old.coinsInitialized(),old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(prepared,old.revision());f.players.put(a.core.id,a.player);f.activate(a);PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,PartyBookNativeTest.button(a,"[+1 CON]"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);f.plugin.setEnabled(false);f.awaitWorkers();}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}var value=events.stream().filter(event->event.get("type").getAsString().endsWith("character.stat_increased")).map(event->event.getAsJsonObject("data")).findFirst().orElseThrow();assertEquals("1014",value.get("stat_before").getAsString());assertEquals("1015",value.get("stat_after").getAsString());assertEquals(0,new java.math.BigDecimal(value.get("maximum_health_before").getAsString()).compareTo(new java.math.BigDecimal(value.get("maximum_health_after").getAsString())));assertEquals(0,new java.math.BigDecimal(value.get("health_before").getAsString()).compareTo(new java.math.BigDecimal(value.get("health_after").getAsString())));
}
@Test void conPurchaseKeepsItsOwnMaximumAndOneTimeGainDespiteExternalModifiers() throws Exception {
@@ -173,6 +195,7 @@ class CharacterBookNativeTest {
assertTrue(PartyBookNativeTest.text(b).contains("STR: 1"));
assertEquals(saved.stats(), characters(f).find(saved.id()).get(5, TimeUnit.SECONDS).orElseThrow().stats());
}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}var statEvents=events.stream().filter(event->event.getAsJsonObject("data").has("action_kind")&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("character_stat_increase")).toList();assertEquals(1,statEvents.stream().filter(event->event.get("type").getAsString().endsWith("character.stat_increased")&&event.getAsJsonObject("data").get("stat_id").getAsString().equals("STR")).count());assertTrue(statEvents.stream().filter(event->event.get("type").getAsString().endsWith("action.resolved")).map(event->event.getAsJsonObject("data")).anyMatch(value->value.get("outcome").getAsString().equals("failed")&&value.get("reason").getAsString().equals("save_failed")));assertTrue(statEvents.stream().filter(event->event.get("type").getAsString().endsWith("action.resolved")).map(event->event.getAsJsonObject("data")).anyMatch(value->value.get("outcome").getAsString().equals("refused")&&value.get("reason").getAsString().equals("stale_forged_or_repeated_action")));
}
@Test void nativePagesKeepLargeNumbersUnicodeNamesAndUnknownAbilitiesReadableAndComplete() throws Exception {
@@ -210,6 +233,7 @@ class CharacterBookNativeTest {
}
@Test void largeRecordsRemainCompleteThroughBoundedSingleUseNavigation() throws Exception {
int expectedNavigations;
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
@@ -247,13 +271,14 @@ class CharacterBookNativeTest {
drainReads(f); runQueued(f);
assertEquals(volumes + 1, a.books.size(), "One native action cannot open two volumes");
}
assertTrue(volumes > 0); assertTrue(all.toString().contains(digits), "Every digit remains reachable in order");
assertTrue(volumes > 0);expectedNavigations=volumes; assertTrue(all.toString().contains(digits), "Every digit remains reachable in order");
var previous = PartyBookNativeTest.button(a, "[Previous pages]");
a.core.inventory.getInventory().setSelectedSlot(1);
PartyBookNativeTest.click(f, a, previous); drainReads(f); runQueued(f);
assertEquals(volumes + 1, a.books.size(), "Stale held-slot navigation has no authority");
assertArrayEquals(bytes, Files.readAllBytes(data.resolve("characters").resolve(saved.id() + ".yml")));
}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}assertEquals(expectedNavigations,events.stream().filter(event->event.get("type").getAsString().endsWith("character_book.navigated")).count());assertEquals(expectedNavigations,events.stream().filter(event->event.get("type").getAsString().endsWith("character_book.navigated")&&event.getAsJsonObject("data").get("native_open").getAsString().equals("acknowledged")&&event.getAsJsonObject("data").get("offset").getAsInt()>0).count());
}
@Test void acknowledgedRenameTransferAndReloadInspectTheCurrentRecordWithoutCarryingTheFormerHoldersView() throws Exception {
@@ -42,11 +42,12 @@ class CombatExperienceNativeTest {
@Test void tooCloseHostileDeathDiscardsItsAllocatedCoinsAndPublishesNoCoinFeedback() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"hostile-coin-drop-min: 10\nhostile-coin-drop-max: 10\n");
UUID recipient,worldId;
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(actor.core.id, actor.player); f.activate(actor);
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
f.players.put(actor.core.id, actor.player); f.activate(actor);recipient=actor.core.record.id();
var world = mock(org.bukkit.World.class); worldId=UUID.randomUUID();when(world.getUID()).thenReturn(worldId);
var first = mob(EntityType.ZOMBIE, new Location(world, 0, 64, 0));
hit(f, actor.player, first, false); die(f, first); settle(f);
var eligible = record(f, actor);
@@ -64,6 +65,7 @@ class CombatExperienceNativeTest {
assertEquals(new java.math.BigDecimal("1.0"), blocked.restrictedKills().values().iterator().next().x(),
"Blocked attempts still advance the spacing reference");
}
var records=events(data).stream().filter(event->event.get("type").getAsString().endsWith("combat.reward_resolved")).toList();assertEquals(2,records.size());var allowed=records.get(0).getAsJsonObject("data");var blocked=records.get(1).getAsJsonObject("data");assertEquals(recipient.toString(),allowed.get("character_id").getAsString());assertEquals("ordinary_spacing_eligible",allowed.get("resolution").getAsString());assertEquals("10",allowed.get("allocated_coins").getAsString());assertEquals("10",allowed.get("applied_coins").getAsString());assertEquals("10",allowed.get("experience_pool").getAsString());assertEquals("1",allowed.get("reward_unit_count").getAsString());assertEquals(java.util.List.of(recipient.toString()),allowed.getAsJsonArray("direct_contributor_character_ids").asList().stream().map(com.google.gson.JsonElement::getAsString).toList());assertTrue(allowed.has("before_points"));assertTrue(allowed.has("after_points"));assertEquals("ordinary_spacing_blocked",blocked.get("resolution").getAsString());assertEquals("10",blocked.get("allocated_coins").getAsString());assertEquals("0",blocked.get("applied_coins").getAsString());assertEquals("spacing_refusal",blocked.get("feedback").getAsString());assertEquals(worldId.toString(),blocked.getAsJsonObject("ordinary_history_after").get("world_id").getAsString());assertEquals("1.0",blocked.getAsJsonObject("ordinary_history_after").get("x").getAsString());
}
@Test void everyManagedEncounterOriginWaivesDistanceWithoutChangingRestrictedHistory() throws Exception {
@@ -74,13 +76,16 @@ class CombatExperienceNativeTest {
var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);f.activate(actor);combat.attachManagedOrigins(entity->java.util.Optional.of(new games.dmg.fantasystick.progression.ManagedEncounterOrigin(kind,UUID.nameUUIDFromBytes(entity.getUniqueId().toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)))));var world=mock(org.bukkit.World.class);when(world.getUID()).thenReturn(UUID.randomUUID());var before=record(f,actor);var first=mob(EntityType.ZOMBIE,new Location(world,0,64,0));hit(f,actor.player,first,false);die(f,first);settle(f);var second=mob(EntityType.ZOMBIE,new Location(world,1,64,0));hit(f,actor.player,second,false);die(f,second);settle(f);var saved=record(f,actor);assertTrue(saved.experience().compareTo(before.experience())>0,kind.toString());assertEquals(before.coins().add(java.math.BigInteger.valueOf(20)),saved.coins(),kind.toString());assertEquals(before.restrictedKills(),saved.restrictedKills(),kind+" never consults or advances ordinary spacing history");
}
}
var records=events(data).stream().filter(event->event.get("type").getAsString().endsWith("combat.reward_resolved")).map(event->event.getAsJsonObject("data")).toList();assertEquals(6,records.size());for(var kind:games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.values()){var matching=records.stream().filter(event->event.get("managed_origin_kind").getAsString().equals(kind.name().toLowerCase(java.util.Locale.ROOT))).toList();assertEquals(2,matching.size(),kind.toString());for(var event:matching){assertEquals("managed_origin_exempt",event.get("resolution").getAsString());assertEquals("10",event.get("allocated_coins").getAsString());assertEquals("10",event.get("applied_coins").getAsString());assertFalse(event.has("ordinary_history_before"));assertFalse(event.has("ordinary_history_after"));assertTrue(event.has("managed_origin_source"));}}
}
@Test void repeatedManagedKillAuthorityCannotPayTwice() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),"hostile-coin-drop-min: 10\nhostile-coin-drop-max: 10\n");
UUID authority=UUID.randomUUID();
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var combat=(games.dmg.fantasystick.party.CombatExperience)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"combatExperience");var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);f.activate(actor);var authority=UUID.randomUUID();combat.attachManagedOrigins(entity->java.util.Optional.of(new games.dmg.fantasystick.progression.ManagedEncounterOrigin(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,authority)));var world=mock(org.bukkit.World.class);when(world.getUID()).thenReturn(UUID.randomUUID());var before=record(f,actor);for(int attempt=0;attempt<2;attempt++){var target=mob(EntityType.ZOMBIE,new Location(world,0,64,0));hit(f,actor.player,target,false);die(f,target);settle(f);}var saved=record(f,actor);assertEquals(before.experience().add(java.math.BigInteger.TEN),saved.experience());assertEquals(before.coins().add(java.math.BigInteger.TEN),saved.coins());assertEquals(before.restrictedKills(),saved.restrictedKills());
f.plugin.setEnabled(true);var combat=(games.dmg.fantasystick.party.CombatExperience)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"combatExperience");var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);f.activate(actor);combat.attachManagedOrigins(entity->java.util.Optional.of(new games.dmg.fantasystick.progression.ManagedEncounterOrigin(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,authority)));var world=mock(org.bukkit.World.class);when(world.getUID()).thenReturn(UUID.randomUUID());var before=record(f,actor);for(int attempt=0;attempt<2;attempt++){var target=mob(EntityType.ZOMBIE,new Location(world,0,64,0));hit(f,actor.player,target,false);die(f,target);settle(f);}var saved=record(f,actor);assertEquals(before.experience().add(java.math.BigInteger.TEN),saved.experience());assertEquals(before.coins().add(java.math.BigInteger.TEN),saved.coins());assertEquals(before.restrictedKills(),saved.restrictedKills());
}
var records=events(data).stream().filter(event->event.get("type").getAsString().endsWith("combat.reward_resolved")&&event.getAsJsonObject("data").get("managed_origin_source").getAsString().equals(authority.toString())).toList();assertEquals(2,records.size());assertEquals(1,records.stream().map(event->event.get("id").getAsString()).distinct().count());assertEquals(java.util.Set.of("applied","duplicate"),records.stream().map(event->event.getAsJsonObject("data").get("storage_status").getAsString()).collect(java.util.stream.Collectors.toSet()));
}
@Test void everyRestrictedMobCoinClassUsesTheExistingExactAndCrossWorldSpacingGeometry() throws Exception {
@@ -132,11 +137,12 @@ class CombatExperienceNativeTest {
@Test void partyRecipientsApplyIndependentSpacingWithoutRedistributingBlockedShares() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"hostile-coin-drop-min: 20\nhostile-coin-drop-max: 20\n");
UUID characterA,characterB,sharedTarget;
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.activate(a); f.activate(b);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.activate(a); f.activate(b);characterA=a.core.record.id();characterB=b.core.record.id();
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
var history = mob(EntityType.ZOMBIE, new Location(world, 0, 64, 0));
hit(f, a.player, history, false); die(f, history); settle(f);
@@ -144,7 +150,7 @@ class CombatExperienceNativeTest {
var parties = PartyLifecycleNativeTest.parties(f); var party = parties.create(a.core.id).orElseThrow();
assertTrue(parties.accept(b.core.id, parties.invite(a.core.id, party.id(), b.core.id).orElseThrow().id()));
var shared = mob(EntityType.ZOMBIE, new Location(world, 1, 64, 0));
var shared = mob(EntityType.ZOMBIE, new Location(world, 1, 64, 0));sharedTarget=shared.getUniqueId();
hit(f, a.player, shared, false); die(f, shared); settle(f); settle(f);
var afterA = record(f, a); var afterB = record(f, b);
assertEquals(beforeA.experience(), afterA.experience());
@@ -156,6 +162,7 @@ class CombatExperienceNativeTest {
assertTrue(a.messages.stream().anyMatch(message -> message.contains("too close")));
assertTrue(b.messages.stream().anyMatch(message -> message.contains("10 character coins")));
}
var records=events(data).stream().filter(event->event.get("type").getAsString().endsWith("combat.reward_resolved")&&event.getAsJsonObject("data").get("target_id").getAsString().equals(sharedTarget.toString())).toList();assertEquals(2,records.size());var byCharacter=records.stream().collect(java.util.stream.Collectors.toMap(event->event.getAsJsonObject("data").get("recipient_character_id").getAsString(),event->event.getAsJsonObject("data")));assertEquals("ordinary_spacing_blocked",byCharacter.get(characterA.toString()).get("resolution").getAsString());assertEquals("0",byCharacter.get(characterA.toString()).get("applied_coins").getAsString());assertEquals("ordinary_spacing_eligible",byCharacter.get(characterB.toString()).get("resolution").getAsString());assertEquals("10",byCharacter.get(characterB.toString()).get("applied_coins").getAsString());assertEquals(2,records.stream().map(event->event.get("id").getAsString()).distinct().count());
}
@Test void packagedWitchPoolFlowsThroughExactSoloAndPartyAllocationWithOrdinaryCoins() throws Exception {
@@ -365,28 +372,28 @@ class CombatExperienceNativeTest {
var writes = new java.util.concurrent.atomic.AtomicInteger();
when(storage.awardCombatReward(eq(original.id()), any(), any(), eq(java.math.BigInteger.valueOf(20)), any(),
same(progression), any(), any())).thenAnswer(ignored -> writes.getAndIncrement() == 0 ? failed : success);
var failures = new ArrayList<games.dmg.fantasystick.party.CombatExperience.Failure>();
var failures = new ArrayList<games.dmg.fantasystick.party.CombatExperience.Failure>();var emitted=new ArrayList<games.dmg.fantasystick.events.EventInput>();var clock=java.time.Clock.fixed(java.time.Instant.parse("2026-09-19T20:00:00Z"), java.time.ZoneOffset.UTC);
var service = new games.dmg.fantasystick.party.CombatExperience(storage, progression,
games.dmg.fantasystick.progression.CombatRewardRules.defaults(), sessions,
java.time.Clock.fixed(java.time.Instant.parse("2026-09-19T20:00:00Z"), java.time.ZoneOffset.UTC), Runnable::run,
clock, Runnable::run,
ignored -> attacker, failures::add);
var authority=new games.dmg.fantasystick.progression.ManagedEncounterOrigin(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,UUID.randomUUID());var settled=new ArrayList<games.dmg.fantasystick.progression.ManagedEncounterOrigin>();service.attachManagedOrigins(ignored->java.util.Optional.of(authority));service.attachManagedSettlement(settled::add);
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
var authority=new games.dmg.fantasystick.progression.ManagedEncounterOrigin(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,UUID.randomUUID());var settled=new ArrayList<games.dmg.fantasystick.progression.ManagedEncounterOrigin>();service.attachManagedOrigins(ignored->java.util.Optional.of(authority));service.attachManagedSettlement(settled::add);service.attachEvents(new games.dmg.fantasystick.events.CombatRewardEventBridge(emitted::add,clock));
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());when(world.getName()).thenReturn("world");when(attacker.getName()).thenReturn("Attacker");when(attacker.getLocation()).thenReturn(new Location(world,0,64,0));
var target = mob(EntityType.WITCH, new Location(world, 0, 64, 0));
var damageSource = mock(org.bukkit.damage.DamageSource.class);
when(damageSource.getDirectEntity()).thenReturn(attacker); when(damageSource.getCausingEntity()).thenReturn(attacker);
service.damage(new EntityDamageByEntityEvent(attacker, target, DamageCause.ENTITY_ATTACK, damageSource, 1));
service.mobDeath(new org.bukkit.event.entity.EntityDeathEvent(target, damageSource, new ArrayList<>(), 0));
verify(storage, times(1)).awardCombatReward(eq(original.id()), any(), any(), any(), any(), any(), any(), any());
assertEquals(1, failures.size());assertTrue(settled.isEmpty(),"slot authority remains unsettled while reward persistence is uncertain");verify(attacker, never()).sendMessage(anyString());
assertEquals(1, failures.size());assertTrue(settled.isEmpty(),"slot authority remains unsettled while reward persistence is uncertain");assertTrue(emitted.isEmpty(),"Failed persistence cannot claim a reward resolution");verify(attacker, never()).sendMessage(anyString());
service.configure(service.rules().withHardPool(java.math.BigInteger.valueOf(25)));
service.retryPending();
verify(storage, times(2)).awardCombatReward(eq(original.id()), any(), any(), any(), any(), any(), any(), any());
verify(attacker, times(1)).sendMessage(contains("character coins"));assertEquals(java.util.List.of(authority),settled,"slot defeat may publish only after the captured payout is durable");
verify(attacker, times(1)).sendMessage(contains("character coins"));assertEquals(java.util.List.of(authority),settled,"slot defeat may publish only after the captured payout is durable");assertEquals(1,emitted.size());var evidence=emitted.getFirst();assertEquals("20",evidence.data().get("experience_pool"));assertEquals("managed_origin_exempt",evidence.data().get("resolution"));assertEquals(authority.source().toString(),evidence.data().get("managed_origin_source"));assertEquals(java.util.List.of(original.id().toString()),evidence.data().get("direct_contributor_character_ids"));
service.mobDeath(new org.bukkit.event.entity.EntityDeathEvent(target, damageSource, new ArrayList<>(), 0));
service.retryPending();
verify(storage, times(2)).awardCombatReward(eq(original.id()), any(), any(), any(), any(), any(), any(), any());
verify(attacker, times(1)).sendMessage(contains("character coins"));
verify(attacker, times(1)).sendMessage(contains("character coins"));assertEquals(1,emitted.size(),"Duplicate target callbacks cannot mint another reward record");
service.close();
}
@@ -423,6 +430,7 @@ class CombatExperienceNativeTest {
assertTrue(a.messages.stream().anyMatch(message -> message.contains("Level " + expected)));
}
}
var coinConfig=events(data).stream().filter(event->event.get("type").getAsString().endsWith("config.read")||event.get("type").getAsString().endsWith("config.changed")).filter(event->event.getAsJsonObject("data").get("key").getAsString().equals("starting-coins-min")).toList();assertEquals(2,coinConfig.size());assertTrue(coinConfig.stream().anyMatch(event->event.get("type").getAsString().endsWith("config.read")&&event.getAsJsonObject("data").get("after").getAsString().equals("1")));assertTrue(coinConfig.stream().anyMatch(event->event.get("type").getAsString().endsWith("config.changed")&&event.getAsJsonObject("data").get("before").getAsString().equals("1")&&event.getAsJsonObject("data").get("after").getAsString().equals("0")));
}
@Test void hardMobXpCommandPersistsBeforePublishingAndAffectsOnlyFutureWitchDefeats() throws Exception {
@@ -475,20 +483,20 @@ class CombatExperienceNativeTest {
assertEquals(java.math.BigInteger.ZERO, record(f, a).experience(), "Cancelled damage never contributes");
}
var second = data.resolve("accepted"); java.nio.file.Files.createDirectories(second);
var second = data.resolve("accepted"); java.nio.file.Files.createDirectories(second);UUID finalAttacker,finalVictimPlayer,deathEpisode;java.math.BigInteger finalVictimCoins,finalAttackerCoins;
try (var f = new FantasyStickLifecycleNativeTest.Fixture(second)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(second);
var b = new FantasyStickLifecycleNativeTest.Actor(second);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
f.activate(a); f.activate(b);
var attackerCoins = record(f, a).coins();
var victimCoins = record(f, b).coins();
f.activate(a); f.activate(b);finalAttacker=a.core.record.id();finalVictimPlayer=b.core.id;
var attackerCoins = record(f, a).coins();finalAttackerCoins=attackerCoins;
var victimCoins = record(f, b).coins();finalVictimCoins=victimCoins;
hit(f, a.player, b.player, false);
var source = mock(org.bukkit.damage.DamageSource.class);
var defeat = new org.bukkit.event.entity.PlayerDeathEvent(b.player, source, new ArrayList<>(), 0,
net.kyori.adventure.text.Component.text("defeat"), true);
f.dispatcher.accept(defeat);settle(f);var downed=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&downed.all().isEmpty();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var captured=downed.all().getFirst().attribution();assertTrue(captured.contributors().contains(a.core.record.id()),"Originating character attribution survives the wait");assertEquals(java.math.BigInteger.ZERO,record(f,a).experience(),"Downing alone grants no defeat XP");assertEquals(victimCoins,record(f,b).coins(),"Downing alone loses no victim coins");DownedSessionNativeTest.acceptDeath(f,b);settle(f);settle(f);settle(f);for(int attempt=0;attempt<120&&record(f,a).experience().signum()==0;attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}
f.dispatcher.accept(defeat);settle(f);var downed=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&downed.all().isEmpty();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var resolvedEpisode=downed.all().getFirst();deathEpisode=resolvedEpisode.id();var captured=resolvedEpisode.attribution();assertTrue(captured.contributors().contains(a.core.record.id()),"Originating character attribution survives the wait");assertEquals(java.math.BigInteger.ZERO,record(f,a).experience(),"Downing alone grants no defeat XP");assertEquals(victimCoins,record(f,b).coins(),"Downing alone loses no victim coins");DownedSessionNativeTest.acceptDeath(f,b);settle(f);settle(f);settle(f);for(int attempt=0;attempt<120&&record(f,a).experience().signum()==0;attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}
assertTrue(defeat.isCancelled());
assertEquals(java.math.BigInteger.TEN,record(f,a).experience(),"Level-one fantasy defeat supplies a ten-XP pool");
assertEquals(attackerCoins.add(victimCoins), record(f, a).coins());
@@ -497,6 +505,7 @@ class CombatExperienceNativeTest {
f.dispatcher.accept(defeat); settle(f);
assertEquals(java.math.BigInteger.TEN, record(f, a).experience(), "Repeated intercepted callback cannot award twice");
}
var rewards=events(second).stream().filter(event->event.get("type").getAsString().endsWith("combat.reward_resolved")).toList();assertEquals(1,rewards.size(),"Initial downing and repeated callbacks emit no extra reward resolution");var reward=rewards.getFirst().getAsJsonObject("data");assertEquals("final_death",reward.get("stage").getAsString());assertEquals(finalVictimPlayer.toString(),reward.get("target_id").getAsString());assertEquals("10",reward.get("experience_pool").getAsString());assertEquals("10",reward.get("allocated_experience").getAsString());assertEquals("10",reward.get("applied_experience").getAsString());assertEquals(finalVictimCoins.toString(),reward.get("coin_pool").getAsString());assertEquals(finalVictimCoins.toString(),reward.get("allocated_coins").getAsString());assertEquals(finalVictimCoins.toString(),reward.get("applied_coins").getAsString());assertEquals(finalAttackerCoins.toString(),reward.get("balance_before").getAsString());assertEquals(finalAttackerCoins.add(finalVictimCoins).toString(),reward.get("balance_after").getAsString());assertEquals("spacing_not_applicable",reward.get("resolution").getAsString());assertEquals(java.util.List.of(finalAttacker.toString()),reward.getAsJsonArray("direct_contributor_character_ids").asList().stream().map(com.google.gson.JsonElement::getAsString).toList());assertTrue(reward.has("reward_unit_id"));var losses=events(second).stream().filter(event->event.get("type").getAsString().endsWith("character.coins_lost")).toList();assertEquals(1,losses.size());var loss=losses.getFirst().getAsJsonObject("data");assertEquals(deathEpisode.toString(),loss.get("death_id").getAsString());assertEquals(finalVictimCoins.toString(),loss.get("balance_before").getAsString());assertEquals("0",loss.get("balance_after").getAsString());assertEquals(finalVictimCoins.toString(),loss.get("death_bound_escrow").getAsString());assertEquals("player_attributed_final_death",loss.get("reason").getAsString());var returns=events(second).stream().filter(event->event.get("type").getAsString().endsWith("action.resolved")&&event.getAsJsonObject("data").has("action_kind")&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("final_death_return")).toList();assertEquals(1,returns.size());assertEquals(deathEpisode.toString(),returns.getFirst().getAsJsonObject("data").get("death_id").getAsString());assertEquals("returned",returns.getFirst().getAsJsonObject("data").get("outcome").getAsString());
}
private static EntityDamageByEntityEvent hit(FantasyStickLifecycleNativeTest.Fixture f, org.bukkit.entity.Entity source,
@@ -524,6 +533,8 @@ class CombatExperienceNativeTest {
return storage.find(actor.core.record.id()).get().orElseThrow();
}
private static java.util.List<com.google.gson.JsonObject> events(Path data)throws Exception{var records=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}return records;}
private static LivingEntity type(EntityType type) {
var entity = mock(LivingEntity.class); when(entity.getType()).thenReturn(type); return entity;
}
@@ -42,10 +42,10 @@ class DownedSessionNativeTest{
var types=java.util.List.of(org.bukkit.damage.DamageType.PLAYER_ATTACK,org.bukkit.damage.DamageType.ARROW,org.bukkit.damage.DamageType.ON_FIRE,org.bukkit.damage.DamageType.FALL);int index=0;for(var type:types){var directory=data.resolve("cause-"+index++);java.nio.file.Files.createDirectories(directory);try(var f=new FantasyStickLifecycleNativeTest.Fixture(directory)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(directory);f.players.put(actor.core.id,actor.player);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);actor.core.health=0;var death=death(actor,type);f.dispatcher.accept(death);var downed=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&(downed.all().isEmpty()||actor.inventories.isEmpty());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(death.isCancelled());assertFalse(actor.inventories.isEmpty());assertEquals(type.getKey().asString(),downed.all().getFirst().attribution().cause());f.plugin.setEnabled(false);var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(directory.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}var recorded=events.stream().filter(event->event.get("type").getAsString().endsWith("character.downed")).map(event->event.getAsJsonObject("data")).findFirst().orElseThrow();assertEquals(type.getKey().asString(),recorded.get("lethal_cause").getAsString());}}
}
@Test void nativeLethalOutcomeEntersDurableDownedPhaseWithoutFinalDeathOrTail()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);actor.core.location.add(7,0,3);var anchor=actor.core.location.clone();actor.core.health=0;var death=death(actor);f.dispatcher.accept(death);actor.player.setHealth(death.getReviveHealth());var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&directory.all().isEmpty();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(death.isCancelled());assertEquals(1d,actor.player.getHealth());var episode=directory.all().getFirst();assertEquals(actor.core.record.id(),episode.character());assertEquals(actor.core.id,episode.player());assertEquals(anchor.getWorld().getUID(),episode.world());assertEquals(anchor.getX(),episode.x());assertEquals(games.dmg.fantasystick.session.DownedEpisode.Phase.PENDING,episode.phase());var characters=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(characters.find(actor.core.record.id()).join().orElseThrow().dead());assertNotEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");var active=sessions.active(actor.core.id).orElseThrow();assertFalse(sessions.current(active));assertTrue(sessions.downed(actor.core.id).isPresent());}
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);actor.core.location.add(7,0,3);var anchor=actor.core.location.clone();actor.core.health=0;var death=death(actor);f.dispatcher.accept(death);actor.player.setHealth(death.getReviveHealth());var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&(directory.all().isEmpty()||actor.inventories.isEmpty());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(death.isCancelled());assertEquals(1d,actor.player.getHealth());var episode=directory.all().getFirst();assertEquals(actor.core.record.id(),episode.character());assertEquals(actor.core.id,episode.player());assertEquals(anchor.getWorld().getUID(),episode.world());assertEquals(anchor.getX(),episode.x());assertEquals(games.dmg.fantasystick.session.DownedEpisode.Phase.PENDING,episode.phase());var characters=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(characters.find(actor.core.record.id()).join().orElseThrow().dead());assertNotEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");var active=sessions.active(actor.core.id).orElseThrow();assertFalse(sessions.current(active));assertTrue(sessions.downed(actor.core.id).isPresent());assertEquals(org.bukkit.Material.GRAY_DYE,actor.inventories.getLast().getItem(13).getType());}
}
@Test void claimedReviveRestoresFullHealthInPlaceAndAddsBoundedDamageProtection()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);actor.core.location.add(4,0,2);var anchor=actor.core.location.clone();actor.core.health=0;f.dispatcher.accept(death(actor));var controller=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downedController");var outcome=new java.util.concurrent.atomic.AtomicReference<games.dmg.fantasystick.session.DownedController.RescueStatus>();for(int attempt=0;attempt<80&&!controller.downed(actor.player);attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}controller.revive(actor.player,java.time.Duration.ofSeconds(5),outcome::set);for(int attempt=0;attempt<80&&outcome.get()==null;attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertEquals(games.dmg.fantasystick.session.DownedController.RescueStatus.APPLIED,outcome.get());assertEquals(actor.maximumHealth.getValue(),actor.player.getHealth());assertEquals(anchor,actor.player.getLocation());var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");assertTrue(sessions.current(sessions.active(actor.core.id).orElseThrow()));assertTrue(sessions.downedProtected(actor.core.id));assertFalse(new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow().dead());var damage=org.mockito.Mockito.mock(org.bukkit.event.entity.EntityDamageEvent.class);org.mockito.Mockito.when(damage.getEntity()).thenReturn(actor.player);controller.damage(damage);org.mockito.Mockito.verify(damage).setCancelled(true);assertNotEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());sessions.exit(actor.player);f.awaitCharacterAndReceipt(actor);sessions.arrive(actor.player);for(int attempt=0;attempt<120&&sessions.active(actor.core.id).isEmpty();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(sessions.downedProtected(actor.core.id),"Reconnect retains the original absolute protection deadline");f.plugin.setEnabled(false);var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}assertTrue(events.stream().filter(event->event.get("type").getAsString().endsWith("combat.prevented")).map(event->event.getAsJsonObject("data")).anyMatch(event->event.get("reason").getAsString().equals("revive_protection")));}
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);var retainedPhoenix=(games.dmg.fantasystick.shop.RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");retainedPhoenix.add(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();actor.core.location.add(4,0,2);var anchor=actor.core.location.clone();actor.core.health=0;f.dispatcher.accept(death(actor));var controller=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downedController");var outcome=new java.util.concurrent.atomic.AtomicReference<games.dmg.fantasystick.session.DownedController.RescueStatus>();for(int attempt=0;attempt<80&&!controller.downed(actor.player);attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}controller.revive(actor.player,java.time.Duration.ofSeconds(5),outcome::set);for(int attempt=0;attempt<80&&outcome.get()==null;attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertEquals(games.dmg.fantasystick.session.DownedController.RescueStatus.APPLIED,outcome.get());assertEquals(actor.maximumHealth.getValue(),actor.player.getHealth());assertEquals(anchor,actor.player.getLocation());var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");assertTrue(sessions.current(sessions.active(actor.core.id).orElseThrow()));assertTrue(sessions.downedProtected(actor.core.id));assertFalse(new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow().dead());var damage=org.mockito.Mockito.mock(org.bukkit.event.entity.EntityDamageEvent.class);org.mockito.Mockito.when(damage.getEntity()).thenReturn(actor.player);controller.damage(damage);org.mockito.Mockito.verify(damage).setCancelled(true);assertNotEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());sessions.exit(actor.player);f.awaitCharacterAndReceipt(actor);sessions.arrive(actor.player);for(int attempt=0;attempt<120&&sessions.active(actor.core.id).isEmpty();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(sessions.downedProtected(actor.core.id),"Reconnect retains the original absolute protection deadline");f.plugin.setEnabled(false);var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}assertTrue(events.stream().filter(event->event.get("type").getAsString().endsWith("combat.prevented")).map(event->event.getAsJsonObject("data")).anyMatch(event->event.get("reason").getAsString().equals("revive_protection")));assertEquals(java.math.BigInteger.ONE,retainedPhoenix.quantity(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN));assertTrue(events.stream().noneMatch(event->event.get("type").getAsString().endsWith("character.died")),"In-place rescue has no final death");}
}
@Test void downedFreezeCancelsMotionCombatHealingHungerItemsAndInventoryMutation()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);actor.core.health=0;f.dispatcher.accept(death(actor));var controller=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downedController");for(int attempt=0;attempt<80&&(!controller.downed(actor.player)||actor.inventories.isEmpty());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}org.mockito.Mockito.verify(actor.player).setGravity(false);var from=actor.core.location.clone();var to=from.clone().add(1,0,0);var move=org.mockito.Mockito.mock(org.bukkit.event.player.PlayerMoveEvent.class);org.mockito.Mockito.when(move.getPlayer()).thenReturn(actor.player);org.mockito.Mockito.when(move.getFrom()).thenReturn(from);org.mockito.Mockito.when(move.getTo()).thenReturn(to);controller.move(move);org.mockito.Mockito.verify(move).setTo(from);var velocity=org.mockito.Mockito.mock(org.bukkit.event.player.PlayerVelocityEvent.class);org.mockito.Mockito.when(velocity.getPlayer()).thenReturn(actor.player);controller.velocity(velocity);org.mockito.Mockito.verify(velocity).setCancelled(true);var attack=org.mockito.Mockito.mock(org.bukkit.event.entity.EntityDamageByEntityEvent.class);org.mockito.Mockito.when(attack.getDamager()).thenReturn(actor.player);controller.attack(attack);org.mockito.Mockito.verify(attack).setCancelled(true);var heal=org.mockito.Mockito.mock(org.bukkit.event.entity.EntityRegainHealthEvent.class);org.mockito.Mockito.when(heal.getEntity()).thenReturn(actor.player);controller.heal(heal);org.mockito.Mockito.verify(heal).setCancelled(true);var hunger=org.mockito.Mockito.mock(org.bukkit.event.entity.FoodLevelChangeEvent.class);org.mockito.Mockito.when(hunger.getEntity()).thenReturn(actor.player);controller.hunger(hunger);org.mockito.Mockito.verify(hunger).setCancelled(true);var consume=org.mockito.Mockito.mock(org.bukkit.event.player.PlayerItemConsumeEvent.class);org.mockito.Mockito.when(consume.getPlayer()).thenReturn(actor.player);controller.consume(consume);org.mockito.Mockito.verify(consume).setCancelled(true);var old=actor.core.inventory.getItem(4).clone();actor.core.inventory.setItem(4,new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIRT));var change=new io.papermc.paper.event.player.PlayerInventorySlotChangeEvent(actor.player,4,old,actor.core.inventory.getItem(4));((games.dmg.fantasystick.session.EquipmentSessionEvents)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessionEvents")).changed(change);assertTrue(((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(actor.core.id).isPresent());controller.slot(change);var task=f.tasks.poll(1,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();assertEquals(old,actor.core.inventory.getItem(4));}
@@ -57,10 +57,15 @@ class DownedSessionNativeTest{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());var characters=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var initial=characters.find(actor.core.record.id()).join().orElseThrow();characters.saveHealth(initial.id(),initial.revision(),java.math.BigDecimal.valueOf(4),initial.maximumHealth()).join();f.activate(actor);actor.core.health=0;f.dispatcher.accept(death(actor));var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&directory.all().isEmpty();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var episode=directory.all().getFirst();var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");sessions.exit(actor.player);f.awaitCharacterAndReceipt(actor);var resolved=directory.claim(episode.id(),games.dmg.fantasystick.session.DownedEpisode.Outcome.REVIVED,UUID.randomUUID(),java.time.Duration.ofSeconds(5)).join().episode().orElseThrow();assertFalse(resolved.published());sessions.arrive(actor.player);for(int attempt=0;attempt<160&&(sessions.active(actor.core.id).isEmpty()||!directory.find(actor.core.record.id()).join().orElseThrow().published());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(sessions.current(sessions.active(actor.core.id).orElseThrow()));assertEquals(actor.maximumHealth.getValue(),actor.player.getHealth());var saved=characters.find(actor.core.record.id()).join().orElseThrow();assertEquals(0,saved.maximumHealth().compareTo(saved.health()));assertTrue(directory.find(actor.core.record.id()).join().orElseThrow().published());assertTrue(sessions.downedProtected(actor.core.id));}
}
@Test void offlineFinalizationRecordsOriginalTimeAndReconcilesTheExactSheet()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);actor.core.health=0;f.dispatcher.accept(death(actor));var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&directory.all().isEmpty();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var episode=directory.all().getFirst();var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");sessions.exit(actor.player);var resolved=directory.claim(episode.id(),games.dmg.fantasystick.session.DownedEpisode.Outcome.FINAL_DEATH,UUID.randomUUID(),java.time.Duration.ZERO).join().episode().orElseThrow();assertTrue(sessions.finalizeDowned(resolved));for(int attempt=0;attempt<120&&!new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow().dead();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var dead=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();assertEquals(resolved.resolvedAt(),dead.diedAt());assertEquals(episode.id(),dead.deathId());assertEquals(org.bukkit.Material.PAPER,actor.core.inventory.getItemInOffHand().getType());sessions.arrive(actor.player);for(int attempt=0;attempt<80&&actor.core.inventory.getItemInOffHand().getType()!=org.bukkit.Material.WRITTEN_BOOK;attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType(),actor.messages.toString());for(int attempt=0;attempt<80&&!directory.find(actor.core.record.id()).join().orElseThrow().published();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(directory.find(actor.core.record.id()).join().orElseThrow().published());}
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);actor.core.health=0;f.dispatcher.accept(death(actor));var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&directory.all().isEmpty();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var episode=directory.all().getFirst();var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");sessions.exit(actor.player);var resolved=directory.claim(episode.id(),games.dmg.fantasystick.session.DownedEpisode.Outcome.FINAL_DEATH,UUID.randomUUID(),java.time.Duration.ZERO).join().episode().orElseThrow();assertTrue(sessions.finalizeDowned(resolved));for(int attempt=0;attempt<120&&!new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow().dead();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var dead=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();assertEquals(resolved.resolvedAt(),dead.diedAt());assertEquals(episode.id(),dead.deathId());assertEquals(org.bukkit.Material.PAPER,actor.core.inventory.getItemInOffHand().getType());sessions.arrive(actor.player);for(int attempt=0;attempt<80&&actor.core.inventory.getItemInOffHand().getType()!=org.bukkit.Material.WRITTEN_BOOK;attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType(),actor.messages.toString());for(int attempt=0;attempt<80&&!directory.find(actor.core.record.id()).join().orElseThrow().published();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(directory.find(actor.core.record.id()).join().orElseThrow().published());f.plugin.setEnabled(false);var died=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank()){var event=com.google.gson.JsonParser.parseString(line).getAsJsonObject();if(event.get("type").getAsString().endsWith("character.died"))died.add(event);}}assertFalse(died.isEmpty());assertEquals(1,died.stream().map(event->event.get("id").getAsString()).distinct().count(),"At-least-once reconciliation retains one logical death ID");assertEquals(episode.id().toString(),died.getFirst().getAsJsonObject("data").get("death_id").getAsString());assertEquals(resolved.resolvedAt().toString(),died.getFirst().getAsJsonObject("data").get("effective_death_time").getAsString());}
}
@Test void deathMenuAcceptanceFinalizesTheSameEpisodeAndDeliversOneTail()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);actor.core.health=0;f.dispatcher.accept(death(actor));var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&(directory.all().isEmpty()||actor.inventories.isEmpty());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var episode=directory.all().getFirst();var menu=actor.inventories.getLast();assertTrue(plain(menu.getItem(11)).contains("Accept Death"));assertTrue(plain(menu.getItem(13)).contains("Phoenix Down"));assertTrue(plain(menu.getItem(15)).contains("Wait for Revive"));var view=org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);org.mockito.Mockito.when(view.getTopInventory()).thenReturn(menu);var click=org.mockito.Mockito.mock(org.bukkit.event.inventory.InventoryClickEvent.class);org.mockito.Mockito.when(click.getView()).thenReturn(view);org.mockito.Mockito.when(click.getWhoClicked()).thenReturn(actor.player);org.mockito.Mockito.when(click.getRawSlot()).thenReturn(11);var controller=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downedController");controller.click(click);controller.click(click);for(int attempt=0;attempt<120&&!new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow().dead();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}PartyLifecycleNativeTest.directory(f).defeat(UUID.randomUUID()).join();f.awaitCharacterAndReceipt(actor);PartyBookNativeTest.drain(f);var dead=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();assertTrue(dead.dead());assertEquals(episode.id(),dead.deathId());assertEquals(games.dmg.fantasystick.session.DownedEpisode.Outcome.FINAL_DEATH,directory.find(dead.id()).join().orElseThrow().outcome());for(int attempt=0;attempt<80&&!directory.find(dead.id()).join().orElseThrow().published();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(directory.find(dead.id()).join().orElseThrow().published());assertEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());}
games.dmg.fantasystick.party.CharacterParty finalParty;
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);var persistentParties=PartyLifecycleNativeTest.directory(f);finalParty=persistentParties.create(actor.core.record.id(),"Finalists").join().party().orElseThrow();var retainedPhoenix=(games.dmg.fantasystick.shop.RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");retainedPhoenix.add(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();actor.core.health=0;f.dispatcher.accept(death(actor));var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&(directory.all().isEmpty()||actor.inventories.isEmpty());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var episode=directory.all().getFirst();assertEquals(finalParty.id(),persistentParties.partyOf(actor.core.record.id()).orElseThrow().id());var menu=actor.inventories.getLast();assertTrue(plain(menu.getItem(11)).contains("Accept Death"));assertTrue(plain(menu.getItem(13)).contains("Phoenix Down"));assertTrue(plain(menu.getItem(15)).contains("Wait for Revive"));var view=org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);org.mockito.Mockito.when(view.getTopInventory()).thenReturn(menu);var click=org.mockito.Mockito.mock(org.bukkit.event.inventory.InventoryClickEvent.class);org.mockito.Mockito.when(click.getView()).thenReturn(view);org.mockito.Mockito.when(click.getWhoClicked()).thenReturn(actor.player);org.mockito.Mockito.when(click.getRawSlot()).thenReturn(11);var controller=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downedController");controller.click(click);controller.click(click);for(int attempt=0;attempt<120&&!new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow().dead();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}PartyLifecycleNativeTest.directory(f).defeat(UUID.randomUUID()).join();f.awaitCharacterAndReceipt(actor);PartyBookNativeTest.drain(f);var dead=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();assertTrue(dead.dead());assertEquals(episode.id(),dead.deathId());assertEquals(games.dmg.fantasystick.session.DownedEpisode.Outcome.FINAL_DEATH,directory.find(dead.id()).join().orElseThrow().outcome());for(int attempt=0;attempt<80&&!directory.find(dead.id()).join().orElseThrow().published();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(directory.find(dead.id()).join().orElseThrow().published());assertEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());assertEquals(java.math.BigInteger.ONE,retainedPhoenix.quantity(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN));assertTrue(persistentParties.partyOf(actor.core.record.id()).isEmpty());}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}assertEquals(1,events.stream().filter(event->event.get("type").getAsString().contains(".party.")).count());var ended=events.stream().filter(event->event.get("type").getAsString().endsWith("party.disbanded")&&event.getAsJsonObject("data").has("reason")&&event.getAsJsonObject("data").get("reason").getAsString().equals("final_death")).toList();assertFalse(ended.isEmpty());assertEquals(1,ended.stream().map(event->event.get("id").getAsString()).distinct().count());assertEquals(finalParty.id().toString(),ended.getFirst().getAsJsonObject("data").get("party_id").getAsString());
}
@Test void explicitPhoenixDownChoiceConsumesOneAndRescuesTheExactDownedCharacter()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);var inventory=(games.dmg.fantasystick.shop.RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");inventory.add(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();inventory.add(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();PartyBookNativeTest.open(f,actor);String inventoryText=PartyBookNativeTest.text(actor);assertTrue(inventoryText.contains("Phoenix Down: 2"),inventoryText);assertTrue(inventoryText.contains("Use Phoenix Down"),inventoryText);assertFalse(inventoryText.contains("[Use]"),inventoryText);actor.core.health=0;var death=death(actor);f.dispatcher.accept(death);var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&(directory.all().isEmpty()||actor.inventories.isEmpty());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var episode=directory.all().getFirst();var menu=actor.inventories.getLast();assertEquals(org.bukkit.Material.FEATHER,menu.getItem(13).getType());assertTrue(plain(menu.getItem(13)).contains("Use Phoenix Down"));assertTrue(plain(menu.getItem(13)).contains("2"));var view=org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);org.mockito.Mockito.when(view.getTopInventory()).thenReturn(menu);var click=org.mockito.Mockito.mock(org.bukkit.event.inventory.InventoryClickEvent.class);org.mockito.Mockito.when(click.getView()).thenReturn(view);org.mockito.Mockito.when(click.getWhoClicked()).thenReturn(actor.player);org.mockito.Mockito.when(click.getRawSlot()).thenReturn(13);var controller=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downedController");controller.click(click);controller.click(click);for(int attempt=0;attempt<160&&(!directory.find(actor.core.record.id()).join().orElseThrow().published()||inventory.quantity(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN).compareTo(java.math.BigInteger.ONE)!=0||actor.player.getHealth()!=actor.maximumHealth.getValue());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var resolved=directory.find(actor.core.record.id()).join().orElseThrow();assertEquals(games.dmg.fantasystick.session.DownedEpisode.Outcome.REVIVED,resolved.outcome());assertEquals("phoenix_down",resolved.decision().source());assertEquals(episode.id(),resolved.id());assertEquals(java.math.BigInteger.ONE,inventory.quantity(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN));assertEquals(actor.maximumHealth.getValue(),actor.player.getHealth());assertTrue(((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).downedProtected(actor.core.id));assertFalse(new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow().dead());assertNotEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());var protectedDamage=org.mockito.Mockito.mock(org.bukkit.event.entity.EntityDamageEvent.class);org.mockito.Mockito.when(protectedDamage.getEntity()).thenReturn(actor.player);org.mockito.Mockito.when(protectedDamage.getFinalDamage()).thenReturn(3d);controller.damage(protectedDamage);org.mockito.Mockito.verify(protectedDamage).setCancelled(true);f.plugin.setEnabled(false);var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}var phoenixEvents=events.stream().filter(event->event.get("type").getAsString().endsWith("character.downed_resolved")&&event.getAsJsonObject("data").get("outcome").getAsString().equals("phoenix_down")).toList();assertFalse(phoenixEvents.isEmpty());assertEquals(1,phoenixEvents.stream().map(event->event.get("id").getAsString()).distinct().count());var phoenixData=phoenixEvents.getFirst().getAsJsonObject("data");assertEquals("downed_menu",phoenixData.get("selection_entry_point").getAsString());assertEquals("2",phoenixData.get("quantity_before").getAsString());assertEquals("1",phoenixData.get("quantity_after").getAsString());assertEquals("10.0",phoenixData.get("maximum_health").getAsString());assertTrue(events.stream().filter(event->event.get("type").getLine truncated
}
static void awaitTail(FantasyStickLifecycleNativeTest.Fixture fixture,FantasyStickLifecycleNativeTest.Actor actor)throws Exception{for(int attempt=0;attempt<160&&actor.core.inventory.getItemInOffHand().getType()!=org.bukkit.Material.WRITTEN_BOOK;attempt++){var task=fixture.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());}
static void acceptDeath(FantasyStickLifecycleNativeTest.Fixture fixture,FantasyStickLifecycleNativeTest.Actor actor)throws Exception{var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin,"downed");for(int attempt=0;attempt<80&&(directory.all().stream().noneMatch(value->value.player().equals(actor.core.id)&&value.phase()==games.dmg.fantasystick.session.DownedEpisode.Phase.PENDING)||actor.inventories.isEmpty());attempt++){var task=fixture.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var menu=actor.inventories.getLast();var view=org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);org.mockito.Mockito.when(view.getTopInventory()).thenReturn(menu);var click=org.mockito.Mockito.mock(org.bukkit.event.inventory.InventoryClickEvent.class);org.mockito.Mockito.when(click.getView()).thenReturn(view);org.mockito.Mockito.when(click.getWhoClicked()).thenReturn(actor.player);org.mockito.Mockito.when(click.getRawSlot()).thenReturn(11);((games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin,"downedController")).click(click);for(int attempt=0;attempt<120&&directory.all().stream().anyMatch(value->value.player().equals(actor.core.id)&&value.phase()==games.dmg.fantasystick.session.DownedEpisode.Phase.PENDING);attempt++){var task=fixture.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin,"sessions");for(int attempt=0;attempt<80&&sessions.active(actor.core.id).isPresent();attempt++){var task=fixture.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}}
@@ -46,6 +46,77 @@ final class EventCaptureNativeTest {
assertTrue(joined.contains("\"from_location\""));assertTrue(joined.contains("\"to_location\""));assertTrue(java.util.Arrays.stream(EventCaptureListener.class.getDeclaredMethods()).noneMatch(method->java.util.Arrays.asList(method.getParameterTypes()).contains(org.bukkit.event.player.PlayerMoveEvent.class)));
}
@Test void protectedDragAndConsumeCaptureExactItemDowningAndFinalCancellationContext() throws Exception {
var clock=Clock.fixed(Instant.parse("2026-09-25T01:20:00Z"),ZoneOffset.UTC);var sessions=mock(EquipmentSessions.class);var player=mock(Player.class);var world=mock(World.class);var playerId=UUID.randomUUID();var active=new EquipmentSessions.Active(playerId,UUID.randomUUID(),UUID.randomUUID());var character=mock(CharacterRecord.class);var episode=mock(games.dmg.fantasystick.session.DownedEpisode.class);var episodeId=UUID.randomUUID();when(player.getUniqueId()).thenReturn(playerId);when(player.getName()).thenReturn("Guarded");when(world.getUID()).thenReturn(UUID.randomUUID());when(world.getName()).thenReturn("world");when(player.getLocation()).thenReturn(new Location(world,2,70,3));when(sessions.active(playerId)).thenReturn(Optional.of(active));when(sessions.currentRecord(active)).thenReturn(Optional.of(character));when(character.name()).thenReturn("Frozen Hero");when(sessions.downed(playerId)).thenReturn(Optional.of(episode));when(episode.id()).thenReturn(episodeId);
var control=new org.bukkit.inventory.ItemStack(org.bukkit.Material.STICK);var meta=control.getItemMeta();meta.getPersistentDataContainer().set(new org.bukkit.NamespacedKey("fantasystick","control_version"),org.bukkit.persistence.PersistentDataType.INTEGER,1);meta.getPersistentDataContainer().set(new org.bukkit.NamespacedKey("fantasystick","control_kind"),org.bukkit.persistence.PersistentDataType.STRING,"basic_attack");meta.getPersistentDataContainer().set(new org.bukkit.NamespacedKey("fantasystick","control_owner_id"),org.bukkit.persistence.PersistentDataType.STRING,playerId.toString());meta.getPersistentDataContainer().set(new org.bukkit.NamespacedKey("fantasystick","control_session_id"),org.bukkit.persistence.PersistentDataType.STRING,active.session().toString());control.setItemMeta(meta);
try(var log=LocalEventLog.open(data,clock,new LocalEventLog.RuntimeInfo("Season 4","26.2","test"),16,134_217_728L,message->fail(message))){log.ready().toCompletableFuture().get();var listener=new EventCaptureListener(log,sessions,clock);
var drag=mock(org.bukkit.event.inventory.InventoryDragEvent.class);when(drag.getWhoClicked()).thenReturn(player);when(drag.getOldCursor()).thenReturn(control);when(drag.getNewItems()).thenReturn(java.util.Map.of(4,control));listener.observe(drag);when(drag.isCancelled()).thenReturn(true);listener.resolve(drag);
var consume=mock(org.bukkit.event.player.PlayerItemConsumeEvent.class);when(consume.getPlayer()).thenReturn(player);when(consume.getItem()).thenReturn(control);listener.observe(consume);when(consume.isCancelled()).thenReturn(true);listener.resolve(consume);
var craft=mock(org.bukkit.event.inventory.CraftItemEvent.class);when(craft.getWhoClicked()).thenReturn(player);when(craft.getRawSlot()).thenReturn(0);when(craft.getClick()).thenReturn(org.bukkit.event.inventory.ClickType.LEFT);when(craft.getAction()).thenReturn(org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);when(craft.getCurrentItem()).thenReturn(control);listener.observe(craft);when(craft.isCancelled()).thenReturn(true);listener.resolve(craft);
var armor=mock(org.bukkit.event.block.BlockDispenseArmorEvent.class);when(armor.getTargetEntity()).thenReturn(player);when(armor.getItem()).thenReturn(control);listener.observe(armor);when(armor.isCancelled()).thenReturn(true);listener.resolve(armor);
var rod=control.clone();rod.setType(org.bukkit.Material.FISHING_ROD);var rodMeta=rod.getItemMeta();rodMeta.getPersistentDataContainer().set(games.dmg.fantasystick.adventure.AdventureQuestNavigation.FISHING_QUEST,org.bukkit.persistence.PersistentDataType.STRING,UUID.randomUUID().toString());rod.setItemMeta(rodMeta);var launch=mock(com.destroystokyo.paper.event.player.PlayerLaunchProjectileEvent.class);when(launch.getPlayer()).thenReturn(player);when(launch.getItemStack()).thenReturn(rod);listener.observe(launch);when(launch.isCancelled()).thenReturn(true);listener.resolve(launch);
var duplicate=mock(PlayerInteractEvent.class);when(duplicate.getPlayer()).thenReturn(player);when(duplicate.getHand()).thenReturn(EquipmentSlot.OFF_HAND);listener.observe(duplicate);listener.resolve(duplicate);
}
var records=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}
var guarded=records.stream().filter(event->event.get("type").getAsString().contains("action.")).toList();assertEquals(10,guarded.size());for(var event:guarded){var value=event.getAsJsonObject("data");assertEquals(episodeId.toString(),value.get("downing_id").getAsString());assertEquals("downed",value.get("guard_reason").getAsString());}assertEquals(java.util.Set.of("inventory_drag","consume_item","inventory_click","dispense_armor","launch_projectile"),guarded.stream().map(event->event.getAsJsonObject("data").get("action_kind").getAsString()).collect(java.util.stream.Collectors.toSet()));assertTrue(guarded.stream().map(event->event.getAsJsonObject("data")).anyMatch(value->value.get("item_kind").getAsString().equals("quest_fishing_rod")));assertTrue(guarded.stream().map(event->event.getAsJsonObject("data")).filter(value->value.get("item_kind").getAsString().equals("control")).allMatch(value->value.get("control_kind").getAsString().equals("basic_attack")));
}
@Test void asynchronousChatUsesCapturedSessionWithoutNativeLocationLookupOrReplacementCharacter() throws Exception {
var clock=Clock.fixed(Instant.parse("2026-09-25T04:05:00Z"),ZoneOffset.UTC);
var account=UUID.randomUUID();var first=UUID.randomUUID();var session=UUID.randomUUID();var world=mock(World.class);
when(world.getUID()).thenReturn(UUID.randomUUID());when(world.getName()).thenReturn("world");
var player=mock(Player.class);when(player.getUniqueId()).thenReturn(account);when(player.getName()).thenReturn("Account");
when(player.getLocation()).thenReturn(new Location(world,1,64,2));
var observed=new java.util.concurrent.CopyOnWriteArrayList<games.dmg.fantasystick.events.EventInput>();
var bridge=new games.dmg.fantasystick.events.SessionEventBridge(observed::add,clock);
var record=mock(CharacterRecord.class);when(record.id()).thenReturn(first);when(record.name()).thenReturn("Original");when(record.revision()).thenReturn(java.math.BigInteger.ONE);
bridge.entered(player,session,record,new Location(world,1,64,2),false,true);
var view=new java.util.concurrent.atomic.AtomicReference<>(new games.dmg.fantasystick.presentation.CharacterPresentation.View(account,session,first,java.math.BigInteger.ONE,"Original"));
var listener=new games.dmg.fantasystick.events.MessageEventBridge(ignored->view.get(),bridge,observed::add,clock);
var body=net.kyori.adventure.text.Component.text("signed 你好 🌿");
var signed=net.kyori.adventure.chat.SignedMessage.system("signed 你好 🌿",body);
var chat=new io.papermc.paper.event.player.AsyncChatEvent(true,player,new java.util.HashSet<>(),io.papermc.paper.chat.ChatRenderer.defaultRenderer(),body,body,signed);
var failure=new java.util.concurrent.atomic.AtomicReference<Throwable>();
var worker=new Thread(()->{try{listener.chat(chat);}catch(Throwable error){failure.set(error);}},"character-message-test");worker.start();worker.join();assertNull(failure.get());
var event=observed.stream().filter(value->value.type().equals("character.chat")).findFirst().orElseThrow();
assertEquals(first,event.context().characterId());assertEquals(session,event.context().sessionId());
assertEquals("signed 你好 🌿",event.data().get("message"));assertEquals("last_native_session_capture",event.data().get("location_quality"));
view.set(new games.dmg.fantasystick.presentation.CharacterPresentation.View(account,UUID.randomUUID(),UUID.randomUUID(),java.math.BigInteger.ZERO,"Replacement"));
listener.chat(chat);assertEquals(1,observed.stream().filter(value->value.type().equals("character.chat")).count());
verify(player,times(1)).getLocation();assertSame(signed,chat.signedMessage());
}
@Test void privateAliasObservationsKeepFullBodiesWithoutClaimingRecipientDelivery() {
var clock=Clock.fixed(Instant.parse("2026-09-25T04:12:00Z"),ZoneOffset.UTC);var account=UUID.randomUUID();var character=UUID.randomUUID();var session=UUID.randomUUID();var world=mock(World.class);when(world.getUID()).thenReturn(UUID.randomUUID());when(world.getName()).thenReturn("world");
var player=mock(Player.class);when(player.getUniqueId()).thenReturn(account);when(player.getName()).thenReturn("Account");when(player.getWorld()).thenReturn(world);when(player.getLocation()).thenReturn(new Location(world,1,64,2));
var record=mock(CharacterRecord.class);when(record.id()).thenReturn(character);when(record.name()).thenReturn("Hero");when(record.revision()).thenReturn(java.math.BigInteger.ONE);
var observed=new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();var sessions=new games.dmg.fantasystick.events.SessionEventBridge(observed::add,clock);sessions.entered(player,session,record,new Location(world,1,64,2),false,false);
var listener=new games.dmg.fantasystick.events.MessageEventBridge(id->new games.dmg.fantasystick.presentation.CharacterPresentation.View(account,session,character,java.math.BigInteger.ONE,"Hero"),sessions,observed::add,clock);
var aliases=java.util.List.of("msg","message","tell","w","whisper","pm","reply","r","minecraft:msg");
for(var alias:aliases){var request=mock(org.bukkit.event.player.PlayerCommandPreprocessEvent.class);when(request.getPlayer()).thenReturn(player);when(request.getMessage()).thenReturn("/"+alias+(alias.equals("reply")||alias.equals("r")?" ":" Alex ")+"full Unicode 雪 🐉 text");listener.privateMessage(request);}
var ordinary=mock(org.bukkit.event.player.PlayerCommandPreprocessEvent.class);when(ordinary.getMessage()).thenReturn("/warp nowhere");listener.privateMessage(ordinary);
var messages=observed.stream().filter(value->value.type().equals("character.private_message")).toList();assertEquals(aliases.size(),messages.size());
for(var message:messages){assertEquals("full Unicode 雪 🐉 text",message.data().get("message"));assertEquals("unverified",message.data().get("delivery"));assertFalse(message.data().containsKey("recipient_player_id"));}
assertTrue(messages.stream().filter(value->Boolean.TRUE.equals(value.data().get("reply"))).allMatch(value->!value.data().containsKey("recipient_token")));
}
@Test void journalFailureCannotBlockSessionTransitionOrMessageHandling() {
var clock=Clock.fixed(Instant.parse("2026-09-25T04:10:00Z"),ZoneOffset.UTC);
var account=UUID.randomUUID();var character=UUID.randomUUID();var session=UUID.randomUUID();var world=mock(World.class);
when(world.getUID()).thenReturn(UUID.randomUUID());when(world.getName()).thenReturn("world");
var player=mock(Player.class);when(player.getUniqueId()).thenReturn(account);when(player.getName()).thenReturn("Account");
when(player.getWorld()).thenReturn(world);when(player.getLocation()).thenReturn(new Location(world,1,64,2));
var record=mock(CharacterRecord.class);when(record.id()).thenReturn(character);when(record.name()).thenReturn("Hero");when(record.revision()).thenReturn(java.math.BigInteger.ONE);
var failures=new games.dmg.fantasystick.events.SessionEventBridge(event->{throw new IllegalStateException("file unavailable");},clock);
assertDoesNotThrow(()->failures.entered(player,session,record,new Location(world,1,64,2),false,false));
var capture=failures.captureExit(player,session,record,new Location(world,1,64,2));
assertDoesNotThrow(()->failures.exited(session,capture));
var listener=new games.dmg.fantasystick.events.MessageEventBridge(id->new games.dmg.fantasystick.presentation.CharacterPresentation.View(account,session,character,java.math.BigInteger.ONE,"Hero"),failures,event->{throw new IllegalStateException("file unavailable");},clock);
var command=mock(org.bukkit.event.player.PlayerCommandPreprocessEvent.class);when(command.getPlayer()).thenReturn(player);when(command.getMessage()).thenReturn("/minecraft:msg Alex hello");
assertDoesNotThrow(()->listener.privateMessage(command));
verify(command,never()).setCancelled(anyBoolean());
}
private static int count(String value,String sought){int count=0;for(int at=0;(at=value.indexOf(sought,at))>=0;at+=sought.length())count++;return count;}
@Test void activeCharacterNativeInteractionProducesCorrelatedObservedAndCancelledRecords() throws Exception {
@@ -38,6 +38,11 @@ class FantasyFeedbackCommandNativeTest {
assertEquals("Fantasy feedback is off.", player.messages.getLast());
assertTrue(player.messageComponents.isEmpty(), "preference/status notices keep their existing presentation");
}
var records = feedbackRecords();
assertEquals(2, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback")).count());
assertEquals(1, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback")).count());
}
@Test void consoleMalformedAndExtraArgumentsCannotChangeAnAccountPreference() throws Exception {
@@ -60,6 +65,188 @@ class FantasyFeedbackCommandNativeTest {
}
}
@Test void registeredFeedbackReadAndChangeReachAccountOwnedJsonl() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
var root = fixture.playerCommand.getExecutor();
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick", new String[]{"feedback"}));
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick", new String[]{"feedback", "off"}));
fixture.tick();
assertEquals("Fantasy feedback is now off.", actor.messages.getLast());
fixture.plugin.setEnabled(false);
var records = feedbackRecords();
var read = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
.findFirst().orElseThrow().getAsJsonObject("data");
assertEquals("combat_feedback", read.get("preference_key").getAsString());
assertEquals(actor.core.id.toString(), read.get("player_id").getAsString());
assertEquals("/fstick feedback", read.get("entry_point").getAsString());
assertEquals("true", read.get("default_value").getAsString());
assertEquals("true", read.get("effective_value").getAsString());
var change = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
.findFirst().orElseThrow().getAsJsonObject("data");
assertEquals(actor.core.id.toString(), change.get("player_id").getAsString());
assertEquals("true", change.get("before").getAsString());
assertEquals("false", change.get("after").getAsString());
assertTrue(change.get("preference_sequence").getAsLong() > 0);
assertEquals(actor.world.getUID().toString(), change.getAsJsonObject("location").get("world_id").getAsString());
}
}
@Test void feedbackRefusalsFailedAndSupersededWritesCannotClaimSuccess() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
var root = fixture.playerCommand.getExecutor();
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick", new String[]{"feedback", "off"}));
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick", new String[]{"feedback", "on"}));
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
preferences.set(java.util.UUID.randomUUID(), true).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS);
var callbacks = new java.util.ArrayList<Runnable>();
fixture.tasks.drainTo(callbacks);
assertEquals(2, callbacks.size());
java.util.Collections.reverse(callbacks); callbacks.forEach(Runnable::run);
assertFalse(root.onCommand(actor.player, fixture.playerCommand, "fstick", new String[]{"feedback", "OFF"}));
assertTrue(root.onCommand(org.mockito.Mockito.mock(org.bukkit.command.ConsoleCommandSender.class),
fixture.playerCommand, "fstick", new String[]{"feedback", "off"}));
assertTrue(root.onCommand(org.mockito.Mockito.mock(org.bukkit.command.BlockCommandSender.class),
fixture.playerCommand, "fstick", new String[]{"feedback", "off"}));
fixture.plugin.setEnabled(false);
var records = feedbackRecords().stream().filter(e -> e.getAsJsonObject("data").has("preference_key")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback")).toList();
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
assertEquals(java.util.Set.of("superseded", "unchanged", "invalid_arguments", "player_required"),
records.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved"))
.map(e -> e.getAsJsonObject("data").get("reason").getAsString())
.collect(java.util.stream.Collectors.toSet()));
fixture.plugin.setEnabled(true);
fixture.plugin.setEnabled(false);
assertEquals(0, feedbackRecords().stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
}
}
@Test void failedFeedbackSaveIsReportedWithoutChangingEffectiveAccountState() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
java.nio.file.Files.createDirectory(data.resolve("player-preferences.yml"));
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
"fstick", new String[]{"feedback", "off"}));
fixture.tick();
assertTrue(actor.messages.getLast().contains("could not be saved"));
fixture.plugin.setEnabled(false);
assertEquals(0, feedbackRecords().stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
assertTrue(feedbackRecords().stream().anyMatch(e -> e.get("type").getAsString().endsWith("action.resolved")
&& e.getAsJsonObject("data").has("reason")
&& e.getAsJsonObject("data").get("reason").getAsString().equals("save_failed")));
}
}
@Test void closedFeedbackCommandCannotPublishUnacknowledgedPreferenceSuccess() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
"fstick", new String[]{"feedback", "off"}));
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
preferences.set(java.util.UUID.randomUUID(), true).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS);
((games.dmg.fantasystick.session.FantasyFeedbackCommand)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackCommand")).close();
fixture.tick();
assertTrue(preferences.enabled(actor.core.id), "Closed callback leaves the current effective value unchanged");
fixture.plugin.setEnabled(false);
fixture.plugin.setEnabled(true);
fixture.plugin.setEnabled(false);
var records = feedbackRecords();
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
assertTrue(records.stream().anyMatch(e -> e.get("type").getAsString().endsWith("action.resolved")
&& e.getAsJsonObject("data").has("reason")
&& e.getAsJsonObject("data").get("reason").getAsString().equals("closed")));
}
}
@Test void feedbackOutboxRecoversOriginalAcceptedChangeAfterSinkOutage() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
((games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
"fstick", new String[]{"feedback", "off"}));
fixture.tick();
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
for (int i = 0; i < 80 && preferences.pendingEvents().stream()
.noneMatch(p -> "true".equals(p.captured().get("published"))); i++) Thread.sleep(10);
assertTrue(preferences.pendingEvents().stream()
.anyMatch(p -> "true".equals(p.captured().get("published"))));
fixture.plugin.setEnabled(false);
fixture.plugin.setEnabled(true);
for (int i = 0; i < 80 && feedbackRecords().stream()
.noneMatch(e -> e.get("type").getAsString().endsWith("preference.changed")); i++) Thread.sleep(10);
fixture.plugin.setEnabled(false);
var changes = feedbackRecords().stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback")).toList();
assertEquals(1, changes.stream().map(e -> e.get("id").getAsString()).distinct().count());
assertEquals("false", changes.getFirst().getAsJsonObject("data").get("after").getAsString());
}
}
@Test void feedbackPreferenceIsAccountOwnedAcrossCharacterTransfer() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var owner = new FantasyStickLifecycleNativeTest.Actor(data);
var recipient = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(owner.core.id, owner.player); fixture.players.put(recipient.core.id, recipient.player);
var root = fixture.playerCommand.getExecutor();
assertTrue(root.onCommand(owner.player, fixture.playerCommand, "fstick", new String[]{"feedback", "off"}));
fixture.tick();
fixture.activate(owner);
var sheet = owner.core.inventory.getItemInOffHand().clone();
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
sessions.exit(owner.player);
fixture.awaitCharacterAndReceipt(owner);
recipient.core.inventory.setItem(8, recipient.core.inventory.getItemInOffHand());
recipient.core.inventory.setItemInOffHand(sheet);
fixture.activate(recipient);
assertTrue(root.onCommand(owner.player, fixture.playerCommand, "fstick", new String[]{"feedback"}));
assertEquals("Fantasy feedback is off.", owner.messages.getLast());
assertTrue(root.onCommand(recipient.player, fixture.playerCommand, "fstick", new String[]{"feedback"}));
assertEquals("Fantasy feedback is on.", recipient.messages.getLast());
fixture.plugin.setEnabled(false);
var reads = feedbackRecords().stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
.map(e -> e.getAsJsonObject("data"))
.filter(d -> d.get("preference_key").getAsString().equals("combat_feedback")).toList();
assertTrue(reads.stream().anyMatch(d -> d.get("player_id").getAsString().equals(owner.core.id.toString())
&& d.get("effective_value").getAsString().equals("false") && !d.has("character_id")));
assertTrue(reads.stream().anyMatch(d -> d.get("player_id").getAsString().equals(recipient.core.id.toString())
&& d.get("effective_value").getAsString().equals("true")
&& d.get("character_id").getAsString().equals(owner.core.record.id().toString())));
assertEquals(1, feedbackRecords().stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback")).count());
}
}
private List<com.google.gson.JsonObject> feedbackRecords() throws Exception {
var records = new java.util.ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(data.resolve("events"))) {
for (var path : files.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(path))
if (!line.isBlank()) records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
return records;
}
@Test void sourceAndAffectedAccountPreferencesIndependentlyFilterPerTickMessages() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
@@ -110,7 +110,7 @@ class FantasyHealthNativeTest {
viewer.core.inventory.setItemInOffHand(null); viewer.core.location.setWorld(defeated.world);
f.players.put(defeated.core.id, defeated.player); f.players.put(viewer.core.id, viewer.player);
f.activate(defeated);
f.particlesCommand.execute(defeated.player, "fantasyparticles", new String[]{"all", "off"}); f.tick();
PartyBookNativeTest.disableAllParticles(f,defeated);
defeated.particleCalls.clear(); viewer.particleCalls.clear(); defeated.messages.clear();
var source = new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(
org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(org.bukkit.damage.DamageType.OUT_OF_WORLD)));
@@ -287,6 +287,503 @@ class FantasyParticlesNativeTest {
}
}
@Test void registeredAllParticlesReadAndSavedChangeProduceAccountEvents() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
"fstick", new String[]{"particles", "all"}));
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
"fstick", new String[]{"particles", "all", "off"}));
fixture.tick();
assertEquals("All Fantasy Stick particles are now off.", actor.messages.getLast());
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
for (int attempt = 0; attempt < 80 && !preferences.pendingEvents().isEmpty(); attempt++)
Thread.sleep(10);
assertTrue(preferences.pendingEvents().isEmpty(), "First acknowledged event was not drained");
fixture.plugin.setEnabled(false);
var records = new java.util.ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(data.resolve("events"))) {
for (var path : files.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(path))
if (!line.isBlank()) records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
var read = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
.map(e -> e.getAsJsonObject("data")).filter(d -> d.get("preference_key").getAsString().equals("all-particles"))
.findFirst().orElseThrow();
assertEquals(actor.core.id.toString(), read.get("player_id").getAsString());
assertEquals("true", read.get("effective_value").getAsString());
var changed = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
.map(e -> e.getAsJsonObject("data")).filter(d -> d.get("preference_key").getAsString().equals("all-particles"))
.findFirst().orElseThrow();
assertEquals("true", changed.get("before").getAsString());
assertEquals("false", changed.get("after").getAsString());
assertTrue(changed.get("preference_sequence").getAsLong() > 0);
assertEquals(read.get("player_id"), changed.get("player_id"));
fixture.plugin.setEnabled(true);
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
"fstick", new String[]{"particles", "all", "on"}));
for (int attempt = 0; attempt < 80 && !actor.messages.getLast().contains("now on"); attempt++) {
var callback = fixture.tasks.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS);
if (callback != null) callback.run();
}
assertEquals("All Fantasy Stick particles are now on.", actor.messages.getLast());
fixture.plugin.setEnabled(false);
var changes = preferenceRecords().stream()
.filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
.map(e -> e.getAsJsonObject("data"))
.filter(d -> d.get("preference_key").getAsString().equals("all-particles"))
.toList();
var logical = changes.stream().collect(java.util.stream.Collectors.toMap(
d -> d.get("action_id").getAsString(), d -> d, (first, duplicate) -> first,
java.util.LinkedHashMap::new));
assertEquals(2, logical.size(), "At-least-once export may duplicate a physical line, not a logical change");
assertTrue(logical.values().stream().mapToLong(d -> d.get("preference_sequence").getAsLong()).max().orElseThrow()
> changed.get("preference_sequence").getAsLong(),
"The acknowledged preference revision must stay ordered across restart");
}
}
@Test void refusedAndSupersededAllParticleWritesNeverClaimAnAcceptedChange() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
var root = fixture.playerCommand.getExecutor();
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "all", "off"}));
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "all", "on"}));
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
preferences.setAllParticles(java.util.UUID.randomUUID(), true).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS);
var callbacks = new java.util.ArrayList<Runnable>();
fixture.tasks.drainTo(callbacks);
assertEquals(2, callbacks.size());
java.util.Collections.reverse(callbacks); callbacks.forEach(Runnable::run);
assertTrue(preferences.allParticlesEnabled(actor.core.id));
assertFalse(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "all", "invalid"}));
fixture.plugin.setEnabled(false);
var records = preferenceRecords();
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
var resolved = records.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved"))
.map(e -> e.getAsJsonObject("data"))
.filter(d -> d.has("preference_key") && d.get("preference_key").getAsString().equals("all-particles"))
.toList();
assertEquals(java.util.Set.of("superseded", "unchanged", "invalid_arguments"), resolved.stream()
.map(d -> d.get("reason").getAsString()).collect(java.util.stream.Collectors.toSet()));
assertEquals(3, resolved.size());
fixture.plugin.setEnabled(true);
fixture.plugin.setEnabled(false);
assertEquals(0, preferenceRecords().stream()
.filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count(),
"Superseded and unchanged saves cannot become successes during restart recovery");
}
}
@Test void failedAllParticleSaveRemainsObservableWithoutChangingGameplay() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
java.nio.file.Files.createDirectory(data.resolve("player-preferences.yml"));
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
"fstick", new String[]{"particles", "all", "off"}));
fixture.tick();
assertTrue(actor.messages.getLast().contains("could not be saved"));
fixture.plugin.setEnabled(false);
var records = preferenceRecords();
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
assertEquals(1, records.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved")
&& e.getAsJsonObject("data").has("reason")
&& e.getAsJsonObject("data").get("reason").getAsString().equals("save_failed")).count());
}
}
@Test void accountOptOutRetainsAuditAndCapturesCurrentCharacterWithoutBorrowingAnotherAccount() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
var other = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
fixture.players.put(other.core.id, other.player);
fixture.activate(actor);
var root = fixture.playerCommand.getExecutor();
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "all", "off"}));
fixture.tick();
assertTrue(root.onCommand(other.player, fixture.playerCommand, "fstick",
new String[]{"particles", "all"}));
assertEquals("All Fantasy Stick particles are on.", other.messages.getLast());
var sourceSheet = actor.core.inventory.getItemInOffHand().clone();
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
sessions.exit(actor.player);
fixture.awaitCharacterAndReceipt(actor);
other.core.inventory.setItem(8, other.core.inventory.getItemInOffHand());
other.core.inventory.setItemInOffHand(sourceSheet);
fixture.activate(other);
assertTrue(root.onCommand(other.player, fixture.playerCommand, "fstick",
new String[]{"particles", "all"}));
assertEquals("All Fantasy Stick particles are on.", other.messages.getLast());
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "all"}));
assertEquals("All Fantasy Stick particles are off.", actor.messages.getLast());
assertEquals(List.of("off"), fixture.playerCommand.getTabCompleter().onTabComplete(actor.player,
fixture.playerCommand, "fstick", new String[]{"particles", "all", "of"}));
fixture.plugin.setEnabled(false);
var records = preferenceRecords();
var changed = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
.map(e -> e.getAsJsonObject("data")).toList();
assertEquals(1, changed.size(), "Particle delivery and autocomplete do not create audit events");
assertEquals(actor.core.id.toString(), changed.getFirst().get("player_id").getAsString());
assertEquals(actor.core.record.id().toString(), changed.getFirst().get("character_id").getAsString());
assertEquals(actor.world.getUID().toString(), changed.getFirst().getAsJsonObject("location")
.get("world_id").getAsString());
var read = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
.map(e -> e.getAsJsonObject("data"))
.filter(d -> d.get("player_id").getAsString().equals(other.core.id.toString()))
.findFirst().orElseThrow();
assertFalse(read.has("character_id"), "An ordinary viewer cannot borrow a playing character identity");
assertEquals("true", read.get("effective_value").getAsString());
assertTrue(records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
.map(e -> e.getAsJsonObject("data"))
.anyMatch(d -> d.get("player_id").getAsString().equals(other.core.id.toString())
&& d.has("character_id")
&& d.get("character_id").getAsString().equals(actor.core.record.id().toString())
&& d.get("effective_value").getAsString().equals("true")),
"A transferred sheet carries its character, not the previous account's particle preference");
}
}
@Test void nonPlayerAllParticleRequestsAreTruthfullyRefusedWithoutInventingPreferenceSuccess() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var console = org.mockito.Mockito.mock(org.bukkit.command.ConsoleCommandSender.class);
var block = org.mockito.Mockito.mock(org.bukkit.command.BlockCommandSender.class);
var root = fixture.playerCommand.getExecutor();
assertTrue(root.onCommand(console, fixture.playerCommand, "fstick",
new String[]{"particles", "all", "off"}));
assertTrue(root.onCommand(block, fixture.playerCommand, "fstick",
new String[]{"particles", "all", "off"}));
fixture.plugin.setEnabled(false);
var records = preferenceRecords();
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().contains(".preference.")).count());
var refused = records.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved"))
.map(e -> e.getAsJsonObject("data"))
.filter(d -> d.has("reason") && d.get("reason").getAsString().equals("player_required"))
.toList();
assertEquals(java.util.Set.of("console", "command_block"), refused.stream()
.map(d -> d.get("actor_kind").getAsString()).collect(java.util.stream.Collectors.toSet()));
assertEquals(2, refused.size());
assertTrue(refused.stream().allMatch(d -> d.get("location_status").getAsString().equals("not_applicable")));
}
}
@Test void acceptedPreferenceOutlivesUnavailableEventSinkWithoutLosingItsOriginalEvent() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
var log = (games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog");
log.close(); // Logging fails after gameplay has started; the saved preference remains authoritative.
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
"fstick", new String[]{"particles", "all", "off"}));
fixture.tick();
assertEquals("All Fantasy Stick particles are now off.", actor.messages.getLast());
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
for (int attempt = 0; attempt < 80 && preferences.pendingEvents().stream()
.noneMatch(intent -> "true".equals(intent.captured().get("published"))); attempt++)
Thread.sleep(10);
assertTrue(preferences.pendingEvents().stream()
.anyMatch(intent -> "true".equals(intent.captured().get("published"))),
"Committed publication proof must be durable before orderly restart");
fixture.plugin.setEnabled(false);
fixture.plugin.setEnabled(true);
for (int attempt = 0; attempt < 80 && preferenceRecords().stream()
.noneMatch(e -> e.get("type").getAsString().endsWith("preference.changed")); attempt++)
Thread.sleep(10);
fixture.plugin.setEnabled(false);
var events = preferenceRecords();
var changes = events.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
.filter(e -> e.getAsJsonObject("data").get("preference_key").getAsString().equals("all-particles"))
.toList();
assertEquals(1, changes.stream().map(e -> e.get("id").getAsString()).distinct().count(),
"An acknowledged account change must retain one recoverable event identity");
assertEquals("true", changes.getFirst().getAsJsonObject("data").get("before").getAsString());
assertEquals("false", changes.getFirst().getAsJsonObject("data").get("after").getAsString());
}
}
@Test void readDuringDeferredPublicationDistinguishesSavedFromEffectivePreference() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
var root = fixture.playerCommand.getExecutor();
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "all", "off"}));
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
preferences.setAllParticles(java.util.UUID.randomUUID(), true).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS); // worker barrier; do not execute deferred main callback
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "all"}));
assertEquals("All Fantasy Stick particles are on.", actor.messages.getLast());
fixture.plugin.setEnabled(false);
var read = preferenceRecords().stream()
.filter(e -> e.get("type").getAsString().endsWith("preference.read"))
.map(e -> e.getAsJsonObject("data"))
.filter(d -> d.get("player_id").getAsString().equals(actor.core.id.toString()))
.findFirst().orElseThrow();
assertEquals("false", read.get("saved_value").getAsString());
assertEquals("true", read.get("effective_value").getAsString());
assertEquals(0, preferenceRecords().stream()
.filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count(),
"A queued native callback has not yet published a changed outcome");
}
}
@Test void twoAcknowledgedChangesSurviveEventSinkFailureWithTheirOriginalIdentities() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
((games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
var root = fixture.playerCommand.getExecutor();
for (String value : List.of("off", "on")) {
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "all", value}));
for (int attempt = 0; attempt < 80 && (actor.messages.isEmpty()
|| !actor.messages.getLast().contains("now " + value)); attempt++) {
var callback = fixture.tasks.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS);
if (callback != null) callback.run();
}
assertTrue(actor.messages.getLast().contains("now " + value));
}
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
for (int attempt = 0; attempt < 80 && preferences.pendingEvents().stream()
.filter(intent -> "true".equals(intent.captured().get("published"))).count() < 2; attempt++)
Thread.sleep(10);
assertEquals(2, preferences.pendingEvents().stream()
.filter(intent -> "true".equals(intent.captured().get("published"))).count());
fixture.plugin.setEnabled(false);
fixture.plugin.setEnabled(true);
for (int attempt = 0; attempt < 80 && preferenceRecords().stream()
.filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
.map(e -> e.get("id").getAsString()).distinct().count() < 2; attempt++)
Thread.sleep(10);
fixture.plugin.setEnabled(false);
var changes = preferenceRecords().stream()
.filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
.collect(java.util.stream.Collectors.toMap(e -> e.get("id").getAsString(), e -> e,
(first, duplicate) -> first, java.util.LinkedHashMap::new));
assertEquals(2, changes.size(), "Both acknowledged changes need distinct stable recovered outcomes");
assertEquals(java.util.Set.of("false", "true"), changes.values().stream()
.map(e -> e.getAsJsonObject("data").get("after").getAsString())
.collect(java.util.stream.Collectors.toSet()));
}
}
@Test void registeredAuraPreferenceRecordsDefaultOffAndSavedOptInUnderAllOff() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
var root = fixture.playerCommand.getExecutor();
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "players"}));
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "all", "off"}));
fixture.tick();
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "players", "on"}));
fixture.tick();
assertTrue(actor.messages.getLast().contains("effectively off"));
fixture.plugin.setEnabled(false);
var records = preferenceRecords();
var aura = records.stream().filter(e -> e.getAsJsonObject("data").has("preference_key")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("player-aura-particles"))
.toList();
var read = aura.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
.findFirst().orElseThrow().getAsJsonObject("data");
assertEquals("false", read.get("default_value").getAsString());
assertEquals("false", read.get("saved_value").getAsString());
assertEquals("false", read.get("effective_value").getAsString());
assertEquals("/fstick particles players", read.get("entry_point").getAsString());
var change = aura.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
.findFirst().orElseThrow().getAsJsonObject("data");
assertEquals(actor.core.id.toString(), change.get("player_id").getAsString());
assertEquals("false", change.get("saved_before").getAsString());
assertEquals("true", change.get("saved_value").getAsString());
assertEquals("false", change.get("effective_value").getAsString());
assertEquals("true", change.get("after").getAsString());
assertTrue(change.get("preference_sequence").getAsLong() > 0);
}
}
@Test void auraRefusalsAndSupersededWritesNeverClaimPreferenceSuccess() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
var root = fixture.playerCommand.getExecutor();
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "players", "on"}));
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "players", "off"}));
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
preferences.setPlayerAuras(java.util.UUID.randomUUID(), true).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS);
var callbacks = new java.util.ArrayList<Runnable>();
fixture.tasks.drainTo(callbacks);
assertEquals(2, callbacks.size());
java.util.Collections.reverse(callbacks); callbacks.forEach(Runnable::run);
assertFalse(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "players", "invalid"}));
assertTrue(root.onCommand(org.mockito.Mockito.mock(org.bukkit.command.ConsoleCommandSender.class),
fixture.playerCommand, "fstick", new String[]{"particles", "players", "on"}));
assertTrue(root.onCommand(org.mockito.Mockito.mock(org.bukkit.command.BlockCommandSender.class),
fixture.playerCommand, "fstick", new String[]{"particles", "players", "on"}));
fixture.plugin.setEnabled(false);
var records = preferenceRecords().stream().filter(e -> e.getAsJsonObject("data").has("preference_key")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("player-aura-particles")).toList();
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
var reasons = records.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved"))
.map(e -> e.getAsJsonObject("data").get("reason").getAsString()).toList();
assertEquals(5, reasons.size());
assertEquals(java.util.Set.of("superseded", "unchanged", "invalid_arguments", "player_required"),
java.util.Set.copyOf(reasons));
fixture.plugin.setEnabled(true);
fixture.plugin.setEnabled(false);
assertEquals(0, preferenceRecords().stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
}
}
@Test void auraSaveFailureLeavesDefaultOffAndReportsFailure() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
java.nio.file.Files.createDirectory(data.resolve("player-preferences.yml"));
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
"fstick", new String[]{"particles", "players", "on"}));
fixture.tick();
assertTrue(actor.messages.getLast().contains("could not be saved"));
fixture.plugin.setEnabled(false);
var records = preferenceRecords().stream().filter(e -> e.getAsJsonObject("data").has("preference_key")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("player-aura-particles")).toList();
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
assertEquals("save_failed", records.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved"))
.findFirst().orElseThrow().getAsJsonObject("data").get("reason").getAsString());
}
}
@Test void auraOptInSurvivesRestartAndCharacterTransferWithoutLeakingAccounts() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var owner = new FantasyStickLifecycleNativeTest.Actor(data);
var receiver = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(owner.core.id, owner.player);
fixture.players.put(receiver.core.id, receiver.player);
var root = fixture.playerCommand.getExecutor();
assertTrue(root.onCommand(owner.player, fixture.playerCommand, "fstick",
new String[]{"particles", "players", "on"}));
fixture.tick();
fixture.activate(owner);
var sheet = owner.core.inventory.getItemInOffHand().clone();
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
sessions.exit(owner.player);
fixture.awaitCharacterAndReceipt(owner);
receiver.core.inventory.setItem(8, receiver.core.inventory.getItemInOffHand());
receiver.core.inventory.setItemInOffHand(sheet);
fixture.activate(receiver);
assertTrue(root.onCommand(receiver.player, fixture.playerCommand, "fstick",
new String[]{"particles", "players"}));
assertEquals("Active-character aura particles are off.", receiver.messages.getLast());
assertTrue(root.onCommand(owner.player, fixture.playerCommand, "fstick",
new String[]{"particles", "players"}));
assertEquals("Active-character aura particles are on.", owner.messages.getLast());
fixture.plugin.setEnabled(false);
fixture.plugin.setEnabled(true);
assertTrue(root.onCommand(receiver.player, fixture.playerCommand, "fstick",
new String[]{"particles", "players"}));
assertEquals("Active-character aura particles are off.", receiver.messages.getLast());
assertTrue(root.onCommand(owner.player, fixture.playerCommand, "fstick",
new String[]{"particles", "players"}));
assertEquals("Active-character aura particles are on.", owner.messages.getLast());
assertEquals(List.of("off"), fixture.playerCommand.getTabCompleter().onTabComplete(owner.player,
fixture.playerCommand, "fstick", new String[]{"particles", "players", "of"}));
fixture.plugin.setEnabled(false);
var records = preferenceRecords().stream()
.filter(e -> e.getAsJsonObject("data").has("preference_key")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("player-aura-particles"))
.toList();
assertEquals(1, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
var receiverRead = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
.map(e -> e.getAsJsonObject("data"))
.filter(d -> d.get("player_id").getAsString().equals(receiver.core.id.toString()))
.findFirst().orElseThrow();
assertEquals("false", receiverRead.get("saved_value").getAsString());
assertEquals(owner.core.record.id().toString(), receiverRead.get("character_id").getAsString());
assertTrue(records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
.map(e -> e.getAsJsonObject("data"))
.anyMatch(d -> d.get("player_id").getAsString().equals(owner.core.id.toString())
&& d.get("saved_value").getAsString().equals("true")));
}
}
@Test void acceptedAuraOptInRecoversSameEventAfterSinkOutage() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
((games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
"fstick", new String[]{"particles", "players", "on"}));
fixture.tick();
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
for (int i = 0; i < 80 && preferences.pendingEvents().stream()
.noneMatch(p -> "true".equals(p.captured().get("published"))); i++) Thread.sleep(10);
assertTrue(preferences.pendingEvents().stream()
.anyMatch(p -> "true".equals(p.captured().get("published"))));
fixture.plugin.setEnabled(false);
fixture.plugin.setEnabled(true);
for (int i = 0; i < 80 && preferenceRecords().stream()
.noneMatch(e -> e.get("type").getAsString().endsWith("preference.changed")); i++) Thread.sleep(10);
fixture.plugin.setEnabled(false);
var changes = preferenceRecords().stream()
.filter(e -> e.get("type").getAsString().endsWith("preference.changed")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("player-aura-particles"))
.toList();
assertEquals(1, changes.stream().map(e -> e.get("id").getAsString()).distinct().count());
assertEquals("false", changes.getFirst().getAsJsonObject("data").get("saved_before").getAsString());
assertEquals("true", changes.getFirst().getAsJsonObject("data").get("saved_value").getAsString());
}
}
private java.util.List<com.google.gson.JsonObject> preferenceRecords() throws Exception {
var records = new java.util.ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(data.resolve("events"))) {
for (var path : files.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(path))
if (!line.isBlank()) records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
return records;
}
private static void optIn(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
fixture.particlesCommand.execute(actor.player, "fantasyparticles", new String[]{"players", "on"});
@@ -101,10 +101,32 @@ class FantasyStickLifecycleNativeTest {
assertTrue(f.order.contains("EventCaptureListener"));
assertTrue(f.order.indexOf("EventCaptureListener") < f.order.indexOf("recipe"));
var actor = new Actor(data); f.players.put(actor.core.id, actor.player); f.activate(actor);
var damage = mock(org.bukkit.event.entity.EntityDamageEvent.class);
when(damage.getHandlers()).thenReturn(org.bukkit.event.entity.EntityDamageEvent.getHandlerList());
when(damage.getEntity()).thenReturn(actor.player);
when(damage.getCause()).thenReturn(org.bukkit.event.entity.EntityDamageEvent.DamageCause.LAVA);
when(damage.getFinalDamage()).thenReturn(2.5);
f.dispatcher.accept(damage);
var refusedDamage = mock(org.bukkit.event.entity.EntityDamageEvent.class);
when(refusedDamage.getHandlers()).thenReturn(org.bukkit.event.entity.EntityDamageEvent.getHandlerList());
when(refusedDamage.getEntity()).thenReturn(actor.player);
when(refusedDamage.isCancelled()).thenReturn(true);
f.dispatcher.accept(refusedDamage);
var event = new org.bukkit.event.player.PlayerInteractEvent(actor.player,
org.bukkit.event.block.Action.LEFT_CLICK_AIR, actor.core.inventory.getItem(0), null,
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
f.dispatcher.accept(event);
var duplicate = new org.bukkit.event.player.PlayerInteractEvent(actor.player,
org.bukkit.event.block.Action.LEFT_CLICK_AIR, actor.core.inventory.getItem(0), null,
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.OFF_HAND);
f.dispatcher.accept(duplicate);
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) Fixture.field(f.plugin, "sessions");
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.RESTORED, sessions.exit(actor.player));
for (int attempt = 0; attempt < 80 && f.receipts().find(actor.core.id).toCompletableFuture().get().orElseThrow().pending() != null; attempt++) {
var callback = f.tasks.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS);
if (callback != null) callback.run();
}
assertNull(f.receipts().find(actor.core.id).toCompletableFuture().get().orElseThrow().pending());
f.plugin.setEnabled(false);
String records;
try (var files = java.nio.file.Files.walk(data.resolve("events"))) {
@@ -115,13 +137,56 @@ class FantasyStickLifecycleNativeTest {
records = text.toString();
}
assertTrue(records.contains("games.dmg.minecraft.fantasystick.game.started"));
assertTrue(records.contains("games.dmg.minecraft.fantasystick.character.entered"), "Native saved-session activation must reach JSONL");
assertTrue(records.contains("games.dmg.minecraft.fantasystick.character.exited"), "Living exit must follow acknowledged restoration");
assertTrue(records.contains("games.dmg.minecraft.fantasystick.combat.damage"), "Accepted environmental damage must reach JSONL");
assertTrue(records.contains("\"amount\":\"2.5\""));
assertEquals(1, records.split("games.dmg.minecraft.fantasystick.combat.damage", -1).length - 1);
assertTrue(records.contains("games.dmg.minecraft.fantasystick.action.observed"));
assertTrue(records.contains("games.dmg.minecraft.fantasystick.action.resolved"));
assertTrue(records.contains("games.dmg.minecraft.fantasystick.game.stopped"));
assertTrue(records.contains("\"characterid\":\"" + actor.core.record.id() + "\""));
assertTrue(records.contains("\"item_kind\":\"control\""));
assertTrue(records.contains("\"control_kind\":\"book\""));
assertTrue(records.contains("\"guard_reason\":\"protected_item\""));
assertEquals(1, records.split("games.dmg.minecraft.fantasystick.action.observed", -1).length - 1,
"The duplicate offhand callback must not create another logical observation");
}
}
@Test void activePublicChatAndExplicitPrivateCommandsReachLocalJournalWithoutChangingSignedContent() throws Exception {
try(var f=new Fixture(data)) {
f.plugin.setEnabled(true);
var actor=new Actor(data);f.players.put(actor.core.id,actor.player);f.activate(actor);
var body=net.kyori.adventure.text.Component.text("Hello 雪 🌿");
var signed=net.kyori.adventure.chat.SignedMessage.system("Hello 雪 🌿",body);
var chat=new io.papermc.paper.event.player.AsyncChatEvent(false,actor.player,new java.util.HashSet<>(),
io.papermc.paper.chat.ChatRenderer.defaultRenderer(),body,body,signed);
f.dispatcher.accept(chat);
assertSame(signed,chat.signedMessage());assertSame(body,chat.originalMessage());
var cancelled=new io.papermc.paper.event.player.AsyncChatEvent(false,actor.player,new java.util.HashSet<>(),
io.papermc.paper.chat.ChatRenderer.defaultRenderer(),body,body,signed);
cancelled.setCancelled(true);f.dispatcher.accept(cancelled);assertTrue(cancelled.isCancelled());
for (var command : java.util.List.of("/msg Alex 私信 🌿", "/r thank you", "/warp secret")) {
var request = mock(org.bukkit.event.player.PlayerCommandPreprocessEvent.class);
when(request.getHandlers()).thenReturn(org.bukkit.event.player.PlayerCommandPreprocessEvent.getHandlerList());
when(request.getPlayer()).thenReturn(actor.player);
when(request.getMessage()).thenReturn(command);
when(request.isCancelled()).thenReturn(command.startsWith("/r "));
f.dispatcher.accept(request);
}
f.plugin.setEnabled(false);
}
var rows=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())rows.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}
var chat=rows.stream().filter(row->row.get("type").getAsString().endsWith("character.chat")).toList();
assertEquals(2,chat.size());assertEquals("Hello 雪 🌿",chat.getFirst().getAsJsonObject("data").get("message").getAsString());assertTrue(chat.stream().anyMatch(row->row.getAsJsonObject("data").get("cancelled").getAsBoolean()));
var privateMessages=rows.stream().filter(row->row.get("type").getAsString().endsWith("character.private_message")).toList();
assertEquals(2,privateMessages.size());assertEquals(java.util.Set.of("私信 🌿","thank you"),privateMessages.stream().map(row->row.getAsJsonObject("data").get("message").getAsString()).collect(java.util.stream.Collectors.toSet()));
assertTrue(privateMessages.stream().allMatch(row->row.getAsJsonObject("data").get("delivery").getAsString().equals("unverified")));
assertTrue(privateMessages.stream().anyMatch(row->row.getAsJsonObject("data").get("cancelled").getAsBoolean()));
assertTrue(privateMessages.stream().allMatch(row->row.get("characterid").getAsString().equals(chat.getFirst().get("characterid").getAsString())));
}
@Test void registeredBookInputOpensAuthoritativeNativePagesWithoutGivingOrChangingItems() throws Exception {
try (var f = new Fixture(data)) {
f.plugin.setEnabled(true);
@@ -601,7 +666,7 @@ class FantasyStickLifecycleNativeTest {
f.plugin.setEnabled(true);
var actor = new Actor(data); f.players.put(actor.core.id, actor.player);
actor.core.inventory.setItem(4, new org.bukkit.inventory.ItemStack(org.bukkit.Material.SHULKER_BOX));
f.particlesCommand.execute(actor.player, "fantasyparticles", new String[]{"all", "off"}); f.tick();
PartyBookNativeTest.disableAllParticles(f,actor);
actor.messages.clear(); actor.particles.clear(); actor.particleCalls.clear();
var before = actor.core.snapshot(); f.changed(actor); f.tick(); f.tick();
assertArrayEquals(before, actor.core.snapshot()); assertNull(actor.core.marker());
@@ -1396,7 +1461,8 @@ class FantasyStickLifecycleNativeTest {
}
void activate(Actor actor) throws Exception {
changed(actor); tick(); tick(); tick();
assertTrue(actor.core.marker().endsWith("|PACKED"));
for(int attempt=0;attempt<80&&(actor.core.marker()==null||!actor.core.marker().endsWith("|PACKED"));attempt++)tick();
assertNotNull(actor.core.marker());assertTrue(actor.core.marker().endsWith("|PACKED"));
}
private void captureWorkers(JavaPlugin instance) {
for (String name : List.of("characters", "receipts", "adventureSettings", "revivals", "trainingDummies",
@@ -52,7 +52,7 @@ class FireballNativeTest {
var viewer = new FantasyStickLifecycleNativeTest.Actor(data);
viewer.core.inventory.setItemInOffHand(null); viewer.core.location.setWorld(a.world);
f.players.put(viewer.core.id, viewer.player);
f.particlesCommand.execute(a.player, "fantasyparticles", new String[]{"all", "off"}); PartyBookNativeTest.drain(f);
PartyBookNativeTest.disableAllParticles(f,a);
a.particleCalls.clear(); viewer.particleCalls.clear();
f.dispatcher.accept(cast); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertTrue(a.particleCalls.isEmpty(), "The opted-out caster receives no Fireball particles");
@@ -128,6 +128,69 @@ class FireballNativeTest {
assertEquals(1, launches.get(), "Exit/resume and control reconstruction preserve the same Fireball deadline");
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("fireball"));
}
var events = events(data);
for (var suffix : java.util.List.of("ability.learned", "ability.assigned", "ability.cast",
"ability.resolved", "combat.damage"))
assertEquals(1, events.stream().filter(event -> event.get("type").getAsString().endsWith(suffix)
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("fireball")).count(), suffix);
var castEvent = events.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("fireball")).findFirst().orElseThrow();
var resolution = events.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("fireball")).findFirst().orElseThrow();
assertEquals(castEvent.getAsJsonObject("data").get("action_id").getAsString(),
resolution.getAsJsonObject("data").get("action_id").getAsString());
assertEquals("hit", resolution.getAsJsonObject("data").get("outcome").getAsString());
assertEquals("12", castEvent.getAsJsonObject("data").get("captured_int").getAsString());
assertEquals("6", castEvent.getAsJsonObject("data").getAsJsonObject("captured_settings")
.get("base-damage").getAsString());
assertEquals("definition_at_request", castEvent.getAsJsonObject("data").get("settings_scope").getAsString());
assertEquals("6", resolution.getAsJsonObject("data").getAsJsonObject("effective_settings")
.get("base-damage").getAsString());
assertTrue(resolution.getAsJsonObject("data").has("direct_entity_id"));
assertTrue(resolution.getAsJsonObject("data").has("target_location"));
var damage = events.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("fireball")).findFirst().orElseThrow();
assertEquals("7.0", damage.getAsJsonObject("data").get("actual_amount").getAsString());
assertEquals(castEvent.getAsJsonObject("data").get("action_id").getAsString(),
damage.getAsJsonObject("data").get("action_id").getAsString());
}
@Test void acceptedFantasyPlayerHitHasOneOwnedDamageRecordNotAnIncomingDuplicate() 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);
withFireball(data, a, 12);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
f.activate(a); f.activate(b);
var worldId = UUID.randomUUID();
org.mockito.Mockito.when(a.world.getUID()).thenReturn(worldId);
b.core.location.setWorld(a.world); b.core.location.setX(10); b.core.location.setY(65); b.core.location.setZ(0);
org.mockito.Mockito.doReturn(org.bukkit.entity.EntityType.PLAYER).when(b.player).getType();
var arrow = projectile(a, new org.bukkit.Location(a.world, 10, 65.6, 0));
prepareLaunches(f, a, arrow);
var hits = new java.util.ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
org.mockito.Mockito.doAnswer(call -> {
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(arrow, b.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE,
(org.bukkit.damage.DamageSource) call.getArgument(1), (Double) call.getArgument(0));
f.dispatcher.accept(event); hits.add(event); return null;
}).when(b.player).damage(org.mockito.ArgumentMatchers.anyDouble(),
org.mockito.ArgumentMatchers.any(org.bukkit.damage.DamageSource.class));
var eye = new org.bukkit.Location(a.world, 0, 65.6, 0);
eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
org.mockito.Mockito.doReturn(eye).when(a.player).getEyeLocation();
cast(f, a);
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(arrow, b.player));
assertEquals(1, hits.size()); assertFalse(hits.getFirst().isCancelled());
}
var damage = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
&& event.getAsJsonObject("data").has("target_player_id")).toList();
assertEquals(1, damage.size(), "The native incoming observer must yield to the ability owner");
assertEquals("fireball", damage.getFirst().getAsJsonObject("data").get("ability_id").getAsString());
assertEquals("7.0", damage.getFirst().getAsJsonObject("data").get("actual_amount").getAsString());
}
@SuppressWarnings("unchecked")
@@ -258,6 +321,12 @@ class FireballNativeTest {
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")),
"Transfer/reload must retain the original Fireball deadline after refund and relearn");
}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}var unlearned=events.stream().filter(event->event.get("type").getAsString().endsWith("ability.unlearned")).toList();assertEquals(1,unlearned.size());var value=unlearned.getFirst().getAsJsonObject("data");assertEquals("fireball",value.get("ability_id").getAsString());assertEquals("39",value.get("points_before").getAsString());assertEquals("40",value.get("points_after").getAsString());assertEquals(java.util.List.of(0),value.getAsJsonArray("cleared_slot_indexes").asList().stream().map(com.google.gson.JsonElement::getAsInt).toList());assertEquals("acknowledged",value.get("native_publication").getAsString());assertEquals("invalidation_acknowledged_close_attempted",value.get("owned_cleanup").getAsString());assertEquals(value.get("cooldown_before").getAsString(),value.get("cooldown_after").getAsString());assertTrue(value.getAsJsonObject("location").has("world_id"));var action=value.get("action_id").getAsString();assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("action.observed")&&event.getAsJsonObject("data").has("action_kind")&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("ability_unlearn")&&event.getAsJsonObject("data").get("action_id").getAsString().equals(action)).count());assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("action.resolved")&&event.getAsJsonObject("data").has("action_kind")&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("ability_unlearn")&&event.getAsJsonObject("data").get("action_id").getAsString().equals(action)&&event.getAsJsonObject("data").get("outcome").getAsString().equals("accepted")).count());assertTrue(events.stream().filter(event->event.get("type").getAsString().endsWith("action.resolved")&&event.getAsJsonObject("data").has("action_kind")&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("ability_unlearn")).map(event->event.getAsJsonObject("data")).anyMatch(data->data.get("outcome").getAsString().equals("refused")&&data.get("reason").getAsString().equals("stale_forged_or_repeated_action")));
}
@Test void failedBookUnlearnResolvesWithoutInventingARefundEvent() throws Exception {
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withFireball(data,a,12);f.players.put(a.core.id,a.player);f.activate(a);PartyBookNativeTest.open(f,a);var unlearn=PartyBookNativeTest.button(a,"[Unlearn]");try(var channel=java.nio.channels.FileChannel.open(data.resolve("characters/.records.lock"),java.nio.file.StandardOpenOption.WRITE);var lock=channel.lock()){assertTrue(lock.isValid());PartyBookNativeTest.click(f,a,unlearn);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertTrue(storage.find(a.core.record.id()).get().orElseThrow().learned().contains("fireball"));f.plugin.setEnabled(false);f.awaitWorkers();}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}assertEquals(0,events.stream().filter(event->event.get("type").getAsString().endsWith("ability.unlearned")).count());assertTrue(events.stream().filter(event->event.get("type").getAsString().endsWith("action.resolved")&&event.getAsJsonObject("data").has("action_kind")&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("ability_unlearn")).map(event->event.getAsJsonObject("data")).anyMatch(value->value.get("outcome").getAsString().equals("failed")&&value.get("reason").getAsString().equals("save_or_native_cleanup_uncertain")));
}
@Test void partyAndMixedModeProjectileHitsPreserveProtectionCancellation() throws Exception {
@@ -290,6 +359,14 @@ class FireballNativeTest {
assertTrue(org.mockito.Mockito.mockingDetails(a.world).getInvocations().stream()
.noneMatch(invocation -> invocation.getMethod().getName().equals("createExplosion")));
}
var observed = events(data);
assertEquals(2, observed.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("fireball")
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("cancelled")).count());
assertEquals(0, observed.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("fireball")).count());
}
@Test void rangeLifetimeAndSessionEndEachRemoveOwnedProjectileWithoutLateDamage() throws Exception {
@@ -327,6 +404,16 @@ class FireballNativeTest {
((games.dmg.fantasystick.ability.AbilityEngine) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityEngine")).sweep();
org.mockito.Mockito.verify(exiting, org.mockito.Mockito.atLeastOnce()).remove();
}
var outcomes = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("fireball"))
.map(event -> event.getAsJsonObject("data").get("outcome").getAsString()).toList();
assertEquals(4, outcomes.size(), outcomes.toString());
assertEquals(2, outcomes.stream().filter("miss"::equals).count());
assertTrue(outcomes.contains("expired")); assertTrue(outcomes.contains("interrupted"));
assertEquals(0, events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("fireball")).count());
}
@Test void validatedConfiguredBalanceReachesTheRegisteredDefinition() throws Exception {
@@ -345,6 +432,12 @@ class FireballNativeTest {
assertEquals(new java.math.BigDecimal("4"), definition.parameters().get("projectile-speed"));
assertTrue(String.join(" ", definition.lore()).contains("Deals 9"));
var admin = new EquipmentAdminCommandNativeTest.Sender(); admin.allowed = true;
f.command.execute(admin.port, "fstickadmin",
new String[]{"config", "fireball-projectile-speed-blocks-per-tick"});
admin.allowed = false;
f.command.execute(admin.port, "fstickadmin",
new String[]{"config", "fireball-projectile-speed-blocks-per-tick"});
admin.allowed = true;
assertEquals(java.util.List.of("fireball-projectile-speed-blocks-per-tick"), f.command.tabComplete(admin.port,
"fantasystick", new String[]{"config", "fireball-projectile-s"}));
assertEquals(java.util.List.of("3", "4"), f.command.tabComplete(admin.port, "fantasystick",
@@ -356,7 +449,28 @@ class FireballNativeTest {
assertEquals(new java.math.BigDecimal("4.5"), updated.parameters().get("projectile-speed"));
assertTrue(java.nio.file.Files.readString(data.resolve("config.yml"))
.contains("fireball-projectile-speed-blocks-per-tick: '4.5'"));
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
var lock = channel.lock()) {
assertTrue(lock.isValid());
f.command.execute(admin.port, "fstickadmin",
new String[]{"config", "fireball-projectile-speed-blocks-per-tick", "5"});
var failed = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
assertNotNull(failed); failed.run();
}
assertEquals("4.5", ((games.dmg.fantasystick.persistence.FireballSettings)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "fireballSettings"))
.value("fireball-projectile-speed-blocks-per-tick"));
assertTrue(admin.messages.getLast().contains("save failed"));
}
var config = events(data).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("fireball-projectile-speed-blocks-per-tick")).toList();
assertEquals(2, config.size());
assertTrue(config.getFirst().get("type").getAsString().endsWith("config.read"));
assertEquals("4", config.getLast().getAsJsonObject("data").get("before").getAsString());
assertEquals("4.5", config.getLast().getAsJsonObject("data").get("after").getAsString());
assertEquals("console", config.getLast().getAsJsonObject("data").get("actor_kind").getAsString());
}
@Test void liveSpeedUpdateAppliesOnlyToFutureCastsAndRetainsActiveArrow() throws Exception {
@@ -467,6 +581,12 @@ class FireballNativeTest {
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),
java.util.UUID.randomUUID(), record.id());
var player = org.mockito.Mockito.mock(org.bukkit.entity.Player.class);
var world = org.mockito.Mockito.mock(org.bukkit.World.class);
org.mockito.Mockito.when(world.getUID()).thenReturn(UUID.randomUUID());
org.mockito.Mockito.when(world.getName()).thenReturn("world");
org.mockito.Mockito.when(player.getUniqueId()).thenReturn(active.player());
org.mockito.Mockito.when(player.getName()).thenReturn("Caster");
org.mockito.Mockito.when(player.getLocation()).thenReturn(new org.bukkit.Location(world, 1, 64, 2));
org.mockito.Mockito.when(sessions.current(active)).thenReturn(true);
org.mockito.Mockito.when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
var failed = new java.util.concurrent.CompletableFuture<games.dmg.fantasystick.persistence.CharacterStorage.AbilityMutation>();
@@ -477,6 +597,10 @@ class FireballNativeTest {
var failures = new java.util.ArrayList<games.dmg.fantasystick.ability.AbilityEngine.Failure>();
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry, storage, sessions, parties,
java.time.Clock.systemUTC(), Runnable::run, (delay, action) -> () -> {}, ignored -> {}, failures::add);
var audit = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
engine.attachCastEvents(new games.dmg.fantasystick.events.AbilityCastEventBridge(audit::add,
java.time.Clock.systemUTC()));
engine.enableCastEventsFor("fireball");
engine.accept(new games.dmg.fantasystick.session.ControlInput(player, active, 2, "fireball",
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
games.dmg.fantasystick.session.ControlInput.Gesture.USE, null, 0, 0, () -> true));
@@ -485,6 +609,10 @@ class FireballNativeTest {
org.mockito.Mockito.verify(sessions, org.mockito.Mockito.never()).acknowledgeProgress(
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
assertEquals(1, failures.size());
assertEquals(java.util.List.of("action.observed", "ability.resolved"),
audit.stream().map(games.dmg.fantasystick.events.EventInput::type).toList());
assertEquals("failed", audit.getLast().data().get("outcome"));
assertEquals(audit.getFirst().actionId(), audit.getLast().actionId());
engine.close(); fireball.close(); parties.close();
}
@@ -521,6 +649,16 @@ class FireballNativeTest {
}
}
private static java.util.List<com.google.gson.JsonObject> events(Path data) throws Exception {
var events = new java.util.ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(data.resolve("events"))) {
for (var file : files.filter(path -> path.toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(file))
if (!line.isBlank()) events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
return events;
}
private static void await(java.util.concurrent.atomic.AtomicInteger value, int expected) throws Exception {
for (int attempt = 0; attempt < 500 && value.get() < expected; attempt++) Thread.sleep(10);
assertEquals(expected, value.get());
@@ -596,6 +734,7 @@ class FireballNativeTest {
org.bukkit.Location location) {
var projectile = org.mockito.Mockito.mock(org.bukkit.entity.Arrow.class);
org.mockito.Mockito.when(projectile.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
org.mockito.Mockito.when(projectile.getType()).thenReturn(org.bukkit.entity.EntityType.ARROW);
org.mockito.Mockito.when(projectile.getShooter()).thenReturn(actor.player);
org.mockito.Mockito.when(projectile.getLocation()).thenAnswer(ignored -> location.clone());
org.mockito.Mockito.when(projectile.getWorld()).thenReturn(location.getWorld());
@@ -80,7 +80,7 @@ class FrostNovaNativeTest {
var viewer = new FantasyStickLifecycleNativeTest.Actor(data);
viewer.core.inventory.setItemInOffHand(null); viewer.core.location.setWorld(caster.world);
fixture.players.put(viewer.core.id, viewer.player);
fixture.particlesCommand.execute(caster.player, "fantasyparticles", new String[]{"all", "off"}); PartyBookNativeTest.drain(fixture);
PartyBookNativeTest.disableAllParticles(fixture,caster);
caster.particleCalls.clear(); viewer.particleCalls.clear();
use(fixture, caster); drain(fixture, 2);
assertTrue(caster.particleCalls.isEmpty(), "The opted-out caster receives no Frost Nova particles");
@@ -99,6 +99,45 @@ class FrostNovaNativeTest {
assertEquals(60L, fixture.delayedTicks.getLast()); fixture.delayed.getLast().run();
assertFalse(enemyEffects.containsKey(PotionEffectType.SLOWNESS)); assertFalse(opponentEffects.containsKey(PotionEffectType.SLOWNESS));
}
var records = events(data);
for (var type : List.of("ability.learned", "ability.assigned", "ability.cast", "ability.resolved"))
assertEquals(1, abilityEvents(records, type).size(), type);
assertEquals(2, abilityEvents(records, "combat.damage").size(), "One accepted record per eligible target");
for (var type : List.of("effect.applied", "effect.ended"))
assertEquals(2, abilityEvents(records, type).size(), "Only the two owned Slowness layers: " + type);
var damageEvents = abilityEvents(records, "combat.damage");
assertEquals(2, records.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count(),
"The generic native observer cannot duplicate either owned burst hit");
assertEquals(2, damageEvents.stream().map(event -> event.getAsJsonObject("data").get("application_id").getAsString()).distinct().count());
assertTrue(damageEvents.stream().allMatch(event -> event.getAsJsonObject("data").has("target_location")
&& event.getAsJsonObject("data").get("actual_amount").getAsDouble() == 8
&& event.getAsJsonObject("data").get("damage_kind").getAsString().equals("owned_area_burst")));
var applied = abilityEvents(records, "effect.applied");
assertEquals(2, applied.stream().map(event -> event.getAsJsonObject("data").get("application_id").getAsString()).distinct().count());
assertTrue(applied.stream().allMatch(event -> event.getAsJsonObject("data").get("amplifier").getAsInt() == 1));
assertEquals(2, abilityEvents(records, "effect.ended").stream()
.filter(event -> event.getAsJsonObject("data").get("reason").getAsString().equals("expired")).count());
var outcome = abilityEvents(records, "ability.resolved").getFirst().getAsJsonObject("data");
assertEquals("hit", outcome.get("outcome").getAsString());
assertEquals(2, outcome.get("accepted_count").getAsInt());
assertEquals(3, outcome.get("attempt_count").getAsInt());
assertEquals(5, outcome.get("candidate_count").getAsInt(), "Duplicate nearby references are deduplicated");
assertTrue(outcome.getAsJsonObject("burst_location").has("world_id"));
}
private static List<com.google.gson.JsonObject> events(Path root) throws Exception {
var values = new ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(root.resolve("events"))) {
for (var file : files.filter(path -> path.toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(file))
if (!line.isBlank()) values.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
return values;
}
private static List<com.google.gson.JsonObject> abilityEvents(List<com.google.gson.JsonObject> events, String suffix) {
return events.stream().filter(event -> event.get("type").getAsString().endsWith(suffix)
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_nova")).toList();
}
@SuppressWarnings("unchecked")
@@ -274,6 +313,18 @@ class FrostNovaNativeTest {
var enemyEffects = effects(enemy); var applied = new HashMap<UUID, Double>(); damage(fixture, enemy, applied);
use(fixture, caster); drain(fixture, 2); assertEquals(12, applied.get(enemy.getUniqueId()), 1.0e-9);
assertEquals(3, enemyEffects.get(PotionEffectType.SLOWNESS).getAmplifier()); assertEquals(120L, fixture.delayedTicks.getLast());
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
var lock = channel.lock()) {
assertTrue(lock.isValid());
fixture.command.execute(sender.port, "fstickadmin",
new String[]{"config", "frost-nova-radius-blocks", "11"});
var failed = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(failed); failed.run();
}
assertEquals("10", ((games.dmg.fantasystick.persistence.FrostNovaSettings)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "frostNovaSettings"))
.value("frost-nova-radius-blocks"));
assertTrue(sender.messages.getLast().contains("save failed"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-nova-radius-blocks", "0"});
assertTrue(sender.messages.getLast().contains("invalid"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-nova-radius-blocks", "10", "extra"});
@@ -286,6 +337,12 @@ class FrostNovaNativeTest {
assertEquals(java.time.Duration.ofSeconds(3), saved.cooldown());
assertEquals(0, new BigDecimal("6").compareTo(saved.parameters().get("slowness-duration-seconds")));
}
var config = events(data).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().startsWith("frost-nova-")).toList();
assertEquals(6, config.size(), "One authorized read and five acknowledged saves, no denied/invalid successes");
assertEquals("8", config.getFirst().getAsJsonObject("data").get("after").getAsString());
for (String invalid : List.of("frost-nova-base-damage: -1\n", "frost-nova-cooldown-seconds: -1\n",
"frost-nova-radius-blocks: 0\n", "frost-nova-slowness-amplifier: -1\n", "frost-nova-slowness-duration-seconds: 0\n")) {
var directory = data.resolve(Integer.toHexString(invalid.hashCode())); java.nio.file.Files.createDirectories(directory);
@@ -55,7 +55,7 @@ class FrostShardNativeTest {
var viewer = new FantasyStickLifecycleNativeTest.Actor(data);
viewer.core.inventory.setItemInOffHand(null); viewer.core.location.setWorld(a.world);
f.players.put(viewer.core.id, viewer.player);
f.particlesCommand.execute(a.player, "fantasyparticles", new String[]{"all", "off"}); PartyBookNativeTest.drain(f);
PartyBookNativeTest.disableAllParticles(f,a);
a.particleCalls.clear(); viewer.particleCalls.clear();
f.dispatcher.accept(cast); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertTrue(a.particleCalls.isEmpty(), "The opted-out caster receives no Frost Shard particles");
@@ -68,6 +68,65 @@ class GuardianBondNativeTest {
var expired = hit(enemy, ally.player, 10); fixture.dispatcher.accept(expired);
assertEquals(10, expired.getDamage(), 1.0e-9);
}
var records = events(data);
for (var type : List.of("ability.learned", "ability.assigned", "ability.cast", "ability.resolved",
"effect.applied", "effect.ended", "combat.prevented", "combat.damage"))
assertEquals(1, records.stream().filter(event -> event.get("type").getAsString().endsWith(type)
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("guardian_bond")).count(), type);
var cast = records.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("guardian_bond")).findFirst().orElseThrow();
var prevented = records.stream().filter(event -> event.get("type").getAsString().endsWith("combat.prevented")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("guardian_bond")).findFirst().orElseThrow();
assertEquals("3", prevented.getAsJsonObject("data").get("actual_amount").getAsString());
assertEquals(cast.getAsJsonObject("data").get("action_id").getAsString(),
prevented.getAsJsonObject("data").get("action_id").getAsString());
}
private static List<com.google.gson.JsonObject> events(Path root) throws Exception {
var values = new ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(root.resolve("events"))) {
for (var file : files.filter(path -> path.toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(file))
if (!line.isBlank()) values.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
return values;
}
@Test void laterCancelledOriginalHitCannotClaimAcceptedPrevention() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var guardian = new FantasyStickLifecycleNativeTest.Actor(data);
var ally = new FantasyStickLifecycleNativeTest.Actor(data);
prepareGuardian(data, guardian, true);
fixture.players.put(guardian.core.id, guardian.player);
fixture.players.put(ally.core.id, ally.player);
fixture.activate(guardian); fixture.activate(ally);
party(fixture, guardian, ally); aim(guardian, ally, 8, true);
nativeDamage(fixture, guardian);
class Veto implements org.bukkit.event.Listener {
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST)
public void cancel(EntityDamageByEntityEvent event) {
if (event.getEntity() == ally.player) event.setCancelled(true);
}
}
fixture.dispatcher.register(new Veto(), fixture.plugin);
use(fixture, guardian); drain(fixture, 2);
var enemy = mock(org.bukkit.entity.Zombie.class);
when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
var original = hit(enemy, ally.player, 10); fixture.dispatcher.accept(original);
assertTrue(original.isCancelled());
assertEquals(7, guardian.player.getHealth(), 1.0e-9);
}
var observed = events(data);
assertEquals(0, observed.stream().filter(event -> event.get("type").getAsString().endsWith("combat.prevented")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("guardian_bond")).count());
assertEquals(1, observed.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("guardian_bond")).count(),
"The guardian's separately accepted native damage remains truthful");
}
@Test void lethalRedirectUsesNormalDefeatAndOnlyRemovesGuardianAvailableHealth() throws Exception {
@@ -92,7 +151,10 @@ class GuardianBondNativeTest {
}
};
use(fixture, guardian); drain(fixture, 2); guardian.core.health = 1; ally.core.health = 9;
var hit = hit(mock(org.bukkit.entity.Zombie.class), ally.player, 10); fixture.dispatcher.accept(hit);
var attacker = mock(org.bukkit.entity.Zombie.class);
when(attacker.getUniqueId()).thenReturn(UUID.randomUUID());
when(attacker.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
var hit = hit(attacker, ally.player, 10); fixture.dispatcher.accept(hit);
assertEquals(List.of("guardian"), defeatOrder,
"Redirected damage reaches normal guardian defeat before original damage resumes");
assertEquals(9, hit.getDamage(), 1.0e-9, "Overkill cannot delete damage the guardian could not accept");
@@ -107,6 +169,11 @@ class GuardianBondNativeTest {
}
assertEquals(List.of("guardian", "ally"), defeatOrder, "Both characters may be defeated in the approved order");
}
var ownerDamage = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("guardian_bond")).toList();
assertEquals(1, ownerDamage.size(), "A lethal redirect still has one accepted guardian damage record");
assertEquals(1.0, ownerDamage.getFirst().getAsJsonObject("data").get("actual_amount").getAsDouble());
}
@Test void actualProtectionCreatesOnePersistedSupportShareForTheAidedDirectContributor() throws Exception {
@@ -132,6 +199,25 @@ class GuardianBondNativeTest {
}
}
@Test void cancelledOriginalNativeHitCannotCreateEffectiveSupport() {
var context = new Context(true);
when(context.guardian.getHealth()).thenReturn(10.0);
doAnswer(call -> {
var redirected = hit(call.getArgument(1), context.guardian, (Double) call.getArgument(0));
context.bond.acceptedRedirect(redirected);
return null;
}).when(context.guardian).damage(anyDouble(), any(Entity.class));
context.activate();
var cancelled = hit(context.enemy, context.ally, 10);
context.bond.redirect(cancelled); cancelled.setCancelled(true);
context.bond.acceptedPrevention(cancelled);
assertTrue(context.supports.isEmpty(), "A later-vetoed original hit cannot create support credit");
var accepted = hit(context.enemy, context.ally, 10);
context.bond.redirect(accepted); context.bond.acceptedPrevention(accepted);
assertEquals(1, context.supports.size());
assertEquals(0, new BigDecimal("3").compareTo(context.supports.getFirst().amount()));
}
@Test void conservedRoutingUsesPostRecipientAndPostGuardianMitigationCancellationAndLethalCaps() {
var context = new Context(); var requested = new ArrayList<BigDecimal>();
var mitigation = new games.dmg.fantasystick.ability.CombatMitigation();
@@ -268,6 +354,19 @@ class GuardianBondNativeTest {
nativeDamage(fixture, guardian); use(fixture, guardian); drain(fixture, 2);
var future = hit(mock(org.bukkit.entity.Zombie.class), ally.player, 10); fixture.dispatcher.accept(future);
assertEquals(5, future.getDamage(), 1.0e-9); assertEquals(180L, fixture.delayedTicks.getLast());
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
var lock = channel.lock()) {
assertTrue(lock.isValid());
fixture.command.execute(sender.port, "fstickadmin",
new String[]{"config", "guardian-bond-range-blocks", "17"});
var failed = fixture.tasks.poll(5, TimeUnit.SECONDS);
assertNotNull(failed); failed.run();
}
assertEquals("16", ((games.dmg.fantasystick.persistence.GuardianBondSettings)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "guardianBondSettings"))
.value("guardian-bond-range-blocks"));
assertTrue(sender.messages.getLast().contains("save failed"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "guardian-bond-duration-seconds", "0"});
assertTrue(sender.messages.getLast().contains("invalid"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "guardian-bond-range-blocks", "16", "extra"});
@@ -281,6 +380,18 @@ class GuardianBondNativeTest {
assertEquals(0, new BigDecimal("9").compareTo(afterRestart.parameters().get("duration-seconds")));
assertEquals(new BigDecimal("50"), afterRestart.parameters().get("redirect-percent"));
}
var config = events(data).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().startsWith("guardian-bond-")).toList();
assertEquals(5, config.size());
assertEquals("14", config.getFirst().getAsJsonObject("data").get("after").getAsString());
assertEquals("console", config.getFirst().getAsJsonObject("data").get("actor_kind").getAsString());
var range = config.stream().filter(event -> event.get("type").getAsString().endsWith("config.changed")
&& event.getAsJsonObject("data").get("key").getAsString().equals("guardian-bond-range-blocks"))
.findFirst().orElseThrow();
assertEquals("14", range.getAsJsonObject("data").get("before").getAsString());
assertEquals("16", range.getAsJsonObject("data").get("after").getAsString());
for (String invalid : List.of("guardian-bond-cooldown-seconds: -1\n", "guardian-bond-range-blocks: 0\n",
"guardian-bond-duration-seconds: 0\n", "guardian-bond-redirect-percent: 101\n")) {
var directory = data.resolve(Integer.toHexString(invalid.hashCode())); java.nio.file.Files.createDirectories(directory);
@@ -299,7 +410,8 @@ class GuardianBondNativeTest {
GuardianBond.Damage damage = (target, attacker, amount) -> amount; final GuardianBond bond;
final EquipmentSessions.Active guardianActive; final EquipmentSessions.Active allyActive;
final PartyRoster.Party party;
Context() {
Context() { this(false); }
Context(boolean nativeAdapter) {
var guardianId = UUID.randomUUID(); var allyId = UUID.randomUUID();
when(guardian.getUniqueId()).thenReturn(guardianId); when(ally.getUniqueId()).thenReturn(allyId);
when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
@@ -314,8 +426,10 @@ class GuardianBondNativeTest {
when(parties.partyOf(guardianId)).thenReturn(java.util.Optional.of(party)); when(parties.partyOf(allyId)).thenReturn(java.util.Optional.of(party));
when(input.player()).thenReturn(guardian); when(input.target()).thenReturn(ally); when(input.session()).thenReturn(guardianActive);
when(execution.input()).thenReturn(input); when(execution.stillCurrent()).thenReturn(true);
bond = new GuardianBond(GuardianBond.Settings.defaults(), sessions, parties,
(target, attacker, amount) -> damage.apply(target, attacker, amount), supports::add);
bond = nativeAdapter
? new GuardianBond(GuardianBond.Settings.defaults(), sessions, parties, supports::add)
: new GuardianBond(GuardianBond.Settings.defaults(), sessions, parties,
(target, attacker, amount) -> damage.apply(target, attacker, amount), supports::add);
when(execution.definition()).thenReturn(bond.definition());
}
void activate() { bond.activate(execution); }
@@ -88,6 +88,45 @@ class MendingLightNativeTest {
resumed.core.inventory.setItemInOffHand(allySheet); fixture.players.put(resumed.core.id, resumed.player); fixture.activate(resumed);
assertEquals(10, resumed.player.getHealth(), "Healed health survives exit, transfer and resume");
}
var records = events(data);
for (var type : List.of("ability.learned", "ability.assigned", "ability.cast", "ability.resolved", "combat.healed"))
assertEquals(type.equals("ability.learned") || type.equals("ability.assigned") ? 2 : 1,
records.stream().filter(event -> event.get("type").getAsString().endsWith(type)
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("mending_light")).count(), type);
var cast = records.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("mending_light")).findFirst().orElseThrow();
var healed = records.stream().filter(event -> event.get("type").getAsString().endsWith("combat.healed")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("mending_light")).findFirst().orElseThrow();
assertEquals(cast.getAsJsonObject("data").get("action_id").getAsString(),
healed.getAsJsonObject("data").get("action_id").getAsString());
assertEquals("6", healed.getAsJsonObject("data").get("actual_amount").getAsString());
assertEquals("4", healed.getAsJsonObject("data").get("health_before").getAsString());
assertEquals("10", healed.getAsJsonObject("data").get("health_after").getAsString());
}
@Test void accountFeedbackOptOutCannotHideAnAcceptedNativeHealEvent() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var caster = new FantasyStickLifecycleNativeTest.Actor(data);
var ally = new FantasyStickLifecycleNativeTest.Actor(data);
prepareCaster(data, caster, true); setHealth(data, ally, BigDecimal.valueOf(4));
fixture.players.put(caster.core.id, caster.player); fixture.players.put(ally.core.id, ally.player);
fixture.activate(caster); fixture.activate(ally); party(fixture, caster, ally); aim(caster, ally, 8, true);
assertTrue(fixture.playerCommand.getExecutor().onCommand(caster.player, fixture.playerCommand,
"fstick", new String[]{"feedback", "off"}));
fixture.tick();
use(fixture, caster); drain(fixture, 3);
assertFalse(caster.messages.contains("Mending Light healed " + ally.core.record.name() + " for 6 health."));
assertTrue(ally.messages.contains("Mending Light healed " + ally.core.record.name() + " for 6 health."));
fixture.plugin.setEnabled(false);
}
var records = events(data);
assertEquals(1, records.stream().filter(e -> e.get("type").getAsString().endsWith("combat.healed")
&& e.getAsJsonObject("data").has("ability_id")
&& e.getAsJsonObject("data").get("ability_id").getAsString().equals("mending_light")).count());
assertEquals(1, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback")).count());
}
@Test void actualHealingCreatesOnePersistedSupportShareForAnAllyDirectContributor() throws Exception {
@@ -124,6 +163,16 @@ class MendingLightNativeTest {
}
}
private static List<com.google.gson.JsonObject> events(Path root) throws Exception {
var values = new ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(root.resolve("events"))) {
for (var file : files.filter(path -> path.toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(file))
if (!line.isBlank()) values.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
return values;
}
@Test void selfOrdinaryNonPartyFullHealthRangeAndLineOfSightRefuseBeforeCooldown() throws Exception {
for (int route = 0; route < 6; route++) {
var directory = data.resolve("refusal-" + route);
@@ -145,6 +194,9 @@ class MendingLightNativeTest {
assertFalse(storage(fixture).find(caster.core.record.id()).get().orElseThrow()
.cooldowns().containsKey("mending_light"), "route " + route + " must refuse before cooldown");
}
assertEquals(0, events(directory).stream().filter(event -> event.get("type").getAsString().endsWith("combat.healed")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("mending_light")).count());
}
}
@@ -166,6 +218,14 @@ class MendingLightNativeTest {
assertEquals(4, ally.player.getHealth());
assertNotNull(storage(fixture).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("mending_light"));
}
var observed = events(data);
assertEquals(0, observed.stream().filter(event -> event.get("type").getAsString().endsWith("combat.healed")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("mending_light")).count());
assertEquals(1, observed.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("mending_light")
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("cancelled")).count());
}
@Test void targetSaveFailureOrSessionReplacementPublishesNoNativeHealingOrSupport() {
@@ -211,6 +271,8 @@ class MendingLightNativeTest {
verify(target, never()).setHealth(anyDouble()); assertTrue(supports.isEmpty());
verify(sessions, never()).acknowledgeProgress(eq(targetActive), any(), any());
verify(execution, times(2)).finish();
verify(execution, never()).acceptedHeal(any(), any(), any(), anyDouble(), anyDouble(),
anyDouble(), anyDouble(), anyDouble());
}
@Test void configuredDefinitionAdminRestartAndFutureCastCoverEverySetting() throws Exception {
@@ -243,6 +305,19 @@ class MendingLightNativeTest {
fixture.players.put(caster.core.id, caster.player); fixture.players.put(ally.core.id, ally.player);
fixture.activate(caster); fixture.activate(ally); party(fixture, caster, ally); aim(caster, ally, 15, true);
use(fixture, caster); drain(fixture, 3); assertEquals(10, ally.player.getHealth());
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
var lock = channel.lock()) {
assertTrue(lock.isValid());
fixture.command.execute(sender.port, "fstickadmin",
new String[]{"config", "mending-light-base-healing", "10"});
var failed = fixture.tasks.poll(5, TimeUnit.SECONDS);
assertNotNull(failed); failed.run();
}
assertEquals("9", ((MendingLightSettings)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "mendingLightSettings"))
.value("mending-light-base-healing"));
assertTrue(sender.messages.getLast().contains("save failed"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "mending-light-base-healing", "-1"});
assertTrue(sender.messages.getLast().contains("invalid"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "mending-light-base-healing", "9", "extra"});
@@ -252,6 +327,15 @@ class MendingLightNativeTest {
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
assertEquals(new BigDecimal("9"), reloaded.find("mending_light").orElseThrow().parameters().get("base-healing"));
}
var config = events(data).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("mending-light-base-healing")).toList();
assertEquals(2, config.size());
assertEquals("8", config.getFirst().getAsJsonObject("data").get("after").getAsString());
assertEquals("8", config.getLast().getAsJsonObject("data").get("before").getAsString());
assertEquals("9", config.getLast().getAsJsonObject("data").get("after").getAsString());
assertEquals("console", config.getLast().getAsJsonObject("data").get("actor_kind").getAsString());
for (String invalid : List.of("mending-light-base-healing: -1\n", "mending-light-cooldown-seconds: -1\n",
"mending-light-range-blocks: 0\n")) {
var directory = data.resolve(Integer.toHexString(invalid.hashCode())); java.nio.file.Files.createDirectories(directory);
@@ -29,12 +29,55 @@ class MeteorNativeTest {
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void characterBookPurchaseEquippedGroundWarningAndNativeImpact()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,false);f.players.put(caster.core.id,caster.player);f.activate(caster);var placement=placement(caster,24,BlockFace.UP);var enemy=mock(LivingEntity.class);locate(enemy,caster.world,new UUID(0,2),2,65,0);doReturn(List.of(enemy)).when(caster.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());var damaged=new ArrayList<Double>();doAnswer(call->{double amount=call.getArgument(0);damaged.add(amount);var source=(DamageSource)call.getArgument(1);f.dispatcher.accept(new EntityDamageByEntityEvent(caster.player,enemy,EntityDamageEvent.DamageCause.MAGIC,source,amount));return null;}).when(enemy).damage(anyDouble(),any(DamageSource.class));
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,caster);String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("Meteor"));assertTrue(text.contains("INT26"));assertTrue(text.contains("WIS20"));assertTrue(text.contains("CON12"));assertTrue(text.contains("2-secondwarning"));PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","meteor"));drain(f,2);var viewer=new FantasyStickLifecycleNativeTest.Actor(data);viewer.core.inventory.setItemInOffHand(null);viewer.core.location.setWorld(caster.world);f.players.put(viewer.core.id,viewer.player);f.particlesCommand.execute(caster.player,"fantasyparticles",new String[]{"all","off"});PartyBookNativeTest.drain(f);caster.particleCalls.clear();viewer.particleCalls.clear();use(f,caster);drain(f,2);assertTrue(damaged.isEmpty());assertEquals(1L,f.delayedTicks.getLast());for(int i=0;i<39;i++)f.delayed.removeFirst().run();assertTrue(damaged.isEmpty(),"The 40-tick warning must remain escapable");f.delayed.removeFirst().run();assertEquals(List.of(22d),damaged);assertTrue(caster.particleCalls.isEmpty(),"The opted-out caster receives no Meteor warning or impact particles");assertTrue(viewer.particleCalls.stream().filter(call->call[0]==Particle.DUST&&call[2].equals(24)).count()>=40);assertTrue(viewer.particleCalls.stream().anyMatch(call->call[0]==Particle.EXPLOSION_EMITTER&&call[2].equals(2)));assertTrue(viewer.particleCalls.stream().anyMatch(call->call[0]==Particle.FLAME&&call[2].equals(48)));verify(caster.world,atLeastOnce()).playSound(any(Location.class),eq(Sound.ENTITY_GENERIC_EXPLODE),eq(1f),eq(.7f));verify(placement,never()).setType(any(Material.class));assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("meteor"));assertEquals(BigInteger.ZERO,storage(f).find(caster.core.record.id()).get().orElseThrow().unspentPoints());
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,false);f.players.put(caster.core.id,caster.player);f.activate(caster);var placement=placement(caster,24,BlockFace.UP);var enemy=mock(LivingEntity.class);locate(enemy,caster.world,new UUID(0,2),2,65,0);
var vetoed=mock(LivingEntity.class);locate(vetoed,caster.world,new UUID(0,3),3,65,0);
doReturn(List.of(enemy,vetoed)).when(caster.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());
class Veto implements org.bukkit.event.Listener{
@org.bukkit.event.EventHandler(priority=org.bukkit.event.EventPriority.HIGHEST)
public void cancel(EntityDamageEvent event){if(event.getEntity()==vetoed)event.setCancelled(true);}
}
f.dispatcher.register(new Veto(),f.plugin);
doAnswer(call->{var source=(DamageSource)call.getArgument(1);
f.dispatcher.accept(new EntityDamageByEntityEvent(caster.player,vetoed,EntityDamageEvent.DamageCause.MAGIC,
source,(double)call.getArgument(0)));return null;
}).when(vetoed).damage(anyDouble(),any(DamageSource.class));
var damaged=new ArrayList<Double>();doAnswer(call->{double amount=call.getArgument(0);damaged.add(amount);var source=(DamageSource)call.getArgument(1);f.dispatcher.accept(new EntityDamageByEntityEvent(caster.player,enemy,EntityDamageEvent.DamageCause.MAGIC,source,amount));return null;}).when(enemy).damage(anyDouble(),any(DamageSource.class));
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,caster);String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("Meteor"));assertTrue(text.contains("INT26"));assertTrue(text.contains("WIS20"));assertTrue(text.contains("CON12"));assertTrue(text.contains("2-secondwarning"));PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","meteor"));drain(f,2);var viewer=new FantasyStickLifecycleNativeTest.Actor(data);viewer.core.inventory.setItemInOffHand(null);viewer.core.location.setWorld(caster.world);f.players.put(viewer.core.id,viewer.player);PartyBookNativeTest.disableAllParticles(f,caster);caster.particleCalls.clear();viewer.particleCalls.clear();use(f,caster);drain(f,2);assertTrue(damaged.isEmpty());assertEquals(1L,f.delayedTicks.getLast());for(int i=0;i<39;i++)f.delayed.removeFirst().run();assertTrue(damaged.isEmpty(),"The 40-tick warning must remain escapable");f.delayed.removeFirst().run();assertEquals(List.of(22d),damaged);assertTrue(caster.particleCalls.isEmpty(),"The opted-out caster receives no Meteor warning or impact particles");assertTrue(viewer.particleCalls.stream().filter(call->call[0]==Particle.DUST&&call[2].equals(24)).count()>=40);assertTrue(viewer.particleCalls.stream().anyMatch(call->call[0]==Particle.EXPLOSION_EMITTER&&call[2].equals(2)));assertTrue(viewer.particleCalls.stream().anyMatch(call->call[0]==Particle.FLAME&&call[2].equals(48)));verify(caster.world,atLeastOnce()).playSound(any(Location.class),eq(Sound.ENTITY_GENERIC_EXPLODE),eq(1f),eq(.7f));verify(placement,never()).setType(any(Material.class));assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("meteor"));assertEquals(BigInteger.ZERO,storage(f).find(caster.core.record.id()).get().orElseThrow().unspentPoints());
}
var records=events(data);
for(var type:List.of("ability.learned","ability.assigned","ability.cast","ability.resolved","combat.damage"))
assertEquals(1,abilityEvents(records,type).size(),type);
assertEquals(1,records.stream().filter(event->event.get("type").getAsString().endsWith("combat.damage")).count(),
"The generic observer must not duplicate Meteor-owned native damage");
var impact=abilityEvents(records,"combat.damage").getFirst().getAsJsonObject("data");
assertEquals(22,impact.get("actual_amount").getAsDouble(),1e-9);
assertTrue(impact.has("target_location"));
var outcome=abilityEvents(records,"ability.resolved").getFirst().getAsJsonObject("data");
assertEquals("hit",outcome.get("outcome").getAsString());
assertEquals(1,outcome.get("accepted_count").getAsInt());
assertEquals(2,outcome.get("attempted_count").getAsInt(),"Cancelled native impact has no damage success");
assertTrue(outcome.getAsJsonObject("impact_location").has("world_id"));
}
@Test void missingOrSideGroundRefusesBeforeCooldown()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,true);f.players.put(caster.core.id,caster.player);f.activate(caster);use(f,caster);drain(f,2);assertFalse(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("meteor"));placement(caster,24,BlockFace.NORTH);use(f,caster);drain(f,2);assertFalse(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("meteor"));placement(caster,24,BlockFace.UP);use(f,caster);drain(f,2);assertTrue(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("meteor"));}}
private static List<com.google.gson.JsonObject> events(Path root)throws Exception{
var values=new ArrayList<com.google.gson.JsonObject>();
try(var files=java.nio.file.Files.walk(root.resolve("events"))){
for(var file:files.filter(path->path.toString().endsWith(".jsonl")).toList())
for(var line:java.nio.file.Files.readAllLines(file))
if(!line.isBlank())values.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
return values;
}
private static List<com.google.gson.JsonObject> abilityEvents(List<com.google.gson.JsonObject> events,String suffix){
return events.stream().filter(event->event.get("type").getAsString().endsWith(suffix)
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("meteor")).toList();
}
@Test void missingOrSideGroundRefusesBeforeCooldown()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,true);f.players.put(caster.core.id,caster.player);f.activate(caster);use(f,caster);drain(f,2);assertFalse(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("meteor"));placement(caster,24,BlockFace.NORTH);use(f,caster);drain(f,2);assertFalse(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("meteor"));placement(caster,24,BlockFace.UP);use(f,caster);drain(f,2);assertTrue(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("meteor"));}
assertEquals(1,abilityEvents(events(data),"ability.cast").size(),"Only real ground selection commits a cooldown");
assertTrue(abilityEvents(events(data),"combat.damage").isEmpty(),"Warning alone cannot claim a hit");
}
@Test void impactRechecksGroundPartyModeGeometryObstructionCancellationAndSourceLifecycle(){
var d=direct();var escaping=target(d.world,new UUID(0,1),2,65);var joined=mock(Player.class);locate(joined,d.world,new UUID(0,2),2,65,1);var ordinary=mock(Player.class);locate(ordinary,d.world,new UUID(0,3),2,65,2);var inactive=mock(Player.class);locate(inactive,d.world,new UUID(0,8),2,65,3);var inactiveSession=new EquipmentSessions.Active(inactive.getUniqueId(),UUID.randomUUID(),UUID.randomUUID());when(d.sessions.active(inactive.getUniqueId())).thenReturn(Optional.of(inactiveSession));when(d.sessions.current(inactiveSession)).thenReturn(false);var blocked=target(d.world,new UUID(0,4),4.5,65);var cancelled=target(d.world,new UUID(0,5),2,65);var boundary=mock(LivingEntity.class);locate(boundary,d.world,new UUID(0,6),5.5,69,.5);var outside=mock(LivingEntity.class);locate(outside,d.world,new UUID(0,7),5.51,69,.5);doReturn(List.of(escaping,joined,ordinary,inactive,blocked,cancelled,boundary,outside,d.caster)).when(d.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());var joinedActive=new EquipmentSessions.Active(joined.getUniqueId(),UUID.randomUUID(),UUID.randomUUID());when(d.sessions.active(joined.getUniqueId())).thenReturn(Optional.of(joinedActive));when(d.sessions.current(joinedActive)).thenReturn(true);var party=new PartyRoster.Party(UUID.randomUUID(),d.caster.getUniqueId(),List.of(d.caster.getUniqueId(),joined.getUniqueId()));when(d.parties.partyOf(d.caster.getUniqueId())).thenReturn(Optional.of(party));when(d.parties.partyOf(joined.getUniqueId())).thenReturn(Optional.of(party));doReturn(mock(RayTraceResult.class)).when(d.world).rayTraceBlocks(any(Location.class),any(org.bukkit.util.Vector.class),doubleThat(distance->distance>4&&distance<4.5),eq(FluidCollisionMode.NEVER),eq(true));when(escaping.getLocation()).thenReturn(new Location(d.world,6,65,.5));doAnswer(call->{var event=new EntityDamageEvent(cancelled,EntityDamageEvent.DamageCause.MAGIC,(DamageSource)call.getArgument(1),(double)call.getArgument(0));event.setCancelled(true);d.meteor.acceptedDamage(event);return null;}).when(cancelled).damage(anyDouble(),any(DamageSource.class));doAnswer(call->{d.meteor.acceptedDamage(new EntityDamageEvent(boundary,EntityDamageEvent.DamageCause.MAGIC,(DamageSource)call.getArgument(1),(double)call.getArgument(0)));return null;}).when(boundary).damage(anyDouble(),any(DamageSource.class));d.meteor.activate(d.execution);run(d.scheduled,40);verify(escaping,never()).damage(anyDouble(),any(DamageSource.class));verify(joined,never()).damage(anyDouble(),any(DamageSource.class));verify(ordinary,never()).damage(anyDouble(),any(DamageSource.class));verify(inactive,never()).damage(anyDouble(),any(DamageSource.class));verify(blocked,never()).damage(anyDouble(),any(DamageSource.class));verify(cancelled).damage(anyDouble(),any(DamageSource.class));verify(boundary).damage(anyDouble(),any(DamageSource.class));verify(outside,never()).damage(anyDouble(),any(DamageSource.class));verify(d.execution,times(1)).report(eq(AbilityReport.Type.DAMAGE),eq(boundary),any());
@@ -44,9 +87,27 @@ class MeteorNativeTest {
@Test void impactUsesExactCylinderDistanceUuidOrderAndSharedBoundsOnce(){var d=direct();var targets=new ArrayList<LivingEntity>();var order=new ArrayList<UUID>();for(int i=0;i<257;i++){var target=target(d.world,new UUID(0,i+1),i==256?0:Math.min(5,i/20d),65+(i%2==0?4:0));targets.add(target);doAnswer(call->{order.add(target.getUniqueId());d.meteor.acceptedDamage(new EntityDamageEvent(target,EntityDamageEvent.DamageCause.MAGIC,(DamageSource)call.getArgument(1),(double)call.getArgument(0)));return null;}).when(target).damage(anyDouble(),any(DamageSource.class));}doReturn(new ArrayList<Entity>(targets)).when(d.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());d.meteor.activate(d.execution);run(d.scheduled,40);assertEquals(64,order.size());assertFalse(order.contains(targets.get(256).getUniqueId()),"Candidates after the 256 inspection ceiling are ignored");assertEquals(new UUID(0,11),order.getFirst());verify(d.execution,times(64)).report(eq(AbilityReport.Type.DAMAGE),any(),any());assertEquals(BigInteger.valueOf(22),Meteor.damage(BigInteger.valueOf(26)));}
@Test void configuredDefinitionAdminRestartAndFutureImpactCoverEverySetting()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"meteor-base-damage: 16\nmeteor-cooldown-seconds: 3\nmeteor-target-range-blocks: 20\nmeteor-warning-seconds: 1\nmeteor-radius-blocks: 6\nmeteor-vertical-range-blocks: 3\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var registry=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");var definition=registry.find("meteor").orElseThrow();assertEquals(new BigDecimal("16"),definition.parameters().get("base-damage"));assertEquals(java.time.Duration.ofSeconds(3),definition.cooldown());var sender=new EquipmentAdminCommandNativeTest.Sender();sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","meteor-"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,"7"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;assertEquals(6,f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","meteor-"}).size());assertEquals(List.of("5","6"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,""}));for(String[] update:List.of(new String[]{MeteorSettings.BASE_DAMAGE,"18"},new String[]{MeteorSettings.COOLDOWN,"4"},new String[]{MeteorSettings.TARGET_RANGE,"22"},new String[]{MeteorSettings.WARNING,"1.5"},new String[]{MeteorSettings.RADIUS,"7"},new String[]{MeteorSettings.VERTICAL,"5"})){f.command.execute(sender.port,"fantasystick",new String[]{"config",update[0],update[1]});var response=f.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(response);response.run();}var configured=registry.find("meteor").orElseThrow();assertEquals(new BigDecimal("18"),configured.parameters().get("base-damage"));assertEquals(0,new BigDecimal("1.5").compareTo(configured.parameters().get("warning-seconds")));
@Test void configuredDefinitionAdminRestartAndFutureImpactCoverEverySetting()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"meteor-base-damage: 16\nmeteor-cooldown-seconds: 3\nmeteor-target-range-blocks: 20\nmeteor-warning-seconds: 1\nmeteor-radius-blocks: 6\nmeteor-vertical-range-blocks: 3\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var registry=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");var definition=registry.find("meteor").orElseThrow();assertEquals(new BigDecimal("16"),definition.parameters().get("base-damage"));assertEquals(java.time.Duration.ofSeconds(3),definition.cooldown());var sender=new EquipmentAdminCommandNativeTest.Sender();sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","meteor-"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,"7"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;assertEquals(6,f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","meteor-"}).size());assertEquals(List.of("5","6"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,""}));
f.command.execute(sender.port,"fstickadmin",new String[]{"config",MeteorSettings.RADIUS});
assertTrue(sender.messages.getLast().contains("=6"));
for(String[] update:List.of(new String[]{MeteorSettings.BASE_DAMAGE,"18"},new String[]{MeteorSettings.COOLDOWN,"4"},new String[]{MeteorSettings.TARGET_RANGE,"22"},new String[]{MeteorSettings.WARNING,"1.5"},new String[]{MeteorSettings.RADIUS,"7"},new String[]{MeteorSettings.VERTICAL,"5"})){f.command.execute(sender.port,"fantasystick",new String[]{"config",update[0],update[1]});var response=f.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(response);response.run();}var configured=registry.find("meteor").orElseThrow();assertEquals(new BigDecimal("18"),configured.parameters().get("base-damage"));assertEquals(0,new BigDecimal("1.5").compareTo(configured.parameters().get("warning-seconds")));
var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,true);f.players.put(caster.core.id,caster.player);f.activate(caster);placement(caster,22,BlockFace.UP);var enemy=mock(LivingEntity.class);locate(enemy,caster.world,UUID.randomUUID(),2,65,0);doReturn(List.of(enemy)).when(caster.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());var damaged=new ArrayList<Double>();doAnswer(call->{damaged.add(call.getArgument(0));return null;}).when(enemy).damage(anyDouble(),any(DamageSource.class));use(f,caster);drain(f,2);for(int i=0;i<30;i++)f.delayed.removeFirst().run();assertEquals(List.of(26d),damaged);
f.command.execute(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,"0"});assertTrue(sender.messages.getLast().contains("invalid"));f.command.execute(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,"7","extra"});assertTrue(sender.messages.getLast().contains("Usage"));f.plugin.setEnabled(false);f.plugin.setEnabled(true);var reloaded=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");assertEquals(new BigDecimal("18"),reloaded.find("meteor").orElseThrow().parameters().get("base-damage"));}}
try(var channel=java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
java.nio.file.StandardOpenOption.CREATE,java.nio.file.StandardOpenOption.WRITE);
var lock=channel.lock()){
assertTrue(lock.isValid());
f.command.execute(sender.port,"fstickadmin",new String[]{"config",MeteorSettings.RADIUS,"8"});
var failed=f.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(failed);failed.run();
}
assertEquals("7",((MeteorSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"meteorSettings")).value(MeteorSettings.RADIUS));
assertTrue(sender.messages.getLast().contains("save failed"));
f.command.execute(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,"0"});assertTrue(sender.messages.getLast().contains("invalid"));f.command.execute(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,"7","extra"});assertTrue(sender.messages.getLast().contains("Usage"));f.plugin.setEnabled(false);f.plugin.setEnabled(true);var reloaded=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");assertEquals(new BigDecimal("18"),reloaded.find("meteor").orElseThrow().parameters().get("base-damage"));}
var config=events(data).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().startsWith("meteor-")).toList();
assertEquals(7,config.size(),"Authorized read and six acknowledged saved updates only");
}
private record Direct(Meteor meteor,AbilityExecution execution,Player caster,World world,EquipmentSessions sessions,PartyRoster parties,ArrayDeque<Runnable> scheduled,java.util.concurrent.atomic.AtomicReference<Material> blockType){}
private static Direct direct(){var sessions=mock(EquipmentSessions.class);var parties=mock(PartyRoster.class);var caster=mock(Player.class);var world=mock(World.class);UUID id=UUID.randomUUID();when(caster.getUniqueId()).thenReturn(id);when(caster.getWorld()).thenReturn(world);when(caster.getLocation()).thenReturn(new Location(world,0,65,0));when(caster.getBoundingBox()).thenReturn(new BoundingBox(0,65,0,1,67,1));var active=new EquipmentSessions.Active(id,UUID.randomUUID(),UUID.randomUUID());when(sessions.active(id)).thenReturn(Optional.of(active));when(sessions.current(active)).thenReturn(true);var type=new java.util.concurrent.atomic.AtomicReference<>(Material.STONE);var block=mock(Block.class);when(block.getType()).thenAnswer(call->type.get());when(block.getWorld()).thenReturn(world);when(block.getLocation()).thenReturn(new Location(world,0,64,0));var ray=mock(RayTraceResult.class);when(ray.getHitBlock()).thenReturn(block);when(ray.getHitBlockFace()).thenReturn(BlockFace.UP);when(caster.getEyeLocation()).thenReturn(new Location(world,0,65.62,0,0,45));doReturn(ray).when(world).rayTraceBlocks(any(Location.class),any(org.bukkit.util.Vector.class),eq(24d),eq(FluidCollisionMode.NEVER),eq(true));var execution=mock(AbilityExecution.class);var input=mock(ControlInput.class);when(input.player()).thenReturn(caster);when(input.session()).thenReturn(active);when(execution.input()).thenReturn(input);when(execution.stillCurrent()).thenReturn(true);doAnswer(call->{((LivingEntity)call.getArgument(0)).damage((Double)call.getArgument(1),(org.bukkit.damage.DamageSource)call.getArgument(2));return null;}).when(execution).damage(any(LivingEntity.class),anyDouble(),any(org.bukkit.damage.DamageSource.class));var character=mock(CharacterRecord.class);var stats=new EnumMap<Stat,BigInteger>(Stat.class);for(var stat:Stat.values())stats.put(stat,BigInteger.ZERO);stats.put(Stat.INT,BigInteger.valueOf(26));when(character.stats()).thenReturn(stats);when(execution.character()).thenReturn(character);var scheduled=new ArrayDeque<Runnable>();doAnswer(call->{scheduled.addLast(call.getArgument(1));return mock(AbilityScheduler.Task.class);}).when(execution).later(any(),any());var meteor=new Meteor(Meteor.Settings.defaults(),sessions,parties);when(execution.definition()).thenReturn(meteor.definition());return new Direct(meteor,execution,caster,world,sessions,parties,scheduled,type);}
@@ -116,16 +116,18 @@ class OutOfCombatRecoveryNativeTest {
stale.clock.advance(Duration.ofSeconds(9)); stale.recovery.sweep(); assertEquals(1, stale.events.size());
}
@Test void eventSinkFailureCannotChangeAnAcceptedRecoveryPulse(){var f=new Direct();f.recovery.attachBonusHealing(ignored->BigInteger.valueOf(3));f.recovery.attachEvents(new games.dmg.fantasystick.events.RecoveryEventBridge(event->{throw new IllegalStateException("disk");},f.clock),ignored->BigInteger.valueOf(3));assertDoesNotThrow(()->{f.recovery.sweep();f.clock.advance(Duration.ofSeconds(10));f.recovery.sweep();f.runNext();});assertEquals(8,f.health.get());assertEquals(0,new BigDecimal("8").compareTo(f.record.get().health()));}
@Test void passiveBonusZeroCancellationAndFullHealthStayInTheBasePulseTransaction() {
var zero = new Direct(); zero.recovery.attachBonusHealing(ignored -> BigInteger.ZERO);
zero.recovery.sweep(); zero.clock.advance(Duration.ofSeconds(10)); zero.recovery.sweep(); zero.runNext();
assertEquals(5, zero.health.get(), "A zero passive bonus leaves the base pulse unchanged");
var cancelled = new Direct(); cancelled.recovery.attachBonusHealing(ignored -> BigInteger.valueOf(3)); cancelled.veto = true;
var cancelled = new Direct(); cancelled.recovery.attachBonusHealing(ignored -> BigInteger.valueOf(3));var audit=new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();cancelled.recovery.attachEvents(new games.dmg.fantasystick.events.RecoveryEventBridge(audit::add,cancelled.clock),ignored->BigInteger.valueOf(3));cancelled.veto = true;
var cancelledRevision = cancelled.record.get().revision();
cancelled.recovery.sweep(); cancelled.clock.advance(Duration.ofSeconds(10)); cancelled.recovery.sweep();
assertEquals(4, cancelled.events.getFirst().getAmount());
assertEquals(4, cancelled.health.get()); assertEquals(cancelledRevision, cancelled.record.get().revision());
assertEquals(4, cancelled.health.get()); assertEquals(cancelledRevision, cancelled.record.get().revision());assertEquals(1,audit.stream().filter(event->event.type().equals("action.observed")).count());assertEquals(1,audit.stream().filter(event->event.type().equals("action.resolved")&&event.data().get("outcome").equals("refused")&&event.data().get("reason").equals("native_event_cancelled")).count());assertEquals(0,audit.stream().filter(event->event.type().equals("combat.healed")).count());
var full = new Direct(); full.health.set(10d); full.record.set(full.saved(BigDecimal.TEN));
full.recovery.attachBonusHealing(ignored -> BigInteger.valueOf(3)); var fullRevision = full.record.get().revision();
@@ -241,7 +243,7 @@ class OutOfCombatRecoveryNativeTest {
base.unspentPoints(), BigDecimal.valueOf(4), base.learned(), base.loadout(), base.cooldowns(),
base.restrictedKills(), false, null, null));
active = new EquipmentSessions.Active(java.util.UUID.randomUUID(), java.util.UUID.randomUUID(), base.id());
when(player.getUniqueId()).thenReturn(active.player()); when(player.getHealth()).thenAnswer(call -> health.get());
var world=mock(org.bukkit.World.class);when(world.getUID()).thenReturn(java.util.UUID.randomUUID());when(world.getName()).thenReturn("world");when(player.getUniqueId()).thenReturn(active.player());when(player.getName()).thenReturn("Player");when(player.getLocation()).thenReturn(new org.bukkit.Location(world,0,64,0)); when(player.getHealth()).thenAnswer(call -> health.get());
doAnswer(call -> { health.set(call.getArgument(0)); return null; }).when(player).setHealth(anyDouble());
when(player.getAttribute(Attribute.MAX_HEALTH)).thenReturn(maximum); when(maximum.getValue()).thenReturn(10d);
when(sessions.active(active.player())).thenReturn(Optional.of(active));
@@ -50,6 +50,17 @@ class PartyBookNativeTest {
Runnable task;
while ((task = f.tasks.poll()) != null) task.run();
}
static void disableAllParticles(FantasyStickLifecycleNativeTest.Fixture f,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
int before=actor.messages.size();
f.particlesCommand.execute(actor.player,"fantasyparticles",new String[]{"all","off"});
for(int attempt=0;attempt<120;attempt++){
Runnable task=f.tasks.poll(25,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();
if(actor.messages.subList(Math.min(before,actor.messages.size()),actor.messages.size()).stream()
.anyMatch(message->message.contains("All Fantasy Stick particles")&&message.contains("off")))return;
}
fail("Particle preference acknowledgement was not published");
}
static net.minecraft.network.chat.ClickEvent.Custom button(FantasyStickLifecycleNativeTest.Actor a, String label) {
var book = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
@@ -43,21 +43,25 @@ class PartyRegistrarNativeTest {
@SuppressWarnings("unchecked")
@Test void adminPlacementReconcilesProtectedVillagerAndActiveCharacterCanPayToRegister()throws Exception{
UUID invitedPlayer,invitedCharacter,invitedSession;
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);
doReturn(true).when(actor.player).hasPermission("fantasystick.admin");UUID worldId=UUID.randomUUID();when(actor.world.getUID()).thenReturn(worldId);
int chunkX=actor.core.location.getBlockX()>>4,chunkZ=actor.core.location.getBlockZ()>>4;var chunk=mock(Chunk.class);when(chunk.getWorld()).thenReturn(actor.world);when(chunk.getX()).thenReturn(chunkX);when(chunk.getZ()).thenReturn(chunkZ);when(chunk.getEntities()).thenReturn(new org.bukkit.entity.Entity[0]);when(actor.world.getChunkAt(chunkX,chunkZ)).thenReturn(chunk);when(actor.world.getChunkAt(any(Location.class))).thenReturn(chunk);when(actor.world.isChunkLoaded(chunkX,chunkZ)).thenReturn(true);when(actor.world.getEntities()).thenReturn(List.of());
var villager=mock(Villager.class);when(villager.getLocation()).thenReturn(actor.core.location);var values=new HashMap<String,String>();var pdc=mock(PersistentDataContainer.class);when(villager.getPersistentDataContainer()).thenReturn(pdc);doAnswer(call->{values.put(((NamespacedKey)call.getArgument(0)).getKey(),call.getArgument(2));return null;}).when(pdc).set(any(NamespacedKey.class),eq(PersistentDataType.STRING),anyString());when(pdc.get(any(NamespacedKey.class),eq(PersistentDataType.STRING))).thenAnswer(call->values.get(((NamespacedKey)call.getArgument(0)).getKey()));
doAnswer(call->{((java.util.function.Consumer<Villager>)call.getArgument(2)).accept(villager);return villager;}).when(actor.world).spawn(any(Location.class),eq(Villager.class),any(java.util.function.Consumer.class));
int chunkX=actor.core.location.getBlockX()>>4,chunkZ=actor.core.location.getBlockZ()>>4;var chunk=mock(Chunk.class);when(chunk.getWorld()).thenReturn(actor.world);when(chunk.getX()).thenReturn(chunkX);when(chunk.getZ()).thenReturn(chunkZ);when(chunk.getEntities()).thenReturn(new org.bukkit.entity.Entity[0]);when(actor.world.getChunkAt(chunkX,chunkZ)).thenReturn(chunk);when(actor.world.getChunkAt(any(Location.class))).thenReturn(chunk);when(actor.world.isChunkLoaded(chunkX,chunkZ)).thenReturn(true);var worldEntities=new ArrayList<org.bukkit.entity.Entity>();when(actor.world.getEntities()).thenAnswer(call->List.copyOf(worldEntities));
var villager=mock(Villager.class);when(villager.getUniqueId()).thenReturn(UUID.randomUUID());when(villager.getLocation()).thenReturn(actor.core.location);var values=new HashMap<String,String>();var pdc=mock(PersistentDataContainer.class);when(villager.getPersistentDataContainer()).thenReturn(pdc);doAnswer(call->{values.put(((NamespacedKey)call.getArgument(0)).getKey(),call.getArgument(2));return null;}).when(pdc).set(any(NamespacedKey.class),eq(PersistentDataType.STRING),anyString());when(pdc.get(any(NamespacedKey.class),eq(PersistentDataType.STRING))).thenAnswer(call->values.get(((NamespacedKey)call.getArgument(0)).getKey()));
doAnswer(call->{((java.util.function.Consumer<Villager>)call.getArgument(2)).accept(villager);worldEntities.add(villager);return villager;}).when(actor.world).spawn(any(Location.class),eq(Villager.class),any(java.util.function.Consumer.class));
f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"party-registrar","place","Central"});var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();verify(actor.world).spawn(any(Location.class),eq(Villager.class),any(java.util.function.Consumer.class));verify(villager).setAI(false);verify(villager).setInvulnerable(true);
var directory=(games.dmg.fantasystick.party.PartyRegistrarDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrars");var registrar=directory.all().getFirst();assertEquals("Central",registrar.name());
var old=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(new games.dmg.fantasystick.persistence.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(),false,null,null,BigInteger.valueOf(20),true,old.rewardReceipts()),old.revision());f.activate(actor);var management=(games.dmg.fantasystick.party.PartyManagement)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyManagement");management.ready().toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);assertTrue(management.active(actor.player,registrar.id()).isPresent());f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"config","party-registration-price","7"});for(int attempt=0;attempt<10&&actor.messages.stream().noneMatch(message->message.contains("Saved party-registration-price=7"));attempt++){response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();}f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"config","party-leader-change-price","6"});for(int attempt=0;attempt<10&&actor.messages.stream().noneMatch(message->message.contains("Saved party-leader-change-price=6"));attempt++){response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();}var controller=(games.dmg.fantasystick.party.PartyRegistrarController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrarController");assertTrue(controller.tagged(villager));
var old=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(new games.dmg.fantasystick.persistence.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(),false,null,null,BigInteger.valueOf(20),true,old.rewardReceipts()),old.revision());f.activate(actor);var management=(games.dmg.fantasystick.party.PartyManagement)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyManagement");management.ready().toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);assertTrue(management.active(actor.player,registrar.id()).isPresent());f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"config","party-registration-price","7"});for(int attempt=0;attempt<10&&actor.messages.stream().noneMatch(message->message.contains("Saved party-registration-price=7"));attempt++){response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();}f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"config","party-leader-change-price","6"});for(int attempt=0;attempt<10&&actor.messages.stream().noneMatch(message->message.contains("Saved party-leader-change-price=6"));attempt++){response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();}f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"config","party-registration-price"});f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"config","party-leader-change-price"});var controller=(games.dmg.fantasystick.party.PartyRegistrarController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrarController");assertTrue(controller.tagged(villager));
int openMenus=actor.inventories.size(),openBooks=actor.books.size();var mainInteraction=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND);f.dispatcher.accept(mainInteraction);var offhandDuplicate=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.OFF_HAND);f.dispatcher.accept(offhandDuplicate);assertTrue(mainInteraction.isCancelled());assertTrue(offhandDuplicate.isCancelled());assertEquals(openMenus+1,actor.inventories.size());assertEquals(openBooks,actor.books.size());assertEquals(Material.WRITABLE_BOOK,actor.inventories.getLast().getItem(11).getType());var vetoedInteraction=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND);vetoedInteraction.setCancelled(true);f.dispatcher.accept(vetoedInteraction);assertEquals(openMenus+1,actor.inventories.size());assertEquals(openBooks,actor.books.size());
var menus=(games.dmg.fantasystick.party.PartyRegistrarMenus)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrarMenus");var root=actor.inventories.getLast();assertEquals(net.kyori.adventure.text.Component.text("Register a party — 7 coins"),root.getItem(11).getItemMeta().displayName());var openName=mock(org.bukkit.event.inventory.InventoryClickEvent.class);when(openName.getInventory()).thenReturn(root);when(openName.getWhoClicked()).thenReturn(actor.player);when(openName.getCurrentItem()).thenReturn(root.getItem(11));menus.click(openName);var anvil=actor.inventories.getLast();var submit=mock(org.bukkit.event.inventory.InventoryClickEvent.class);when(submit.getInventory()).thenReturn(anvil);when(submit.getWhoClicked()).thenReturn(actor.player);when(submit.getRawSlot()).thenReturn(2);var anvilView=mock(org.bukkit.inventory.view.AnvilView.class);when(anvilView.getRenameText()).thenReturn(" Cafe\u0301 Heroes ");when(submit.getView()).thenReturn(anvilView);menus.click(submit);var characters=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");characters.find(UUID.randomUUID()).join();var parties=(games.dmg.fantasystick.party.CharacterPartyDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterParties");parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertEquals("Café Heroes",parties.partyOf(old.id()).orElseThrow().displayName());assertEquals(BigInteger.valueOf(13),characters.find(old.id()).join().orElseThrow().coins());
var member=new FantasyStickLifecycleNativeTest.Actor(data);member.core.location.setWorld(actor.world);f.players.put(member.core.id,member.player);f.activate(member);var leaderRoot=actor.inventories.getLast();click(menus,actor.player,leaderRoot,10,false);var eligible=actor.inventories.getLast();click(menus,actor.player,eligible,0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();click(menus,actor.player,actor.inventories.getLast(),22,false);click(menus,actor.player,actor.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(parties.invitationsFor(member.core.record.id()).isEmpty());click(menus,actor.player,actor.inventories.getLast(),10,false);click(menus,actor.player,actor.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(member.player,villager,EquipmentSlot.HAND));var memberRoot=member.inventories.getLast();click(menus,member.player,memberRoot,15,false);var invitationMenu=member.inventories.getLast();click(menus,member.player,invitationMenu,0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(parties.partyOf(member.core.record.id()).isPresent());controller.interact(new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND));click(menus,actor.player,actor.inventories.getLast(),13,false);assertEquals(List.of(net.kyori.adventure.text.Component.text("Left click: Kick (free)"),net.kyori.adventure.text.Component.text("Right click: Transfer leadership — 6 coins")),actor.inventories.getLast().getItem(0).getItemMeta().lore());click(menus,actor.player,actor.inventories.getLast(),0,true);characters.find(UUID.randomUUID()).join();parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertEquals(member.core.record.id(),parties.partyOf(old.id()).orElseThrow().leader());assertEquals(BigInteger.valueOf(7),characters.find(old.id()).join().orElseThrow().coins());
var member=new FantasyStickLifecycleNativeTest.Actor(data);member.core.location.setWorld(actor.world);f.players.put(member.core.id,member.player);f.activate(member);invitedPlayer=member.core.id;invitedCharacter=member.core.record.id();invitedSession=((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(member.core.id).orElseThrow().session();var leaderRoot=actor.inventories.getLast();click(menus,actor.player,leaderRoot,10,false);var eligible=actor.inventories.getLast();click(menus,actor.player,eligible,0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();click(menus,actor.player,actor.inventories.getLast(),22,false);click(menus,actor.player,actor.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(parties.invitationsFor(member.core.record.id()).isEmpty());click(menus,actor.player,actor.inventories.getLast(),10,false);click(menus,actor.player,actor.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(member.player,villager,EquipmentSlot.HAND));var memberRoot=member.inventories.getLast();click(menus,member.player,memberRoot,15,false);var invitationMenu=member.inventories.getLast();click(menus,member.player,invitationMenu,0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(parties.partyOf(member.core.record.id()).isPresent());controller.interact(new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND));click(menus,actor.player,actor.inventories.getLast(),13,false);assertEquals(List.of(net.kyori.adventure.text.Component.text("Left click: Kick (free)"),net.kyori.adventure.text.Component.text("Right click: Transfer leadership — 6 coins")),actor.inventories.getLast().getItem(0).getItemMeta().lore());click(menus,actor.player,actor.inventories.getLast(),0,true);characters.find(UUID.randomUUID()).join();parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertEquals(member.core.record.id(),parties.partyOf(old.id()).orElseThrow().leader());assertEquals(BigInteger.valueOf(7),characters.find(old.id()).join().orElseThrow().coins());
controller.interact(new PlayerInteractEntityEvent(member.player,villager,EquipmentSlot.HAND));click(menus,member.player,member.inventories.getLast(),13,false);click(menus,member.player,member.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(parties.partyOf(old.id()).isEmpty());controller.interact(new PlayerInteractEntityEvent(member.player,villager,EquipmentSlot.HAND));click(menus,member.player,member.inventories.getLast(),10,false);click(menus,member.player,member.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND));click(menus,actor.player,actor.inventories.getLast(),15,false);click(menus,actor.player,actor.inventories.getLast(),0,true);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(member.player,villager,EquipmentSlot.HAND));click(menus,member.player,member.inventories.getLast(),10,false);click(menus,member.player,member.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND));click(menus,actor.player,actor.inventories.getLast(),15,false);click(menus,actor.player,actor.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND));click(menus,actor.player,actor.inventories.getLast(),13,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(member.player,villager,EquipmentSlot.HAND));click(menus,member.player,member.inventories.getLast(),16,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(parties.partyOf(member.core.record.id()).isEmpty());
f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"party-registrar","list"});assertTrue(actor.messages.stream().anyMatch(message->message.contains(registrar.id().toString())&&message.contains("Central")));when(chunk.getEntities()).thenReturn(new org.bukkit.entity.Entity[]{villager});controller.reconcile(chunk);verify(actor.world,times(1)).spawn(any(Location.class),eq(Villager.class),any(java.util.function.Consumer.class));var damage=new org.bukkit.event.entity.EntityDamageEvent(villager,org.bukkit.event.entity.EntityDamageEvent.DamageCause.FIRE,1);controller.damage(damage);assertTrue(damage.isCancelled());var combust=new org.bukkit.event.entity.EntityCombustEvent(villager,5);controller.combust(combust);assertTrue(combust.isCancelled());var interaction=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND);controller.interact(interaction);assertTrue(interaction.isCancelled());var leash=new org.bukkit.event.entity.PlayerLeashEntityEvent(villager,actor.player,actor.player,EquipmentSlot.HAND);controller.leash(leash);assertTrue(leash.isCancelled());
when(actor.world.getEntities()).thenReturn(List.of(villager));f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"party-registrar","remove",registrar.id().toString()});directory.remove(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(directory.find(registrar.id()).isEmpty());verify(villager).remove();
PartyBookNativeTest.drain(f);worldEntities.clear();worldEntities.add(villager);f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"party-registrar","remove",registrar.id().toString()});directory.remove(UUID.randomUUID()).join();PartyBookNativeTest.drain(f);assertTrue(directory.find(registrar.id()).isEmpty());verify(villager).remove();
}
var records=events(data);var types=records.stream().map(event->event.get("type").getAsString()).toList();for(var type:List.of("party_registrar.placed","party_registrar.removed","party.created","party.invitation_created","party.invitation_cancelled","party.invitation_declined","party.joined","party.left","party.member_kicked","party.leadership_transferred","party.disbanded"))assertTrue(types.stream().anyMatch(value->value.endsWith(type)),()->type+" absent from "+types);assertEquals(4,types.stream().filter(value->value.endsWith("party.invitation_created")).count());assertEquals(2,records.stream().filter(event->event.get("type").getAsString().endsWith("config.changed")&&event.getAsJsonObject("data").get("key").getAsString().startsWith("party-")).count());assertEquals(2,records.stream().filter(event->event.get("type").getAsString().endsWith("config.read")&&event.getAsJsonObject("data").get("key").getAsString().startsWith("party-")).count());var created=records.stream().filter(event->event.get("type").getAsString().endsWith("party.created")).findFirst().orElseThrow().getAsJsonObject("data");assertEquals("7",created.get("charged_coins").getAsString());assertEquals("requested",created.get("reason").getAsString());assertTrue(created.has("party_id"));assertTrue(created.has("registrar_id"));assertTrue(created.has("registrar_location"));assertEquals("not_applicable",created.get("popup_delivery").getAsString());var invited=records.stream().filter(event->event.get("type").getAsString().endsWith("party.invitation_created")).findFirst().orElseThrow().getAsJsonObject("data");assertEquals(invitedPlayer.toString(),invited.get("target_player_id").getAsString());assertEquals(invitedCharacter.toString(),invited.get("target_character_id").getAsString());assertEquals(invitedSession.toString(),invited.get("target_session_id").getAsString());var placed=records.stream().filter(event->event.get("type").getAsString().endsWith("party_registrar.placed")).findFirst().orElseThrow().getAsJsonObject("data");assertEquals("loaded",placed.get("entity_status").getAsString());assertTrue(placed.has("entity_id"));
}
private static java.util.List<com.google.gson.JsonObject> events(Path data)throws Exception{var records=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}return records;}
private static void click(games.dmg.fantasystick.party.PartyRegistrarMenus menus,org.bukkit.entity.Player player,org.bukkit.inventory.Inventory inventory,int slot,boolean right){var event=mock(org.bukkit.event.inventory.InventoryClickEvent.class);when(event.getInventory()).thenReturn(inventory);when(event.getWhoClicked()).thenReturn(player);when(event.getCurrentItem()).thenReturn(inventory.getItem(slot));when(event.isRightClick()).thenReturn(right);menus.click(event);}
}
@@ -0,0 +1,68 @@
package games.dmg.fantasystick;
import games.dmg.fantasystick.shop.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.persistence.RareSettings;
import games.dmg.fantasystick.persistence.YamlRareInventoryRepository;
import games.dmg.fantasystick.session.DownedController;
import games.dmg.fantasystick.session.DownedDirectory;
import games.dmg.fantasystick.session.DownedEpisode;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.nio.file.Path;
import java.time.*;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.bukkit.attribute.Attribute;
import org.bukkit.attribute.AttributeInstance;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class PhoenixDownServiceTest {
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@TempDir Path data;
private final Clock clock=Clock.fixed(Instant.parse("2026-09-25T00:00:00Z"),ZoneOffset.UTC);
@Test void explicitCurrentEpisodeSelectionReservesClaimsAndCommitsExactlyOne()throws Exception{
UUID character=UUID.randomUUID(),playerId=UUID.randomUUID(),session=UUID.randomUUID(),world=UUID.randomUUID();
try(var inventory=new RareInventoryDirectory(new YamlRareInventoryRepository(data));var downed=new DownedDirectory(data,clock);var settings=new RareSettings(data,Map.of())){
inventory.add(character,RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();var episode=downed.begin(character,playerId,session,world,1,2,3,0,0,world,4,5,6,0,0,"Wren","Account",BigInteger.TEN,Duration.ofMinutes(5),new DownedEpisode.Attribution(null,"minecraft:fall")).join();
var sessions=mock(EquipmentSessions.class);when(sessions.active(playerId)).thenReturn(Optional.of(new EquipmentSessions.Active(playerId,session,character)));var player=player(playerId,1,10);var captured=new AtomicReference<DownedEpisode.Decision>();
PhoenixDownService.Rescue rescue=(target,protection,decision,result)->{captured.set(decision);assertEquals(Duration.ofSeconds(5),protection);assertEquals(DownedDirectory.ClaimStatus.APPLIED,downed.claim(episode.id(),DownedEpisode.Outcome.REVIVED,decision.actionId(),decision,protection,List.of()).join().status());result.accept(DownedController.RescueStatus.APPLIED);};
var service=new PhoenixDownService(inventory,settings,downed,sessions,clock,Runnable::run,rescue,failureSink("unexpected failure"));var outcome=new CompletableFuture<PhoenixDownService.Outcome>();service.use(player,episode.id(),outcome::complete);assertEquals(PhoenixDownService.Status.APPLIED,outcome.get(5,TimeUnit.SECONDS).status());assertEquals(BigInteger.ZERO,service.quantity(character));var decision=captured.get();assertEquals("phoenix_down",decision.source());assertEquals("downed_menu",decision.phoenix().entryPoint());assertEquals(BigInteger.ONE,decision.phoenix().quantityBefore());assertEquals(BigInteger.ZERO,decision.phoenix().quantityAfter());assertEquals("1.0",decision.phoenix().healthBefore().toPlainString());assertEquals("10.0",decision.phoenix().healthAfter().toPlainString());
}
}
@Test void startupRefundsUnclaimedReservationAndCommitsMatchingResolvedProof()throws Exception{
UUID character=UUID.randomUUID(),player=UUID.randomUUID(),session=UUID.randomUUID(),world=UUID.randomUUID(),refund=UUID.randomUUID(),commit=UUID.randomUUID();
try(var inventory=new RareInventoryDirectory(new YamlRareInventoryRepository(data));var downed=new DownedDirectory(data,clock);var settings=new RareSettings(data,Map.of())){
inventory.add(character,RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();inventory.add(character,RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();var episode=downed.begin(character,player,session,world,0,0,0,0,0,Duration.ofMinutes(5),new DownedEpisode.Attribution(null,"minecraft:fall")).join();inventory.reserveUse(character,RareProduct.PHOENIX_DOWN,refund,BigInteger.TWO).join();var service=new PhoenixDownService(inventory,settings,downed,mock(EquipmentSessions.class),clock,Runnable::run,(a,b,c,d)->org.junit.jupiter.api.Assertions.fail(),failureSink("unexpected failure"));service.install();await(()->inventory.unresolvedUses().isEmpty());assertEquals(BigInteger.TWO,inventory.quantity(character,RareProduct.PHOENIX_DOWN));
inventory.reserveUse(character,RareProduct.PHOENIX_DOWN,commit,BigInteger.TWO).join();var proof=new DownedEpisode.Phoenix(BigInteger.TWO,BigInteger.ONE,java.math.BigDecimal.ONE,java.math.BigDecimal.TEN,java.math.BigDecimal.TEN,"downed_menu");var decision=new DownedEpisode.Decision("phoenix_down",player,"Account",character,"Wren",session,commit,clock.instant(),null,null,null,proof);downed.claim(episode.id(),DownedEpisode.Outcome.REVIVED,commit,decision,Duration.ofSeconds(5),List.of()).join();service.install();await(()->inventory.unresolvedUses().isEmpty());assertEquals(BigInteger.ONE,inventory.quantity(character,RareProduct.PHOENIX_DOWN));
}
}
@Test void finalDeathWinningAfterReservationRefundsThePhoenixDown()throws Exception{
UUID character=UUID.randomUUID(),playerId=UUID.randomUUID(),session=UUID.randomUUID(),world=UUID.randomUUID();
try(var inventory=new RareInventoryDirectory(new YamlRareInventoryRepository(data));var downed=new DownedDirectory(data,clock);var settings=new RareSettings(data,Map.of())){
inventory.add(character,RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();var episode=downed.begin(character,playerId,session,world,0,0,0,0,0,world,0,0,0,0,0,"Wren","Account",BigInteger.ONE,Duration.ofMinutes(5),new DownedEpisode.Attribution(null,"minecraft:fall")).join();var sessions=mock(EquipmentSessions.class);when(sessions.active(playerId)).thenReturn(Optional.of(new EquipmentSessions.Active(playerId,session,character)));PhoenixDownService.Rescue losing=(target,protection,decision,result)->{downed.claim(episode.id(),DownedEpisode.Outcome.FINAL_DEATH,UUID.randomUUID(),Duration.ZERO).join();result.accept(DownedController.RescueStatus.STALE);};var service=new PhoenixDownService(inventory,settings,downed,sessions,clock,Runnable::run,losing,failureSink("unexpected failure"));var outcome=new CompletableFuture<PhoenixDownService.Outcome>();service.use(player(playerId,1,10),episode.id(),outcome::complete);assertEquals(PhoenixDownService.Status.STALE,outcome.get(5,TimeUnit.SECONDS).status());assertEquals(BigInteger.ONE,inventory.quantity(character,RareProduct.PHOENIX_DOWN));assertEquals(DownedEpisode.Outcome.FINAL_DEATH,downed.find(character).join().orElseThrow().outcome());
}
}
@Test void missingItemOrForeignStaleEpisodeCannotInvokeRescue()throws Exception{
UUID character=UUID.randomUUID(),owner=UUID.randomUUID(),foreign=UUID.randomUUID(),session=UUID.randomUUID(),world=UUID.randomUUID();
try(var inventory=new RareInventoryDirectory(new YamlRareInventoryRepository(data));var downed=new DownedDirectory(data,clock);var settings=new RareSettings(data,Map.of())){
var episode=downed.begin(character,owner,session,world,0,0,0,0,0,Duration.ofMinutes(5),new DownedEpisode.Attribution(null,"minecraft:fall")).join();var sessions=mock(EquipmentSessions.class);when(sessions.active(owner)).thenReturn(Optional.of(new EquipmentSessions.Active(owner,session,character)));var invoked=new java.util.concurrent.atomic.AtomicBoolean();var service=new PhoenixDownService(inventory,settings,downed,sessions,clock,Runnable::run,(a,b,c,d)->invoked.set(true),failureSink("unexpected failure"));var none=new AtomicReference<PhoenixDownService.Outcome>();service.use(player(owner,1,10),episode.id(),none::set);assertEquals(PhoenixDownService.Status.NOT_AVAILABLE,none.get().status());var stale=new AtomicReference<PhoenixDownService.Outcome>();service.use(player(foreign,1,10),episode.id(),stale::set);assertEquals(PhoenixDownService.Status.STALE,stale.get().status());assertFalse(invoked.get());
}
}
private static Player player(UUID id,double health,double maximum){var player=mock(Player.class);when(player.getUniqueId()).thenReturn(id);when(player.isOnline()).thenReturn(true);when(player.getHealth()).thenReturn(health);var attribute=mock(AttributeInstance.class);when(attribute.getValue()).thenReturn(maximum);when(player.getAttribute(Attribute.MAX_HEALTH)).thenReturn(attribute);return player;}
private static java.util.function.Consumer<Throwable> failureSink(String message){return failure->org.junit.jupiter.api.Assertions.fail(message,failure);}
private static void await(java.util.function.BooleanSupplier condition)throws Exception{for(int i=0;i<100&&!condition.getAsBoolean();i++)Thread.sleep(10);assertTrue(condition.getAsBoolean());}
}
@@ -73,6 +73,18 @@ class PurifyNativeTest {
learnAndAssign(fixture, actor); use(fixture, actor);
assertTrue(actor.messages.stream().anyMatch(message -> message.contains("ready in")));
}
var recorded = events(data);
for (var type : List.of("ability.learned", "ability.assigned", "ability.cast", "ability.resolved", "combat.cleansed"))
assertEquals(type.equals("ability.learned") || type.equals("ability.assigned") ? 2 : 1,
recorded.stream().filter(event -> event.get("type").getAsString().endsWith(type)
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("purify")).count(), type);
var cleansed = recorded.stream().filter(event -> event.get("type").getAsString().endsWith("combat.cleansed")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("purify")).findFirst().orElseThrow();
var names = new java.util.HashSet<String>();
cleansed.getAsJsonObject("data").getAsJsonArray("removed_effects").forEach(value -> names.add(value.getAsString()));
assertEquals(java.util.Set.of("POISON", "WITHER", "SLOWNESS", "WEAKNESS"), names);
assertEquals("4", cleansed.getAsJsonObject("data").get("actual_count").getAsString());
}
@Test void aimedAllyActualCleanseCreatesPersistedSupportShareThatFollowsSourceCharacter() throws Exception {
@@ -102,6 +114,23 @@ class PurifyNativeTest {
assertEquals(BigInteger.valueOf(5), storage(fixture).find(ally.core.record.id()).get().orElseThrow().experience());
assertTrue(holder.messages.stream().anyMatch(message -> message.contains("5") && message.contains("XP")));
}
var allyCleanse = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.cleansed")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("purify")).toList();
assertEquals(1, allyCleanse.size());
assertEquals("POISON", allyCleanse.getFirst().getAsJsonObject("data")
.getAsJsonArray("removed_effects").get(0).getAsString());
assertFalse(allyCleanse.getFirst().getAsJsonObject("data").get("self_target").getAsBoolean());
}
private static List<com.google.gson.JsonObject> events(Path root) throws Exception {
var values = new ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(root.resolve("events"))) {
for (var file : files.filter(path -> path.toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(file))
if (!line.isBlank()) values.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
return values;
}
@Test void normalSelfOrdinaryNonPartyOutOfRangeObstructedAndNoOpTargetsRefuseBeforeCooldown() throws Exception {
@@ -125,6 +154,9 @@ class PurifyNativeTest {
assertFalse(storage(fixture).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("purify"),
"route " + route + " must refuse before cooldown");
}
assertEquals(0, events(directory).stream().filter(event -> event.get("type").getAsString().endsWith("combat.cleansed")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("purify")).count());
}
}
@@ -145,6 +177,14 @@ class PurifyNativeTest {
assertTrue(effects.containsKey(PotionEffectType.POISON));
assertNotNull(storage(fixture).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("purify"));
}
var records = events(data);
assertEquals(0, records.stream().filter(event -> event.get("type").getAsString().endsWith("combat.cleansed")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("purify")).count());
assertEquals(1, records.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("purify")
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("cancelled")).count());
}
@Test void selfCleanseNeverEmitsSupportEvidence() {
@@ -196,6 +236,19 @@ class PurifyNativeTest {
effects(fixture, ally.player).put(PotionEffectType.SLOWNESS, instance(PotionEffectType.SLOWNESS, 200, 0));
doReturn(false).when(caster.player).isSneaking(); use(fixture, caster); drain(fixture, 2);
assertNull(ally.player.getPotionEffect(PotionEffectType.SLOWNESS));
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
var lock = channel.lock()) {
assertTrue(lock.isValid());
fixture.command.execute(sender.port, "fstickadmin",
new String[]{"config", "purify-range-blocks", "21"});
var failed = fixture.tasks.poll(5, TimeUnit.SECONDS);
assertNotNull(failed); failed.run();
}
assertEquals("20", ((games.dmg.fantasystick.persistence.PurifySettings)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "purifySettings"))
.value("purify-range-blocks"));
assertTrue(sender.messages.getLast().contains("save failed"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "purify-range-blocks", "0"});
assertTrue(sender.messages.getLast().contains("invalid"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "purify-range-blocks", "20", "extra"});
@@ -205,6 +258,14 @@ class PurifyNativeTest {
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
assertEquals(0, new BigDecimal("20").compareTo(reloaded.find("purify").orElseThrow().parameters().get("range")));
}
var config = events(data).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("purify-range-blocks")).toList();
assertEquals(2, config.size());
assertEquals("16", config.getFirst().getAsJsonObject("data").get("after").getAsString());
assertEquals("16", config.getLast().getAsJsonObject("data").get("before").getAsString());
assertEquals("20", config.getLast().getAsJsonObject("data").get("after").getAsString());
for (String invalid : List.of("purify-cooldown-seconds: -1\n", "purify-range-blocks: 0\n")) {
var directory = data.resolve(Integer.toHexString(invalid.hashCode())); java.nio.file.Files.createDirectories(directory);
java.nio.file.Files.writeString(directory.resolve("config.yml"), invalid);
@@ -94,6 +94,12 @@ class QuickerRegenerationNativeTest {
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
assertEquals(refunded.passives(), storage(f).find(refunded.id()).get().orElseThrow().passives());
}
var events=events(data);var learnedEvents=events.stream().filter(event->event.get("type").getAsString().endsWith("passive.learned")).toList();var unlearnedEvents=events.stream().filter(event->event.get("type").getAsString().endsWith("passive.unlearned")).toList();assertEquals(1,learnedEvents.size());assertEquals(1,unlearnedEvents.size());var learned=learnedEvents.getFirst().getAsJsonObject("data");assertEquals("quicker_regeneration_i",learned.get("passive_id").getAsString());assertEquals("40",learned.get("points_before").getAsString());assertEquals("39",learned.get("points_after").getAsString());assertEquals(0,learned.get("passive_slot").getAsInt());assertEquals("12",learned.getAsJsonObject("stat_prerequisites").get("CON").getAsString());var unlearned=unlearnedEvents.getFirst().getAsJsonObject("data");assertEquals("39",unlearned.get("points_before").getAsString());assertEquals("40",unlearned.get("points_after").getAsString());assertEquals("passive_state_acknowledged",unlearned.get("owned_cleanup").getAsString());var healed=events.stream().filter(event->event.get("type").getAsString().endsWith("combat.healed")).map(event->event.getAsJsonObject("data")).filter(value->value.has("recovery_kind")&&value.get("recovery_kind").getAsString().equals("out_of_combat")).toList();assertEquals(1,healed.size());assertEquals("4.0",healed.getFirst().get("accepted_total").getAsString());assertEquals("3.0",healed.getFirst().getAsJsonObject("passive_contributions").get("quicker_regeneration_i").getAsString());assertEquals("acknowledged",healed.getFirst().get("native_publication").getAsString());
}
@Test void failedPassiveSaveResolvesWithoutInventingLearnedStateOrSuccess() throws Exception {
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);makeEligible(data,a,BigDecimal.valueOf(4),List.of("","",""));f.players.put(a.core.id,a.player);f.activate(a);PartyBookNativeTest.open(f,a);var pages=nativePages(a);var qri=pages.get(changePageTo(pages.get(first(pages,"Passive index")),pages,"Quicker Regeneration I").page()-1);var learn=custom(List.of(qri),"fantasystick:passive_unlock");try(var channel=java.nio.channels.FileChannel.open(data.resolve("characters/.records.lock"),java.nio.file.StandardOpenOption.WRITE);var lock=channel.lock()){assertTrue(lock.isValid());PartyBookNativeTest.click(f,a,learn);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}assertFalse(storage(f).find(a.core.record.id()).get().orElseThrow().passives().contains("quicker_regeneration_i"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);f.plugin.setEnabled(false);f.awaitWorkers();}
var events=events(data);assertEquals(0,events.stream().filter(event->event.get("type").getAsString().endsWith("passive.learned")).count());assertTrue(events.stream().filter(event->event.get("type").getAsString().endsWith("action.resolved")&&event.getAsJsonObject("data").has("action_kind")&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("passive_learn")).map(event->event.getAsJsonObject("data")).anyMatch(value->value.get("outcome").getAsString().equals("failed")&&value.get("reason").getAsString().equals("save_or_native_publication_uncertain")),events::toString);
}
@Test void multiPagePassiveIndexAndWrappedDetailsReturnToTheirExactContainingPage() throws Exception {
@@ -176,9 +182,13 @@ class QuickerRegenerationNativeTest {
var settings = (games.dmg.fantasystick.persistence.QuickerRegenerationSettings)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "quickerRegenerationSettings");
assertEquals("4", settings.value());
try(var channel=java.nio.channels.FileChannel.open(data.resolve(".config.lock"),java.nio.file.StandardOpenOption.CREATE,java.nio.file.StandardOpenOption.WRITE);var lock=channel.lock()){assertTrue(lock.isValid());f.command.execute(sender.port,"fantasystick",new String[]{"config","quicker-regeneration-i-bonus-healing","5"});var failed=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(failed);failed.run();}assertEquals("4",settings.value());assertTrue(sender.messages.getLast().contains("save failed"));
}
var events=events(data);var reads=events.stream().filter(event->event.get("type").getAsString().endsWith("config.read")&&event.getAsJsonObject("data").get("key").getAsString().equals("quicker-regeneration-i-bonus-healing")).toList();var changes=events.stream().filter(event->event.get("type").getAsString().endsWith("config.changed")&&event.getAsJsonObject("data").get("key").getAsString().equals("quicker-regeneration-i-bonus-healing")).toList();assertEquals(1,reads.size());assertEquals("1",reads.getFirst().getAsJsonObject("data").get("after").getAsString());assertEquals(1,changes.size());assertEquals("1",changes.getFirst().getAsJsonObject("data").get("before").getAsString());assertEquals("4",changes.getFirst().getAsJsonObject("data").get("after").getAsString());assertEquals("future_recovery_pulses",changes.getFirst().getAsJsonObject("data").get("application").getAsString());
}
private static java.util.List<com.google.gson.JsonObject> events(Path data)throws Exception{var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}return events;}
private static void makeEligible(Path data, FantasyStickLifecycleNativeTest.Actor actor, BigDecimal health,
List<String> passives) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
@@ -70,6 +70,16 @@ class RallyingCryNativeTest {
var hit = new EntityDamageByEntityEvent(ally.player, enemy, EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 10);
fixture.dispatcher.accept(hit); assertEquals(12, hit.getDamage(), 1.0e-9);
assertEquals(1, caster.messages.stream().filter("Rallying Cry added 2 damage against Zombie."::equals).count());
class Veto implements org.bukkit.event.Listener {
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST)
public void veto(EntityDamageByEntityEvent event) {
if (event.getEntity() == enemy) event.setCancelled(true);
}
}
fixture.dispatcher.register(new Veto(), fixture.plugin);
var cancelled = new EntityDamageByEntityEvent(ally.player, enemy,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 10);
fixture.dispatcher.accept(cancelled); assertTrue(cancelled.isCancelled());
fixture.dispatcher.accept(new EntityDeathEvent(enemy, source, new ArrayList<>(), 0)); drain(fixture, 4);
assertEquals(BigInteger.valueOf(5), storage(fixture).find(caster.core.record.id()).get().orElseThrow().experience());
assertEquals(BigInteger.valueOf(5), storage(fixture).find(ally.core.record.id()).get().orElseThrow().experience());
@@ -85,6 +95,31 @@ class RallyingCryNativeTest {
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "rallying_cry")); drain(fixture, 2);
use(fixture, caster); assertTrue(caster.messages.stream().anyMatch(message -> message.contains("ready in")));
}
var records = events(data);
for (var type : List.of("ability.learned", "ability.assigned", "ability.cast", "ability.resolved",
"combat.boosted"))
assertEquals(type.equals("ability.learned") || type.equals("ability.assigned") ? 2 : 1,
records.stream().filter(event -> event.get("type").getAsString().endsWith(type)
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("rallying_cry")).count(), type);
for (var type : List.of("effect.applied", "effect.ended"))
assertEquals(3, records.stream().filter(event -> event.get("type").getAsString().endsWith(type)
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("rallying_cry")).count(), type);
var boosted = records.stream().filter(event -> event.get("type").getAsString().endsWith("combat.boosted")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("rallying_cry")).findFirst().orElseThrow();
assertEquals("2", boosted.getAsJsonObject("data").get("actual_amount").getAsString());
assertEquals("12", boosted.getAsJsonObject("data").get("final_amount").getAsString());
}
private static List<com.google.gson.JsonObject> events(Path root) throws Exception {
var values = new ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(root.resolve("events"))) {
for (var file : files.filter(path -> path.toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(file))
if (!line.isBlank()) values.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
return values;
}
@Test void dynamicSphereMembershipSessionAndSourceValidityRecheckOwnedBenefits() throws Exception {
@@ -199,6 +234,19 @@ class RallyingCryNativeTest {
var futureTarget = mock(LivingEntity.class); when(futureTarget.getUniqueId()).thenReturn(UUID.randomUUID());
var future = hit(ally.player, futureTarget, 10); fixture.dispatcher.accept(future);
assertEquals(14, future.getDamage(), 1.0e-9);
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
var lock = channel.lock()) {
assertTrue(lock.isValid());
fixture.command.execute(sender.port, "fstickadmin",
new String[]{"config", "rallying-cry-radius-blocks", "13"});
var failed = fixture.tasks.poll(5, TimeUnit.SECONDS);
assertNotNull(failed); failed.run();
}
assertEquals("12", ((games.dmg.fantasystick.persistence.RallyingCrySettings)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "rallyingCrySettings"))
.value("rallying-cry-radius-blocks"));
assertTrue(sender.messages.getLast().contains("save failed"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "rallying-cry-duration-seconds", "0"});
assertTrue(sender.messages.getLast().contains("invalid"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "rallying-cry-radius-blocks", "12", "extra"});
@@ -211,6 +259,17 @@ class RallyingCryNativeTest {
assertEquals(0, new BigDecimal("9").compareTo(saved.parameters().get("duration-seconds")));
assertEquals(new BigDecimal("2"), saved.parameters().get("speed-amplifier"));
}
var config = events(data).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().startsWith("rallying-cry-")).toList();
assertEquals(6, config.size());
assertEquals("10", config.getFirst().getAsJsonObject("data").get("after").getAsString());
var radius = config.stream().filter(event -> event.get("type").getAsString().endsWith("config.changed")
&& event.getAsJsonObject("data").get("key").getAsString().equals("rallying-cry-radius-blocks"))
.findFirst().orElseThrow();
assertEquals("10", radius.getAsJsonObject("data").get("before").getAsString());
assertEquals("12", radius.getAsJsonObject("data").get("after").getAsString());
for (String invalid : List.of("rallying-cry-cooldown-seconds: -1\n", "rallying-cry-radius-blocks: 0\n",
"rallying-cry-duration-seconds: 0\n", "rallying-cry-speed-amplifier: -1\n",
"rallying-cry-damage-bonus-percent: 101\n")) {
@@ -28,10 +28,10 @@ class RareShopNativeTest {
@SuppressWarnings("unchecked")
@Test void registeredMerchantPurchaseInventoryUseAndNativeHealingUseConnectedPath()throws Exception{
java.nio.file.Files.writeString(data.resolve("config.yml"),"rare-price-variance-percent: 0\nrare-price-health-potion: 40\nrare-stock-health-potion-min: 1\nrare-stock-health-potion-max: 1\nrare-price-reskill: 20\nrare-stock-reskill-min: 2\nrare-stock-reskill-max: 2\nrare-price-magic-core: 30\nrare-stock-magic-core-min: 1\nrare-stock-magic-core-max: 1\n");
java.nio.file.Files.writeString(data.resolve("config.yml"),"rare-price-variance-percent: 0\nrare-price-health-potion: 40\nrare-stock-health-potion-min: 1\nrare-stock-health-potion-max: 1\nrare-price-reskill: 20\nrare-stock-reskill-min: 2\nrare-stock-reskill-max: 2\nrare-price-magic-core: 30\nrare-stock-magic-core-min: 1\nrare-stock-magic-core-max: 1\nrare-price-phoenix-down: 10\nrare-stock-phoenix-down-min: 1\nrare-stock-phoenix-down-max: 1\n");
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);doReturn(true).when(actor.player).hasPermission("fantasystick.admin");UUID worldId=UUID.randomUUID();when(actor.world.getUID()).thenReturn(worldId);int x=actor.core.location.getBlockX()>>4,z=actor.core.location.getBlockZ()>>4;var chunk=mock(Chunk.class);when(chunk.getWorld()).thenReturn(actor.world);when(chunk.getX()).thenReturn(x);when(chunk.getZ()).thenReturn(z);when(chunk.getEntities()).thenReturn(new org.bukkit.entity.Entity[0]);when(actor.world.getChunkAt(x,z)).thenReturn(chunk);when(actor.world.isChunkLoaded(x,z)).thenReturn(true);when(actor.world.getEntities()).thenReturn(List.of());
assertEquals(List.of("list","place","remove","restock"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"rare-shop",""}));assertTrue(f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","rare-"}).containsAll(List.of("rare-price-health-potion","rare-price-reskill","rare-stock-reskill-min","rare-stock-reskill-max","rare-price-magic-core","rare-stock-magic-core-min","rare-stock-magic-core-max")));assertEquals(List.of("20","2000"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","rare-price-reskill",""}));assertEquals(List.of("30","3000"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","rare-price-magic-core",""}));
assertEquals(List.of("list","place","remove","restock"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"rare-shop",""}));assertTrue(f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","rare-"}).containsAll(List.of("rare-price-health-potion","rare-price-reskill","rare-stock-reskill-min","rare-stock-reskill-max","rare-price-magic-core","rare-stock-magic-core-min","rare-stock-magic-core-max","rare-price-phoenix-down","rare-stock-phoenix-down-min","rare-stock-phoenix-down-max","rare-phoenix-down-invulnerability-seconds")));assertEquals(List.of("20","2000"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","rare-price-reskill",""}));assertEquals(List.of("30","3000"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","rare-price-magic-core",""}));
var villager=mock(Villager.class);when(villager.getLocation()).thenReturn(actor.core.location);var values=new HashMap<String,String>();var pdc=mock(PersistentDataContainer.class);when(villager.getPersistentDataContainer()).thenReturn(pdc);doAnswer(call->{values.put(((NamespacedKey)call.getArgument(0)).getKey(),call.getArgument(2));return null;}).when(pdc).set(any(NamespacedKey.class),eq(PersistentDataType.STRING),anyString());when(pdc.get(any(NamespacedKey.class),eq(PersistentDataType.STRING))).thenAnswer(call->values.get(((NamespacedKey)call.getArgument(0)).getKey()));doAnswer(call->{((java.util.function.Consumer<Villager>)call.getArgument(2)).accept(villager);return villager;}).when(actor.world).spawn(any(Location.class),eq(Villager.class),any(java.util.function.Consumer.class));
f.command.execute(actor.player,"fantasystick",new String[]{"rare-shop","place","Curios"});await(f,()->((RareMerchantDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchants")).all().size()==1);var directory=(RareMerchantDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchants");var shop=directory.all().getFirst();assertEquals("Curios",shop.name());var controller=(RareMerchantController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchantController");assertTrue(controller.tagged(villager));
var repository=new YamlCharacterRepository(data);var old=repository.load(actor.core.record.id()).orElseThrow();var purchasedStats=new java.util.EnumMap<>(old.stats());purchasedStats.put(games.dmg.fantasystick.progression.Stat.DEX,BigInteger.ONE);repository.replace(new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),purchasedStats,old.level(),old.experience(),BigInteger.valueOf(39),old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),false,null,null,BigInteger.valueOf(150),true,old.rewardReceipts(),old.pvpCoinEscrow(),old.gear()),old.revision());f.activate(actor);
@@ -39,9 +39,14 @@ class RareShopNativeTest {
var menus=(RareShopMenus)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareShopMenus");var click=mock(InventoryClickEvent.class);when(click.getInventory()).thenReturn(menu);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getCurrentItem()).thenReturn(potion);menus.click(click);var inventory=(RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");await(f,()->inventory.quantity(actor.core.record.id(),RareProduct.HEALTH_POTION).equals(BigInteger.ONE));var characters=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");await(f,()->sessions.active(actor.core.id).flatMap(sessions::currentRecord).map(value->value.coins().equals(BigInteger.valueOf(110))).orElse(false));var active=sessions.active(actor.core.id).orElseThrow(()->new AssertionError("Purchase removed active session: "+actor.messages));var record=sessions.currentRecord(active).orElseThrow(()->new AssertionError("Purchase left no current record: "+actor.messages));var injured=characters.saveHealth(record.id(),record.revision(),BigDecimal.valueOf(5),BigDecimal.valueOf(100)).join().orElseThrow(()->new AssertionError("Injury save was refused"));assertTrue(sessions.acknowledgeProgress(active,record.revision(),injured));actor.player.setHealth(5);
PartyBookNativeTest.open(f,actor);assertTrue(PartyBookNativeTest.text(actor).contains("Health Potion"),PartyBookNativeTest.text(actor));assertTrue(PartyBookNativeTest.text(actor).contains("[Use]"),PartyBookNativeTest.text(actor));var action=PartyBookNativeTest.button(actor,"[Use]");PartyBookNativeTest.click(f,actor,action);await(f,()->inventory.quantity(actor.core.record.id(),RareProduct.HEALTH_POTION).signum()==0&&actor.player.getHealth()>5&&actor.messages.stream().anyMatch(message->message.contains("Used Health Potion")));assertEquals(10d,actor.player.getHealth());assertEquals(0,BigDecimal.TEN.compareTo(characters.find(record.id()).join().orElseThrow().health()));assertTrue(actor.messages.stream().anyMatch(message->message.contains("Used Health Potion")&&message.contains("restored")));
int menusBeforeReskill=actor.inventories.size();menus.open(actor.player,shop.id());await(f,()->actor.inventories.size()>menusBeforeReskill);var reskillMenu=actor.inventories.getLast();var reskill=reskillMenu.getItem(13);assertTrue(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(reskill.getItemMeta().displayName()).contains("Reskill — 20 coins"));var reskillClick=mock(InventoryClickEvent.class);when(reskillClick.getInventory()).thenReturn(reskillMenu);when(reskillClick.getWhoClicked()).thenReturn(actor.player);when(reskillClick.getCurrentItem()).thenReturn(reskill);menus.click(reskillClick);await(f,()->inventory.quantity(actor.core.record.id(),RareProduct.RESKILL).equals(BigInteger.ONE));await(f,()->sessions.active(actor.core.id).flatMap(sessions::currentRecord).map(value->value.coins().equals(BigInteger.valueOf(90))&&!value.stats().get(games.dmg.fantasystick.progression.Stat.DEX).equals(BigInteger.ZERO)).orElse(false));PartyBookNativeTest.open(f,actor);PartyBookNativeTest.click(f,actor,PartyBookNativeTest.button(actor,"[Use]"));await(f,()->PartyBookNativeTest.text(actor).contains("Confirm Reskill"));PartyBookNativeTest.click(f,actor,PartyBookNativeTest.button(actor,"[Confirm Reskill]"));await(f,()->inventory.quantity(actor.core.record.id(),RareProduct.RESKILL).signum()==0&&sessions.active(actor.core.id).flatMap(sessions::currentRecord).map(value->value.stats().values().stream().allMatch(BigInteger.ZERO::equals)&&value.unspentPoints().equals(BigInteger.valueOf(40))).orElse(false));var resetActive=sessions.active(actor.core.id).orElseThrow();var resetRecord=sessions.currentRecord(resetActive).orElseThrow();var repurchased=characters.purchaseStat(resetRecord.id(),resetRecord.revision(),games.dmg.fantasystick.progression.Stat.DEX,BigDecimal.valueOf(actor.player.getHealth()),new BigDecimal("1024")).join().record().orElseThrow();assertTrue(sessions.acknowledgeProgress(resetActive,resetRecord.revision(),repurchased));assertEquals(BigInteger.ONE,repurchased.stats().get(games.dmg.fantasystick.progression.Stat.DEX));assertEquals(BigInteger.valueOf(39),repurchased.unspentPoints());int beforeCoreMenu=actor.inventories.size();menus.open(actor.player,shop.id());await(f,()->actor.inventories.size()>beforeCoreMenu);var coreMenu=actor.inventories.getLast();var coreItem=coreMenu.getItem(14);assertTrue(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(coreItem.getItemMeta().displayName()).contains("Magic Core — 30 coins"));var coreClick=mock(InventoryClickEvent.class);when(coreClick.getInventory()).thenReturn(coreMenu);when(coreClick.getWhoClicked()).thenReturn(actor.player);when(coreClick.getCurrentItem()).thenReturn(coreItem);menus.click(coreClick);awaitEvidence(f,()->inventory.quantity(actor.core.record.id(),RareProduct.MAGIC_CORE).equals(BigInteger.ONE)&&sessions.active(actor.core.id).flatMap(sessions::currentRecord).map(value->value.coins().equals(BigInteger.valueOf(60))).orElse(false),()->"core purchase inventory="+inventory.inventory(actor.core.record.id())+" record="+sessions.active(actor.core.id).flatMap(sessions::currentRecord)+" messages="+actor.messages);PartyBookNativeTest.open(f,actor);PartyBookNativeTest.click(f,actor,PartyBookNativeTest.button(actor,"[Use]"));awaitEvidence(f,()->inventory.quantity(actor.core.record.id(),RareProduct.MAGIC_CORE).signum()==0&&sessions.active(actor.core.id).flatMap(sessions::currentRecord).map(value->value.unspentPoints().equals(BigInteger.valueOf(40))).orElse(false),()->"core use inventory="+inventory.inventory(actor.core.record.id())+" record="+sessions.active(actor.core.id).flatMap(sessions::currentRecord)+" messages="+actor.messages);int beforeSecondReskill=actor.inventories.size();menus.open(actor.player,shop.id());await(f,()->actor.inventories.size()>beforeSecondReskill);var secondReskillMenu=actor.inventories.getLast();var secondReskill=secondReskillMenu.getItem(13);var secondReskillClick=mock(InventoryClickEvent.class);when(secondReskillClick.getInventory()).thenReturn(secondReskillMenu);when(secondReskillClick.getWhoClicked()).thenReturn(actor.player);when(secondReskillClick.getCurrentItem()).thenReturn(secondReskill);menus.click(secondReskillClick);awaitEvidence(f,()->inventory.quantity(actor.core.record.id(),RareProduct.RESKILL).equals(BigInteger.ONE)&&sessions.active(actor.core.id).flatMap(sessions::currentRecord).map(value->value.coins().equals(BigInteger.valueOf(40))).orElse(false),()->"second reskill inventory="+inventory.inventory(actor.core.record.id())+" messages="+actor.messages);PartyBookNativeTest.open(f,actor);PartyBookNativeTest.click(f,actor,PartyBookNativeTest.button(actor,"[Use]"));await(f,()->PartyBookNativeTest.text(actor).contains("Confirm Reskill"));PartyBookNativeTest.click(f,actor,PartyBookNativeTest.button(actor,"[Confirm Reskill]"));awaitEvidence(f,()->inventory.quantity(actor.core.record.id(),RareProduct.RESKILL).signum()==0&&sessions.active(aLine truncated
f.plugin.setEnabled(false);f.awaitWorkers();f.plugin.setEnabled(true);var reloaded=(RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");assertEquals(BigInteger.ZERO,reloaded.quantity(actor.core.record.id(),RareProduct.HEALTH_POTION));assertEquals(BigInteger.ZERO,reloaded.quantity(actor.core.record.id(),RareProduct.RESKILL));assertEquals(BigInteger.ZERO,reloaded.quantity(actor.core.record.id(),RareProduct.MAGIC_CORE));var persisted=new YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();assertTrue(persisted.stats().values().stream().allMatch(BigInteger.ZERO::equals));assertEquals(BigInteger.valueOf(41),persisted.unspentPoints());assertTrue(((RareMerchantDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchants")).find(shop.id()).isPresent());
int beforePhoenixMenu=actor.inventories.size();menus.open(actor.player,shop.id());await(f,()->actor.inventories.size()>beforePhoenixMenu);var phoenixMenu=actor.inventories.getLast();var phoenix=phoenixMenu.getItem(15);assertTrue(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(phoenix.getItemMeta().displayName()).contains("Phoenix Down — 10 coins"));var phoenixClick=mock(InventoryClickEvent.class);when(phoenixClick.getInventory()).thenReturn(phoenixMenu);when(phoenixClick.getWhoClicked()).thenReturn(actor.player);when(phoenixClick.getCurrentItem()).thenReturn(phoenix);menus.click(phoenixClick);await(f,()->inventory.quantity(actor.core.record.id(),RareProduct.PHOENIX_DOWN).equals(BigInteger.ONE));int beforeRestockMessage=actor.messages.size();f.command.execute(actor.player,"fantasystick",new String[]{"rare-shop","restock"});await(f,()->actor.messages.size()>beforeRestockMessage&&actor.messages.getLast().contains("Restocked Rare Items catalog"));
f.plugin.setEnabled(false);f.awaitWorkers();var eventTypes=new java.util.ArrayList<String>();try(var eventFiles=java.nio.file.Files.walk(data.resolve("events"))){for(var eventFile:eventFiles.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(eventFile))if(!line.isBlank()){var event=com.google.gson.JsonParser.parseString(line).getAsJsonObject();if(event.getAsJsonObject("data").has("product_id")&&event.getAsJsonObject("data").get("product_id").getAsString().equals("phoenix-down"))eventTypes.add(event.get("type").getAsString());}}assertTrue(eventTypes.stream().anyMatch(type->type.endsWith("rare_item.purchased")),eventTypes.toString());assertTrue(eventTypes.stream().anyMatch(type->type.endsWith("rare_catalog.refreshed")),eventTypes.toString());f.plugin.setEnabled(true);var reloaded=(RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");assertEquals(BigInteger.ZERO,reloaded.quantity(actor.core.record.id(),RareProduct.HEALTH_POTION));assertEquals(BigInteger.ZERO,reloaded.quantity(actor.core.record.id(),RareProduct.RESKILL));assertEquals(BigInteger.ZERO,reloaded.quantity(actor.core.record.id(),RareProduct.MAGIC_CORE));assertEquals(BigInteger.ONE,reloaded.quantity(actor.core.record.id(),RareProduct.PHOENIX_DOWN));var persisted=new YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();assertTrue(persisted.stats().values().stream().allMatch(BigInteger.ZERO::equals));assertEquals(BigInteger.valueOf(41),persisted.unspentPoints());assertTrue(((RareMerchantDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchants")).find(shop.id()).isPresent());
}
}
@Test void administratorReadsAndChangesFuturePhoenixProtectionWithEvents()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();var phoenixKeys=f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","rare-"});assertTrue(phoenixKeys.containsAll(List.of("rare-price-phoenix-down","rare-stock-phoenix-down-min","rare-stock-phoenix-down-max","rare-phoenix-down-invulnerability-seconds")),phoenixKeys.toString());assertEquals(List.of("1000"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","rare-price-phoenix-down",""}));assertEquals(List.of("0"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","rare-stock-phoenix-down-min",""}));assertEquals(List.of("2"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","rare-stock-phoenix-down-max",""}));assertEquals(List.of("5"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","rare-phoenix-down-invulnerability-seconds",""}));f.command.execute(sender.port,"fantasystick",new String[]{"config","rare-phoenix-down-invulnerability-seconds"});assertTrue(sender.messages.getLast().contains("=5"));int before=sender.messages.size();f.command.execute(sender.port,"fantasystick",new String[]{"config","rare-phoenix-down-invulnerability-seconds","0"});await(f,()->sender.messages.size()>before);assertTrue(sender.messages.getLast().contains("=0"));f.command.execute(sender.port,"fantasystick",new String[]{"config","rare-phoenix-down-invulnerability-seconds","31536001"});assertTrue(sender.messages.getLast().contains("invalid"));assertEquals(java.time.Duration.ZERO,((RareSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareSettings")).phoenixProtection());f.plugin.setEnabled(false);var types=new java.util.ArrayList<String>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank()){var event=com.google.gson.JsonParser.parseString(line).getAsJsonObject();if(event.getAsJsonObject("data").has("key")&&event.getAsJsonObject("data").get("key").getAsString().equals("rare-phoenix-down-invulnerability-seconds"))types.add(event.get("type").getAsString());}}assertTrue(types.stream().anyMatch(type->type.endsWith("config.read")),types.toString());assertTrue(types.stream().anyMatch(type->type.endsWith("config.changed")),types.toString());}
}
@Test void reskillUseRequiresFreshConfirmationThenResetsTheExactActiveCharacter()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);
@@ -71,7 +76,7 @@ class RareShopNativeTest {
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var inventory=(RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");await(f,()->inventory.unresolvedUses().isEmpty());assertEquals(BigInteger.ZERO,inventory.quantity(invested.id(),RareProduct.RESKILL));assertEquals(BigInteger.ONE,inventory.quantity(other.id(),RareProduct.RESKILL));assertEquals(BigInteger.ZERO,inventory.quantity(invested.id(),RareProduct.MAGIC_CORE));assertEquals(BigInteger.ONE,inventory.quantity(other.id(),RareProduct.MAGIC_CORE));var saved=new YamlCharacterRepository(data).load(invested.id()).orElseThrow();assertEquals(BigInteger.valueOf(41),saved.unspentPoints());assertTrue(saved.stats().values().stream().allMatch(BigInteger.ZERO::equals));}
}
@Test void everyMerchantSharesOneAllZeroCatalogAndShowsAnEmptyShop()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"rare-stock-lesser-health-potion-min: 0\nrare-stock-lesser-health-potion-max: 0\nrare-stock-health-potion-min: 0\nrare-stock-health-potion-max: 0\nrare-stock-greater-health-potion-min: 0\nrare-stock-greater-health-potion-max: 0\nrare-stock-reskill-min: 0\nrare-stock-reskill-max: 0\nrare-stock-magic-core-min: 0\nrare-stock-magic-core-max: 0\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);UUID world=UUID.randomUUID();when(actor.world.getUID()).thenReturn(world);f.activate(actor);var directory=(RareMerchantDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchants");var first=directory.place("One",world,actor.core.location.getX(),actor.core.location.getY(),actor.core.location.getZ(),0,0).join();var second=directory.place("Two",world,actor.core.location.getX(),actor.core.location.getY(),actor.core.location.getZ(),0,0).join();var menus=(RareShopMenus)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareShopMenus");menus.open(actor.player,first.id());await(f,()->!actor.inventories.isEmpty());var one=actor.inventories.getLast();assertTrue(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(one.getItem(13).getItemMeta().displayName()).contains("Reskill"));assertTrue(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(one.getItem(14).getItemMeta().displayName()).contains("Magic Core"));assertEquals(Material.BARRIER,one.getItem(16).getType());menus.open(actor.player,second.id());await(f,()->actor.inventories.size()>=2);var two=actor.inventories.getLast();assertEquals(one.getItem(10).getItemMeta().displayName(),two.getItem(10).getItemMeta().displayName());assertTrue(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(one.getItem(10).getItemMeta().displayName()).startsWith("Sold out"));}}
@Test void everyMerchantSharesOneAllZeroCatalogAndShowsAnEmptyShop()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"rare-stock-lesser-health-potion-min: 0\nrare-stock-lesser-health-potion-max: 0\nrare-stock-health-potion-min: 0\nrare-stock-health-potion-max: 0\nrare-stock-greater-health-potion-min: 0\nrare-stock-greater-health-potion-max: 0\nrare-stock-reskill-min: 0\nrare-stock-reskill-max: 0\nrare-stock-magic-core-min: 0\nrare-stock-magic-core-max: 0\nrare-stock-phoenix-down-min: 0\nrare-stock-phoenix-down-max: 0\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);UUID world=UUID.randomUUID();when(actor.world.getUID()).thenReturn(world);f.activate(actor);var directory=(RareMerchantDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchants");var first=directory.place("One",world,actor.core.location.getX(),actor.core.location.getY(),actor.core.location.getZ(),0,0).join();var second=directory.place("Two",world,actor.core.location.getX(),actor.core.location.getY(),actor.core.location.getZ(),0,0).join();var menus=(RareShopMenus)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareShopMenus");menus.open(actor.player,first.id());await(f,()->!actor.inventories.isEmpty());var one=actor.inventories.getLast();assertTrue(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(one.getItem(13).getItemMeta().displayName()).contains("Reskill"));assertTrue(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(one.getItem(14).getItemMeta().displayName()).contains("Magic Core"));assertEquals(Material.BARRIER,one.getItem(16).getType());menus.open(actor.player,second.id());await(f,()->actor.inventories.size()>=2);var two=actor.inventories.getLast();assertEquals(one.getItem(10).getItemMeta().displayName(),two.getItem(10).getItemMeta().displayName());assertTrue(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(one.getItem(10).getItemMeta().displayName()).startsWith("Sold out"));}}
@Test void allPotionSizesPreserveFractionalHealingAndFullHealthStock()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);f.activate(actor);var inventory=(RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");var characters=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");inventory.add(actor.core.record.id(),RareProduct.LESSER_HEALTH_POTION,UUID.randomUUID()).join();injure(actor,characters,sessions,new BigDecimal("4"));useFirst(f,actor);awaitEvidence(f,()->actor.player.getHealth()==6.5&&inventory.quantity(actor.core.record.id(),RareProduct.LESSER_HEALTH_POTION).signum()==0,()->"lesser health="+actor.player.getHealth()+" quantity="+inventory.quantity(actor.core.record.id(),RareProduct.LESSER_HEALTH_POTION)+" messages="+actor.messages);inventory.add(actor.core.record.id(),RareProduct.LESSER_HEALTH_POTION,UUID.randomUUID()).join();injure(actor,characters,sessions,BigDecimal.TEN);var potionService=(RarePotionService)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rarePotions");var refusal=new java.util.concurrent.atomic.AtomicReference<RarePotionService.Outcome>();potionService.use(actor.player,RareProduct.LESSER_HEALTH_POTION,refusal::set);assertEquals(RarePotionService.Status.FULL_HEALTH,refusal.get().status());assertEquals(BigInteger.ONE,inventory.quantity(actor.core.record.id(),RareProduct.LESSER_HEALTH_POTION));var settings=(RareSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareSettings");var zero=settings.set("rare-lesser-health-potion-healing-percent","0").toCompletableFuture().join();assertTrue(settings.publish(zero));injure(actor,characters,sessions,BigDecimal.ONE);var noEffect=new java.util.concurrent.atomic.AtomicReference<RarePotionService.Outcome>();potionService.use(actor.player,RareProduct.LESSER_HEALTH_POTION,noEffect::set);assertEquals(RarePotionService.Status.NO_EFFECT,noEffect.get().status());assertEquals(BigInteger.ONE,inventory.quantity(actor.core.record.id(),RareProduct.LESSER_HEALTH_POTION));inventory.add(actor.core.record.id(),RareProduct.GREATER_HEALTH_POTION,UUID.randomUUID()).join();injure(actor,characters,sessions,BigDecimal.ONE);var greater=new java.util.concurrent.atomic.AtomicReference<RarePotionService.Outcome>();potionService.use(actor.player,RareProduct.GREATER_HEALTH_POTION,greater::set);awaitEvidence(f,()->greater.get()!=null&&greater.get().status()==RarePotionService.Status.APPLIED&&actor.player.getHealth()==10&&inventory.quantity(actor.core.record.id(),RareProduct.GREATER_HEALTH_POTION).signum()==0,()->"greater outcome="+greater.get()+" health="+actor.player.getHealth()+" quantities="+inventory.inventory(actor.core.record.id())+" messages="+actor.messages);assertEquals(BigInteger.ONE,inventory.quantity(actor.core.record.id(),RareProduct.LESSER_HEALTH_POTION));}}
private static void injure(FantasyStickLifecycleNativeTest.Actor actor,CharacterStorage characters,games.dmg.fantasystick.session.EquipmentSessions sessions,BigDecimal health){var active=sessions.active(actor.core.id).orElseThrow();var record=sessions.currentRecord(active).orElseThrow();var saved=characters.saveHealth(record.id(),record.revision(),health,BigDecimal.valueOf(100)).join().orElseThrow();if(!saved.revision().equals(record.revision()))assertTrue(sessions.acknowledgeProgress(active,record.revision(),saved));actor.player.setHealth(health.doubleValue());}
@@ -45,10 +45,11 @@ class ReviveNativeTest {
assertEquals(BigInteger.ZERO, saved.unspentPoints());
assertEquals(org.bukkit.Material.ECHO_SHARD, actor.core.inventory.getItem(2).getType());
}
var records=events(data);var learned=records.stream().filter(event->event.get("type").getAsString().endsWith("ability.learned")&&event.getAsJsonObject("data").get("ability_id").getAsString().equals("revive")).toList();var assigned=records.stream().filter(event->event.get("type").getAsString().endsWith("ability.assigned")&&event.getAsJsonObject("data").get("ability_id").getAsString().equals("revive")).toList();assertEquals(1,learned.size());assertEquals(1,assigned.size());assertEquals("0",learned.getFirst().getAsJsonObject("data").get("points_after").getAsString());assertEquals(0,assigned.getFirst().getAsJsonObject("data").get("ability_slot").getAsInt());assertEquals(learned.getFirst().getAsJsonObject("data").get("action_id").getAsString(),assigned.getFirst().getAsJsonObject("data").get("action_id").getAsString());
}
@Test void learnedAimedReviveRestoresAnyPartyDownedCharacterInPlace()throws Exception{
try(var fixture=new FantasyStickLifecycleNativeTest.Fixture(data)){fixture.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);withRevive(data,caster);var target=new FantasyStickLifecycleNativeTest.Actor(data);fixture.players.put(caster.core.id,caster.player);fixture.players.put(target.core.id,target.player);var worldId=java.util.UUID.randomUUID();org.mockito.Mockito.when(caster.world.getUID()).thenReturn(worldId);org.mockito.Mockito.when(target.world.getUID()).thenReturn(worldId);fixture.activate(caster);fixture.activate(target);caster.core.location.setWorld(caster.world);caster.core.location.setX(0);caster.core.location.setY(64);caster.core.location.setZ(0);target.core.location.setWorld(caster.world);target.core.location.setX(3);target.core.location.setY(64);target.core.location.setZ(0);var anchor=target.core.location.clone();org.mockito.Mockito.doReturn(true).when(caster.player).hasLineOfSight(target.player);target.core.health=0;var source=new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(org.bukkit.damage.DamageType.GENERIC)));fixture.dispatcher.accept(new org.bukkit.event.entity.PlayerDeathEvent(target.player,source,new java.util.ArrayList<>(),0,net.kyori.adventure.text.Component.empty(),true));var downed=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin,"downedController");for(int attempt=0;attempt<80&&!downed.downed(target.player);attempt++){var task=fixture.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}caster.core.inventory.getInventory().setSelectedSlot(2);fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(caster.player,target.player,org.bukkit.inventory.EquipmentSlot.HAND));for(int attempt=0;attempt<120&&downed.downed(target.player);attempt++){var task=fixture.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}for(int attempt=0;attempt<80&&caster.messages.stream().noneMatch(message->message.contains("restored the downed"));attempt++){var task=fixture.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin,"sessions");assertFalse(downed.downed(target.player),()->"caster="+caster.messages+", target="+target.messages);assertTrue(sessions.current(sessions.active(target.core.id).orElseThrow()));assertEquals(target.maximumHealth.getValue(),target.player.getHealth());assertEquals(anchor,target.player.getLocation());assertNotEquals(org.bukkit.Material.WRITTEN_BOOK,target.core.inventory.getItemInOffHand().getType());var casterRecord=sessions.currentRecord(sessions.active(caster.core.id).orElseThrow()).orElseThrow();assertTrue(casterRecord.cooldowns().get("revive").isAfter(java.time.Instant.now().plusSeconds(86300)));fixture.plugin.setEnabled(false);var records=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}var types=records.stream().map(event->event.get("type").getAsString()).toList();assertTrue(types.contains("games.dmg.minecraft.fantasystick.ability.cast"),types.toString());assertTrue(types.contains("games.dmg.minecraft.fantasystick.ability.resolved"),types.toString());assertEquals(1,types.stream().filter("games.dmg.minecraft.fantasystick.character.downed_resolved"::equals).count(),types.toString());var resolutionEvent=records.stream().filter(event->event.get("type").getAsString().endsWith("character.downed_resolved")).findFirst().orElseThrow();var resolution=resolutionEvent.getAsJsonObject("data");assertEquals(caster.core.id.toString(),resolutionEvent.get("playerid").getAsString());assertEquals(caster.core.record.id().toString(),resolutionEvent.get("characterid").getAsString());assertEquals(caster.core.record.id().toString(),resolution.get("actor_character_id").getAsString());assertEquals(3.0,resolution.get("final_range").getAsDouble());assertTrue(resolution.get("line_of_sight").getAsBoolean());assertFalse(types.stream().anyMatch(type->type.contains("phoenix")||type.endsWith("character.revived")));}
try(var fixture=new FantasyStickLifecycleNativeTest.Fixture(data)){fixture.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);withRevive(data,caster);var target=new FantasyStickLifecycleNativeTest.Actor(data);fixture.players.put(caster.core.id,caster.player);fixture.players.put(target.core.id,target.player);var worldId=java.util.UUID.randomUUID();org.mockito.Mockito.when(caster.world.getUID()).thenReturn(worldId);org.mockito.Mockito.when(target.world.getUID()).thenReturn(worldId);fixture.activate(caster);fixture.activate(target);caster.core.location.setWorld(caster.world);caster.core.location.setX(0);caster.core.location.setY(64);caster.core.location.setZ(0);target.core.location.setWorld(caster.world);target.core.location.setX(3);target.core.location.setY(64);target.core.location.setZ(0);var anchor=target.core.location.clone();org.mockito.Mockito.doReturn(true).when(caster.player).hasLineOfSight(target.player);target.core.health=0;var source=new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(org.bukkit.damage.DamageType.GENERIC)));fixture.dispatcher.accept(new org.bukkit.event.entity.PlayerDeathEvent(target.player,source,new java.util.ArrayList<>(),0,net.kyori.adventure.text.Component.empty(),true));var downed=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin,"downedController");for(int attempt=0;attempt<80&&!downed.downed(target.player);attempt++){var task=fixture.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}caster.core.inventory.getInventory().setSelectedSlot(2);fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(caster.player,target.player,org.bukkit.inventory.EquipmentSlot.HAND));for(int attempt=0;attempt<120&&downed.downed(target.player);attempt++){var task=fixture.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}for(int attempt=0;attempt<80&&caster.messages.stream().noneMatch(message->message.contains("restored the downed"));attempt++){var task=fixture.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin,"sessions");assertFalse(downed.downed(target.player),()->"caster="+caster.messages+", target="+target.messages);assertTrue(sessions.current(sessions.active(target.core.id).orElseThrow()));assertEquals(target.maximumHealth.getValue(),target.player.getHealth());assertEquals(anchor,target.player.getLocation());assertNotEquals(org.bukkit.Material.WRITTEN_BOOK,target.core.inventory.getItemInOffHand().getType());var casterRecord=sessions.currentRecord(sessions.active(caster.core.id).orElseThrow()).orElseThrow();assertTrue(casterRecord.cooldowns().get("revive").isAfter(java.time.Instant.now().plusSeconds(86300)));fixture.plugin.setEnabled(false);var records=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}var types=records.stream().map(event->event.get("type").getAsString()).toList();assertTrue(types.contains("games.dmg.minecraft.fantasystick.ability.cast"),types.toString());assertTrue(types.contains("games.dmg.minecraft.fantasystick.ability.resolved"),types.toString());assertEquals(1,types.stream().filter("games.dmg.minecraft.fantasystick.character.downed_resolved"::equals).count(),types.toString());var resolutionEvent=records.stream().filter(event->event.get("type").getAsString().endsWith("character.downed_resolved")).findFirst().orElseThrow();var resolution=resolutionEvent.getAsJsonObject("data");assertEquals(caster.core.id.toString(),resolutionEvent.get("playerid").getAsString());assertEquals(caster.core.record.id().toString(),resolutionEvent.get("characterid").getAsString());assertEquals(caster.core.record.id().toString(),resolution.get("actor_character_id").getAsString());assertEquals(3.0,resolution.get("final_range").getAsDouble());assertTrue(resolution.get("line_of_sight").getAsBoolean());assertFalse(types.stream().anyMatch(type->type.contains("phoenix")||type.endsWith("character.revived")));assertEquals(1,records.stream().filter(event->event.get("type").getAsString().endsWith("effect.applied")).map(event->event.get("id").getAsString()).distinct().count());var applied=records.stream().filter(event->event.get("type").getAsString().endsWith("effect.applied")).findFirst().orElseThrow().getAsJsonObject("data");assertEquals("revive_downed_invulnerability",applied.get("effect_id").getAsSLine truncated
}
@Test void ordinaryTargetSelectsOneTailAndReceivesTheSameLivingSheetOnce() throws Exception {
@@ -126,6 +127,7 @@ class ReviveNativeTest {
assertTrue(storage.find(caster.core.record.id()).get().orElseThrow().cooldowns().get("revive")
.isAfter(java.time.Instant.now().plusSeconds(86300)));
}
var records=events(data);var revived=records.stream().filter(event->event.get("type").getAsString().endsWith("character.revived")).toList();assertEquals(1,revived.size());var value=revived.getFirst().getAsJsonObject("data");assertEquals("learned_tail",value.get("revival_kind").getAsString());assertEquals(10,value.get("source_slot").getAsInt());assertEquals(1,value.get("destination_slot").getAsInt());assertEquals("2",value.get("source_quantity_before").getAsString());assertEquals("1",value.get("source_quantity_after").getAsString());assertEquals("1",value.get("consumed_quantity").getAsString());assertTrue(value.get("no_auto_entry").getAsBoolean());assertEquals("tail",value.get("selected_branch").getAsString());assertEquals(1,records.stream().filter(event->event.get("type").getAsString().endsWith("ability.cast")&&event.getAsJsonObject("data").has("selected_branch")&&event.getAsJsonObject("data").get("selected_branch").getAsString().equals("tail")).count());assertEquals(1,records.stream().filter(event->event.get("type").getAsString().endsWith("ability.resolved")&&event.getAsJsonObject("data").has("selected_branch")&&event.getAsJsonObject("data").get("selected_branch").getAsString().equals("tail")&&event.getAsJsonObject("data").get("outcome").getAsString().equals("restored")).count());
}
@Test void stackedTailWithNoSafeSheetDestinationChangesNothingAndKeepsTheCooldown() throws Exception {
@@ -342,6 +344,7 @@ class ReviveNativeTest {
10, 1, -1);
try (var store = new games.dmg.fantasystick.persistence.RevivalStore(data)) { store.reserve(reservation).join(); }
var untouchedReservationId=java.util.UUID.randomUUID();
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.players.put(target.core.id, target.player); fixture.plugin.setEnabled(true);
var store = (games.dmg.fantasystick.persistence.RevivalStore)
@@ -361,7 +364,7 @@ class ReviveNativeTest {
secondBase.stats(), secondBase.level(), secondBase.experience(), secondBase.unspentPoints(), java.math.BigDecimal.ZERO,
secondBase.learned(), secondBase.loadout(), secondBase.cooldowns(), secondBase.restrictedKills(), true, secondDeath, died);
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(java.util.UUID.randomUUID(),
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);
store.reserve(untouched).join();
@@ -370,6 +373,7 @@ class ReviveNativeTest {
assertTrue(storage.find(second.id()).get().orElseThrow().dead());
assertEquals(tail, target.core.inventory.getItem(12)); assertTrue(store.find(second.id()).join().isEmpty());
}
var recovered=events(data).stream().filter(event->event.get("type").getAsString().endsWith("character.revived")&&event.getAsJsonObject("data").get("reservation_id").getAsString().equals(reservation.id().toString())).toList();assertFalse(recovered.isEmpty());assertEquals(1,recovered.stream().map(event->event.get("id").getAsString()).distinct().count());assertTrue(recovered.getFirst().getAsJsonObject("data").get("recovery_reconciliation").getAsBoolean());assertEquals(0,events(data).stream().filter(event->event.get("type").getAsString().endsWith("character.revived")&&event.getAsJsonObject("data").get("reservation_id").getAsString().equals(untouchedReservationId.toString())).count());
}
private static net.minecraft.network.chat.ClickEvent.Custom abilityButton(
@@ -395,6 +399,8 @@ class ReviveNativeTest {
return null;
}
private static java.util.List<com.google.gson.JsonObject> events(Path data)throws Exception{var records=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}return records;}
private static void withRevive(Path data, FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
@@ -41,8 +41,21 @@ class SanctuaryNativeTest {
String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("Sanctuary"));assertTrue(text.contains("WIS24"));assertTrue(text.contains("CHR20"));assertTrue(text.contains("CON10"));
PartyBookNativeTest.click(fixture,caster,button(caster,"[Learn]","sanctuary"));drain(fixture,2);
var viewer=new FantasyStickLifecycleNativeTest.Actor(data);viewer.core.inventory.setItemInOffHand(null);viewer.core.location.setWorld(caster.world);fixture.players.put(viewer.core.id,viewer.player);
fixture.particlesCommand.execute(caster.player,"fantasyparticles",new String[]{"all","off"});PartyBookNativeTest.drain(fixture);caster.particleCalls.clear();viewer.particleCalls.clear();
PartyBookNativeTest.disableAllParticles(fixture,caster);caster.particleCalls.clear();viewer.particleCalls.clear();
use(fixture,caster);drain(fixture,2);assertEquals(2L,fixture.delayedTicks.getLast());
var incomingAttacker=mock(org.bukkit.entity.LivingEntity.class);when(incomingAttacker.getUniqueId()).thenReturn(UUID.randomUUID());
var incomingSource=DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK)
.withCausingEntity(incomingAttacker).withDirectEntity(incomingAttacker).build();
var incoming=new EntityDamageByEntityEvent(incomingAttacker,ally.player,EntityDamageEvent.DamageCause.ENTITY_ATTACK,incomingSource,10);
fixture.dispatcher.accept(incoming);assertEquals(8,incoming.getFinalDamage(),1.0e-9);
class LateVeto implements org.bukkit.event.Listener {
@org.bukkit.event.EventHandler(priority=org.bukkit.event.EventPriority.HIGHEST)
public void cancel(EntityDamageByEntityEvent event){if(event.getDamager()==incomingAttacker)event.setCancelled(true);}
}
fixture.dispatcher.register(new LateVeto(),fixture.plugin);
var vetoed=new EntityDamageByEntityEvent(incomingAttacker,ally.player,
EntityDamageEvent.DamageCause.ENTITY_ATTACK,incomingSource,10);
fixture.dispatcher.accept(vetoed);assertTrue(vetoed.isCancelled());
for(int i=0;i<10;i++)fixture.delayed.removeFirst().run();drain(fixture,4);assertEquals(6,ally.core.health,0);
assertTrue(caster.particleCalls.isEmpty(),"The opted-out caster receives no Sanctuary particles");assertTrue(viewer.particleCalls.stream().anyMatch(call->call[0]==org.bukkit.Particle.END_ROD&&call[2].equals(12)),"An enabled nearby viewer retains Sanctuary visuals");
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
@@ -54,6 +67,61 @@ class SanctuaryNativeTest {
assertEquals(BigInteger.valueOf(5),storage.find(caster.core.record.id()).get().orElseThrow().experience());
assertEquals(BigInteger.valueOf(5),storage.find(ally.core.record.id()).get().orElseThrow().experience());
}
var records=events(data);
for(var type:List.of("ability.learned","ability.assigned","ability.cast","ability.resolved","combat.prevented"))
assertEquals(1,abilityEvents(records,type).size(),type);
var heals=abilityEvents(records,"combat.healed");
assertTrue(heals.size()>=1,"Actual acknowledged ally-health pulses are individually recorded");
assertEquals(heals.size(),heals.stream().map(event->event.getAsJsonObject("data").get("application_id").getAsString()).distinct().count());
assertEquals(3,abilityEvents(records,"effect.applied").size(),"One fixed area plus caster/ally benefits");
assertEquals(3,abilityEvents(records,"effect.ended").size(),"No per-tick duplicate grants");
var heal=heals.stream().map(event->event.getAsJsonObject("data"))
.filter(value->value.get("health_before").getAsString().equals("4")).findFirst().orElseThrow();
assertEquals("2",heal.get("actual_amount").getAsString());
assertEquals("6",heal.get("health_after").getAsString());
assertTrue(heal.has("target_location"));
assertEquals("2",abilityEvents(records,"combat.prevented").getFirst().getAsJsonObject("data")
.get("actual_amount").getAsString());
}
@Test void cancelledNativeAllyHealCannotClaimSavedOrEffectiveHealth()throws Exception{
UUID allyId;
try(var fixture=new FantasyStickLifecycleNativeTest.Fixture(data)){
fixture.plugin.setEnabled(true);
var caster=new FantasyStickLifecycleNativeTest.Actor(data);
var ally=new FantasyStickLifecycleNativeTest.Actor(data);allyId=ally.core.id;
prepareCaster(data,caster,true);setHealth(data,ally,BigDecimal.valueOf(4));
fixture.players.put(caster.core.id,caster.player);fixture.players.put(allyId,ally.player);
fixture.activate(caster);fixture.activate(ally);party(fixture,caster,ally);
caster.core.location=new Location(caster.world,0,64,0);ally.core.location=new Location(caster.world,2,64,0);
doReturn(List.of(ally.player)).when(caster.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());
class Veto implements org.bukkit.event.Listener{
@org.bukkit.event.EventHandler(priority=org.bukkit.event.EventPriority.HIGH)
public void cancel(EntityRegainHealthEvent event){if(event.getEntity()==ally.player)event.setCancelled(true);}
}
fixture.dispatcher.register(new Veto(),fixture.plugin);
use(fixture,caster);drain(fixture,2);
for(int i=0;i<10;i++)fixture.delayed.removeFirst().run();drain(fixture,4);
assertEquals(4,ally.core.health,0);
}
assertTrue(abilityEvents(events(data),"combat.healed").stream()
.noneMatch(event->event.getAsJsonObject("data").get("target_player_id").getAsString().equals(allyId.toString())),
"Cancelled ally healing must not become a saved-health success or a JSONL healing claim");
}
private static List<com.google.gson.JsonObject> events(Path root)throws Exception{
var values=new ArrayList<com.google.gson.JsonObject>();
try(var files=java.nio.file.Files.walk(root.resolve("events"))){
for(var file:files.filter(path->path.toString().endsWith(".jsonl")).toList())
for(var line:java.nio.file.Files.readAllLines(file))
if(!line.isBlank())values.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
return values;
}
private static List<com.google.gson.JsonObject> abilityEvents(List<com.google.gson.JsonObject> events,String suffix){
return events.stream().filter(event->event.get("type").getAsString().endsWith(suffix)
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("sanctuary")).toList();
}
@Test void fixedCylinderHasNoImmediatePulseThenSixOwnedHealingAndMitigationPulses() {
@@ -145,6 +213,16 @@ class SanctuaryNativeTest {
plugin.command.execute(sender.port,"fantasystick",new String[]{"config",update[0],update[1]});var response=plugin.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(response);response.run();}
var configured=registry.find("sanctuary").orElseThrow();assertEquals(new BigDecimal("4"),configured.parameters().get("healing-per-pulse"));
assertEquals(new BigDecimal("30"),configured.parameters().get("damage-reduction-percent"));assertEquals(java.time.Duration.ofSeconds(20),configured.cooldown());
try(var channel=java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
java.nio.file.StandardOpenOption.CREATE,java.nio.file.StandardOpenOption.WRITE);
var lock=channel.lock()){
assertTrue(lock.isValid());
plugin.command.execute(sender.port,"fstickadmin",new String[]{"config","sanctuary-radius-blocks","9"});
var failed=plugin.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(failed);failed.run();
}
assertEquals("8",((games.dmg.fantasystick.persistence.SanctuarySettings)
FantasyStickLifecycleNativeTest.Fixture.field(plugin.plugin,"sanctuarySettings")).value("sanctuary-radius-blocks"));
assertTrue(sender.messages.getLast().contains("save failed"));
plugin.command.execute(sender.port,"fantasystick",new String[]{"config","sanctuary-radius-blocks","0"});assertTrue(sender.messages.getLast().contains("invalid"));
plugin.command.execute(sender.port,"fantasystick",new String[]{"config","sanctuary-radius-blocks","8","extra"});assertTrue(sender.messages.getLast().contains("Usage"));
plugin.plugin.setEnabled(false);plugin.plugin.setEnabled(true);
@@ -152,6 +230,11 @@ class SanctuaryNativeTest {
var saved=reloaded.find("sanctuary").orElseThrow();assertEquals(new BigDecimal("4"),saved.parameters().get("healing-per-pulse"));
assertEquals(new BigDecimal("30"),saved.parameters().get("damage-reduction-percent"));assertEquals(java.time.Duration.ofSeconds(20),saved.cooldown());
}
var config=events(data).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().startsWith("sanctuary-")).toList();
assertEquals(8,config.size(),"Authorized read and seven saved settings only");
}
private static void use(FantasyStickLifecycleNativeTest.Fixture fixture,FantasyStickLifecycleNativeTest.Actor actor){actor.core.inventory.getInventory().setSelectedSlot(2);fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(actor.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,actor.core.inventory.getItem(2),null,org.bukkit.block.BlockFace.SELF,org.bukkit.inventory.EquipmentSlot.HAND));}
@@ -35,14 +35,46 @@ class WarBannerNativeTest {
@Test void bookAcquisitionSolidTopPlacementBenefitsPartyAndRewardsUsedDamage()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);var ally=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,false,false);f.players.put(caster.core.id,caster.player);f.players.put(ally.core.id,ally.player);f.activate(caster);f.activate(ally);party(f,caster,ally);placement(caster);nearby(caster,List.of(ally.player));var resistance=attribute(ally.player);resistance.modifiers().add(new AttributeModifier(new NamespacedKey("test","unrelated_knockback"),.2,AttributeModifier.Operation.ADD_NUMBER));
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,caster);String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("WarBanner"));assertTrue(text.contains("STR22"));assertTrue(text.contains("CHR22"));assertTrue(text.contains("CON12"));assertTrue(text.contains("25%"));assertTrue(text.contains("50%"));
PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","war_banner"));drain(f,2);var viewer=new FantasyStickLifecycleNativeTest.Actor(data);viewer.core.inventory.setItemInOffHand(null);viewer.core.location.setWorld(caster.world);f.players.put(viewer.core.id,viewer.player);f.particlesCommand.execute(caster.player,"fantasyparticles",new String[]{"all","off"});PartyBookNativeTest.drain(f);caster.particleCalls.clear();viewer.particleCalls.clear();use(f,caster);drain(f,2);assertTrue(caster.particleCalls.isEmpty(),"The opted-out caster receives no War Banner particles");assertTrue(viewer.particleCalls.stream().anyMatch(call->call[0]==Particle.DUST&&call[2].equals(16)),"An enabled nearby viewer retains War Banner visuals");assertEquals(200L,f.delayedTicks.getLast());assertEquals(.7,resistance.value(),1e-9);assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("war_banner"));
var enemy=mock(LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(enemy.getLocation()).thenReturn(new Location(caster.world,4,65,0));when(caster.world.getUID()).thenReturn(UUID.randomUUID());var source=DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(ally.player).withDirectEntity(ally.player).build();var hit=new EntityDamageByEntityEvent(ally.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(hit);assertEquals(12.5,hit.getDamage(),1e-9);f.dispatcher.accept(new EntityDeathEvent(enemy,source,new ArrayList<>(),0));drain(f,4);assertEquals(BigInteger.valueOf(5),storage(f).find(caster.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage(f).find(ally.core.record.id()).get().orElseThrow().experience());
PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","war_banner"));drain(f,2);var viewer=new FantasyStickLifecycleNativeTest.Actor(data);viewer.core.inventory.setItemInOffHand(null);viewer.core.location.setWorld(caster.world);f.players.put(viewer.core.id,viewer.player);PartyBookNativeTest.disableAllParticles(f,caster);caster.particleCalls.clear();viewer.particleCalls.clear();use(f,caster);drain(f,2);assertTrue(caster.particleCalls.isEmpty(),"The opted-out caster receives no War Banner particles");assertTrue(viewer.particleCalls.stream().anyMatch(call->call[0]==Particle.DUST&&call[2].equals(16)),"An enabled nearby viewer retains War Banner visuals");assertEquals(200L,f.delayedTicks.getLast());assertEquals(.7,resistance.value(),1e-9);assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("war_banner"));
var enemy=mock(LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(enemy.getLocation()).thenReturn(new Location(caster.world,4,65,0));when(caster.world.getUID()).thenReturn(UUID.randomUUID());var source=DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(ally.player).withDirectEntity(ally.player).build();var hit=new EntityDamageByEntityEvent(ally.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(hit);assertEquals(12.5,hit.getDamage(),1e-9);
class LateVeto implements org.bukkit.event.Listener{
@org.bukkit.event.EventHandler(priority=org.bukkit.event.EventPriority.HIGHEST)
public void cancel(EntityDamageByEntityEvent event){if(event.getEntity()==enemy)event.setCancelled(true);}
}
f.dispatcher.register(new LateVeto(),f.plugin);
var cancelled=hit(ally.player,enemy,10);f.dispatcher.accept(cancelled);assertTrue(cancelled.isCancelled());
f.dispatcher.accept(new EntityDeathEvent(enemy,source,new ArrayList<>(),0));drain(f,4);assertEquals(BigInteger.valueOf(5),storage(f).find(caster.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage(f).find(ally.core.record.id()).get().orElseThrow().experience());
f.delayed.getLast().run();assertEquals(.2,resistance.value(),1e-9,"Owned cleanup preserves unrelated knockback modifiers");var expired=new EntityDamageByEntityEvent(ally.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(expired);assertEquals(10,expired.getDamage(),1e-9);
}
var records=events(data);
for(var type:List.of("ability.learned","ability.assigned","ability.cast","ability.resolved","combat.boosted"))
assertEquals(1,abilityEvents(records,type).size(),type);
assertEquals(2,abilityEvents(records,"effect.applied").size(),"Fixed banner and the actually eligible ally benefit");
assertEquals(2,abilityEvents(records,"effect.ended").size(),"Expiry closes only actually owned benefits");
var boosted=abilityEvents(records,"combat.boosted").getFirst().getAsJsonObject("data");
assertEquals("2.5",boosted.get("actual_amount").getAsString());
assertEquals("12.5",boosted.get("final_amount").getAsString());
assertTrue(boosted.has("target_location"));
}
private static List<com.google.gson.JsonObject> events(Path root)throws Exception{
var values=new ArrayList<com.google.gson.JsonObject>();
try(var files=java.nio.file.Files.walk(root.resolve("events"))){
for(var file:files.filter(path->path.toString().endsWith(".jsonl")).toList())
for(var line:java.nio.file.Files.readAllLines(file))
if(!line.isBlank())values.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
return values;
}
private static List<com.google.gson.JsonObject> abilityEvents(List<com.google.gson.JsonObject> events,String suffix){
return events.stream().filter(event->event.get("type").getAsString().endsWith(suffix)
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("war_banner")).toList();
}
@Test void invalidOrNonTopPlacementRefusesBeforeCooldown()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,true,false);f.players.put(caster.core.id,caster.player);f.activate(caster);use(f,caster);drain(f,2);assertFalse(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("war_banner"));placement(caster,12,BlockFace.NORTH);use(f,caster);drain(f,2);assertFalse(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("war_banner"));placement(caster);use(f,caster);drain(f,2);assertTrue(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("war_banner"));}
assertEquals(1,abilityEvents(events(data),"ability.cast").size(),"Only the actual solid-top placement commits a cast");
}
@Test void fixedCylinderObstructionLifecycleOverlapAndRallyUseStrongestOnly()throws Exception{
@@ -65,7 +97,21 @@ class WarBannerNativeTest {
for(String[] update:List.of(new String[]{WarBannerSettings.COOLDOWN,"3"},new String[]{WarBannerSettings.PLACEMENT_RANGE,"11"},new String[]{WarBannerSettings.RADIUS,"9"},new String[]{WarBannerSettings.VERTICAL,"5"},new String[]{WarBannerSettings.DURATION,"9"},new String[]{WarBannerSettings.DAMAGE_BONUS,"35"},new String[]{WarBannerSettings.KNOCKBACK_RESISTANCE,"60"})){f.command.execute(sender.port,"fantasystick",new String[]{"config",update[0],update[1]});var response=f.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(response);response.run();}
var saved=registry.find("war_banner").orElseThrow();assertEquals(java.time.Duration.ofSeconds(3),saved.cooldown());assertEquals(new BigDecimal("35"),saved.parameters().get("damage-bonus-percent"));assertEquals(new BigDecimal("60"),saved.parameters().get("knockback-resistance-percent"));
var caster=new FantasyStickLifecycleNativeTest.Actor(data);var ally=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,true,false);f.players.put(caster.core.id,caster.player);f.players.put(ally.core.id,ally.player);f.activate(caster);f.activate(ally);party(f,caster,ally);placement(caster,11);nearby(caster,List.of(ally.player));var resistance=attribute(ally.player);use(f,caster);drain(f,2);assertEquals(180L,f.delayedTicks.getLast());assertEquals(.6,resistance.value(),1e-9);var enemy=mock(LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());var hit=hit(ally.player,enemy,10);f.dispatcher.accept(hit);assertEquals(13.5,hit.getDamage(),1e-9);
try(var channel=java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
java.nio.file.StandardOpenOption.CREATE,java.nio.file.StandardOpenOption.WRITE);
var lock=channel.lock()){
assertTrue(lock.isValid());
f.command.execute(sender.port,"fstickadmin",new String[]{"config",WarBannerSettings.RADIUS,"10"});
var failed=f.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(failed);failed.run();
}
assertEquals("9",((WarBannerSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"warBannerSettings")).value(WarBannerSettings.RADIUS));
assertTrue(sender.messages.getLast().contains("save failed"));
f.command.execute(sender.port,"fantasystick",new String[]{"config",WarBannerSettings.RADIUS,"0"});assertTrue(sender.messages.getLast().contains("invalid"));f.command.execute(sender.port,"fantasystick",new String[]{"config",WarBannerSettings.RADIUS,"9","extra"});assertTrue(sender.messages.getLast().contains("Usage"));f.plugin.setEnabled(false);f.plugin.setEnabled(true);var reloaded=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");assertEquals(new BigDecimal("35"),reloaded.find("war_banner").orElseThrow().parameters().get("damage-bonus-percent"));}
var config=events(data).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().startsWith("war-banner-")).toList();
assertEquals(8,config.size(),"Authorized read and seven saved updates, without denied/invalid successes");
}
private record AttributeFixture(Set<AttributeModifier> modifiers){double value(){return modifiers.stream().mapToDouble(AttributeModifier::getAmount).sum();}}
@@ -78,7 +78,7 @@ class DistributionTest {
"adventure-champion-reward-xp: 150", "adventure-champion-reward-coins: 40")) {
assertTrue(defaults.contains(setting), "Missing packaged adventure default: " + setting);
}
for(String setting:java.util.List.of("rare-price-reskill: 2000","rare-stock-reskill-min: 0","rare-stock-reskill-max: 1","rare-price-magic-core: 3000","rare-stock-magic-core-min: 0","rare-stock-magic-core-max: 3"))assertTrue(defaults.contains(setting),"Missing packaged Reskill default: "+setting);
for(String setting:java.util.List.of("rare-price-reskill: 2000","rare-stock-reskill-min: 0","rare-stock-reskill-max: 1","rare-price-magic-core: 3000","rare-stock-magic-core-min: 0","rare-stock-magic-core-max: 3","rare-price-phoenix-down: 1000","rare-stock-phoenix-down-min: 0","rare-stock-phoenix-down-max: 2","rare-phoenix-down-invulnerability-seconds: 5"))assertTrue(defaults.contains(setting),"Missing packaged Reskill default: "+setting);
assertFalse(defaults.contains("rare-reskill-healing-percent"));
assertTrue(defaults.contains("great-sword-base-damage: 7"));
assertTrue(defaults.contains("great-sword-recovery-seconds: 0.75"));
@@ -0,0 +1,61 @@
package games.dmg.fantasystick.events;
import static org.junit.jupiter.api.Assertions.*;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class AbilityCastEventBridgeTest {
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-09-25T04:00:00Z"), ZoneOffset.UTC);
@Test void explicitEffectEvidenceCannotReplaceTheOwnedAbilityIdentityOrOutcome() {
var records = new ArrayList<EventInput>();
var bridge = new AbilityCastEventBridge(records::add, CLOCK);
var action = UUID.randomUUID();
var cast = new AbilityCastEventBridge.Cast(action,
new EventInput.Context(UUID.randomUUID(), "Account", UUID.randomUUID(), "Hero", UUID.randomUUID(), null),
"test_focus", null, null, Map.of(), CLOCK.instant());
var application = bridge.effectApplied(cast, "test_effect", null,
Map.of("ability_id", "forged", "outcome", "forged", "action_kind", "forged", "actual_amount", 2));
bridge.effectEnded(cast, application, "test_effect", "expired");
assertEquals(2, records.size());
var applied = records.getFirst();
assertEquals("test_focus", applied.data().get("ability_id"));
assertEquals("applied", applied.data().get("outcome"));
assertEquals("ability_cast", applied.data().get("action_kind"));
assertEquals(2, applied.data().get("actual_amount"));
assertEquals(application.toString(), applied.data().get("application_id"));
assertEquals(application.toString(), records.getLast().data().get("application_id"));
assertNotEquals(applied.id(), records.getLast().id());
assertEquals(action, applied.actionId());
assertEquals(action, records.getLast().actionId());
}
@Test void ownerCanResolvePriorNativeCancellationWithoutInventingACastOrEffect() {
var records = new ArrayList<EventInput>();
var bridge = new AbilityCastEventBridge(records::add, CLOCK);
var cast = new AbilityCastEventBridge.Cast(UUID.randomUUID(),
new EventInput.Context(UUID.randomUUID(), "Account", UUID.randomUUID(), "Hero", UUID.randomUUID(), null),
"test_focus", null, null, Map.of(), CLOCK.instant());
bridge.resolved(cast, "cancelled", "prior_native_cancellation");
assertEquals(1, records.size());
assertEquals("ability.resolved", records.getFirst().type());
assertEquals("cancelled", records.getFirst().data().get("outcome"));
assertEquals("prior_native_cancellation", records.getFirst().data().get("reason"));
}
@Test void aBrokenSinkCannotChangeAnEffectApplicationOrResolution() {
var bridge = new AbilityCastEventBridge(event -> { throw new IllegalStateException("writer unavailable"); }, CLOCK);
var cast = new AbilityCastEventBridge.Cast(UUID.randomUUID(),
new EventInput.Context(UUID.randomUUID(), "Account", UUID.randomUUID(), "Hero", UUID.randomUUID(), null),
"test_focus", null, null, Map.of(), CLOCK.instant());
var application = assertDoesNotThrow(() -> bridge.effectApplied(cast, "native_effect", null, Map.of("actual_amount", 1)));
assertDoesNotThrow(() -> bridge.effectEnded(cast, application, "native_effect", "expired"));
assertDoesNotThrow(() -> bridge.resolved(cast, "completed", "effect_finished_without_hit_claim"));
}
}
@@ -0,0 +1,52 @@
package games.dmg.fantasystick.events;
import static org.junit.jupiter.api.Assertions.*;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class AbilityConfigEventBridgeTest {
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-09-25T04:00:00Z"), ZoneOffset.UTC);
@Test void authorizedReadAndAcknowledgedChangeHaveDistinctTypedRecords() {
var events = new ArrayList<EventInput>();
var bridge = new AbilityConfigEventBridge(events::add, CLOCK);
var actor = new AbilityConfigEventBridge.Actor("console", null, null);
bridge.read(actor, "fireball", "fireball-cooldown-seconds", "1.2");
bridge.changed(actor, "fireball", "fireball-cooldown-seconds", "1.2", "2");
assertEquals(2, events.size());
assertEquals("config.read", events.getFirst().type());
assertEquals("config.changed", events.getLast().type());
assertNotEquals(events.getFirst().id(), events.getLast().id());
assertEquals("fireball", events.getLast().data().get("ability_id"));
assertEquals("1.2", events.getLast().data().get("before"));
assertEquals("2", events.getLast().data().get("after"));
assertEquals("future_casts_only", events.getLast().data().get("application"));
assertNull(events.getLast().context(), "Console is not a fictional player or character");
}
@Test void identityCaptureFailureCannotBlockAnAuthorizedReadOrSave() {
var bridge = new AbilityConfigEventBridge(event -> fail("No unsupported identity may be emitted"), CLOCK);
var player = (org.bukkit.entity.Player) java.lang.reflect.Proxy.newProxyInstance(
org.bukkit.entity.Player.class.getClassLoader(), new Class<?>[]{org.bukkit.entity.Player.class},
(proxy, method, args) -> {
if (method.getName().equals("getUniqueId")) throw new IllegalStateException("identity unavailable");
return null;
});
var actor = assertDoesNotThrow(() -> bridge.capture(player));
assertNull(actor);
assertDoesNotThrow(() -> bridge.read(actor, "fireball", "fireball-range-blocks", "32"));
assertDoesNotThrow(() -> bridge.changed(actor, "fireball", "fireball-range-blocks", "32", "40"));
}
@Test void aBrokenLogSinkCannotRefuseAnAlreadyAuthorizedConfigurationAction() {
var bridge = new AbilityConfigEventBridge(event -> { throw new IllegalStateException("writer down"); }, CLOCK);
var actor = new AbilityConfigEventBridge.Actor("player", UUID.randomUUID(), "Admin");
assertDoesNotThrow(() -> bridge.read(actor, "fireball", "fireball-range-blocks", "32"));
assertDoesNotThrow(() -> bridge.changed(actor, "fireball", "fireball-range-blocks", "32", "40"));
}
}
@@ -0,0 +1,13 @@
package games.dmg.fantasystick.events;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.progression.CoinRules;
import games.dmg.fantasystick.progression.ProgressionRules;
import java.time.Clock;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class CoinEventBridgeTest {
@Test void eventFailureCannotChangeInitializationOrFinalDeathAuthority(){var record=new games.dmg.fantasystick.crafting.CharacterFactory().create(ProgressionRules.defaults());var dead=new games.dmg.fantasystick.persistence.CharacterRecord(record.id(),record.name(),record.revision().add(java.math.BigInteger.ONE),record.stats(),record.level(),record.experience(),record.unspentPoints(),java.math.BigDecimal.ZERO,record.learned(),record.loadout(),record.passives(),record.cooldowns(),record.restrictedKills(),true,UUID.randomUUID(),Clock.systemUTC().instant(),java.math.BigInteger.ZERO,true,record.rewardReceipts(),record.coins(),record.gear(),record.sheetGeneration());var bridge=new CoinEventBridge(event->{throw new IllegalStateException("disk");},Clock.systemUTC());assertDoesNotThrow(()->{bridge.initialized(record,"craft",CoinRules.defaults());bridge.lost(record,dead,dead.deathId(),dead.diedAt());bridge.configRead("console",null,null,"starting-coins-min","1");});}
}
@@ -0,0 +1,21 @@
package games.dmg.fantasystick.events;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.CombatRewardRules;
import games.dmg.fantasystick.progression.ProgressionRules;
import java.math.BigInteger;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class CombatRewardEventBridgeTest {
@Test void sinkFailureCannotChangeAnAcknowledgedRewardAndIdentityIsStable(){var before=new games.dmg.fantasystick.crafting.CharacterFactory().create(ProgressionRules.defaults());var receipt=UUID.randomUUID();var position=new CombatRewardRules.KillPosition(UUID.randomUUID(),java.math.BigDecimal.ZERO,java.math.BigDecimal.valueOf(64),java.math.BigDecimal.ZERO);var history=new HashMap<>(before.restrictedKills());history.put(position.world(),position);var receipts=new HashSet<>(before.rewardReceipts());receipts.add(receipt);var after=new CharacterRecord(before.id(),before.name(),before.revision().add(BigInteger.ONE),before.stats(),before.level(),before.experience().add(BigInteger.TEN),before.unspentPoints(),before.health(),before.learned(),before.loadout(),before.passives(),before.cooldowns(),history,false,before.deathId(),before.diedAt(),before.coins().add(BigInteger.TEN),true,receipts,before.pvpCoinEscrow(),before.gear(),before.sheetGeneration());var award=new CharacterStorage.CombatAward(CharacterStorage.CombatAwardStatus.APPLIED,Optional.of(after),0);var capture=new CombatRewardEventBridge.Capture(UUID.randomUUID(),"zombie","ordinary","hostile",position,null,"mob_defeat",Instant.parse("2026-09-25T00:00:00Z"),java.util.List.of(before.id()),before.id().toString(),1,BigInteger.TEN,BigInteger.TEN);var bridge=new CombatRewardEventBridge(event->{throw new IllegalStateException("disk");},Clock.fixed(Instant.parse("2026-09-25T00:00:01Z"),ZoneOffset.UTC));assertDoesNotThrow(()->bridge.resolved(receipt,before.id(),BigInteger.TEN,BigInteger.TEN,position,CombatRewardRules.defaults(),capture,before,award,null,null,"not_applicable_offline","not_delivered_offline"));}
}
@@ -30,7 +30,7 @@ final class DownedEventBridgeTest {
events.reconcile(List.of(pending, resolved));
assertEquals(List.of("character.downed", "character.downed", "ability.cast",
"character.downed_resolved", "ability.resolved", "action.resolved", "effect.ended"),
"character.downed_resolved", "ability.resolved", "action.resolved", "effect.applied", "effect.ended"),
first.stream().map(EventInput::type).toList());
assertEquals(first.stream().map(EventInput::id).toList(),
captured.subList(first.size(), captured.size()).stream().map(EventInput::id).toList());
@@ -56,7 +56,10 @@ final class DownedEventBridgeTest {
assertEquals(true, resolution.data().get("line_of_sight"));
assertEquals("decision_won", first.get(4).data().get("outcome"));
assertEquals("completed", first.get(5).data().get("outcome"));
assertEquals(resolved.protectionUntil(), first.get(6).time());
assertEquals("revive_downed_invulnerability",first.get(6).data().get("effect_id"));
assertEquals(resolved.protectionUntil(),first.get(6).data().get("protection_until"));
assertEquals("applied",first.get(6).data().get("outcome"));
assertEquals(resolved.protectionUntil(), first.get(7).time());
assertFalse(first.stream().anyMatch(event -> event.type().contains("phoenix")));
}
@@ -77,6 +80,11 @@ final class DownedEventBridgeTest {
assertEquals("4",((java.util.Map<?,?>)((java.util.List<?>)first.get(1).data().get("final_rewards")).getFirst()).get("coins"));
}
@Test void phoenixResolutionWaitsForNativeAcknowledgementAndRetainsStableExactUseEvidence(){
var captured=new ArrayList<EventInput>();var events=new DownedEventBridge(captured::add,Clock.fixed(NOW,ZoneOffset.UTC));var subject=episode(DownedEpisode.Phase.PENDING,null,null,null,null,false);var operation=UUID.randomUUID();var proof=new DownedEpisode.Phoenix(BigInteger.TWO,BigInteger.ONE,new java.math.BigDecimal("1"),new java.math.BigDecimal("10"),new java.math.BigDecimal("10"),"downed_menu");var decision=new DownedEpisode.Decision("phoenix_down",subject.player(),subject.playerName(),subject.character(),subject.characterName(),subject.session(),operation,NOW.minusSeconds(1),null,null,null,proof);var resolved=episode(DownedEpisode.Phase.RESOLVED,DownedEpisode.Outcome.REVIVED,operation,NOW,NOW.plusSeconds(5),false,decision);
events.reconcile(List.of(resolved));assertEquals(List.of("character.downed"),captured.stream().map(EventInput::type).toList());captured.clear();events.outcomePublished(resolved);var first=List.copyOf(captured);events.outcomePublished(resolved);assertEquals(List.of("character.downed_resolved","action.resolved"),first.stream().map(EventInput::type).toList());assertEquals(first.stream().map(EventInput::id).toList(),captured.subList(first.size(),captured.size()).stream().map(EventInput::id).toList());var data=first.getFirst().data();assertEquals("phoenix_down",data.get("outcome"));assertEquals("downed_menu",data.get("selection_entry_point"));assertEquals("phoenix-down",data.get("item_id"));assertEquals("2",data.get("quantity_before"));assertEquals("1",data.get("quantity_after"));assertEquals("1",data.get("health_before"));assertEquals("10",data.get("health_after"));assertEquals(operation.toString(),data.get("item_use_operation_id"));assertEquals("phoenix_down_native_application",first.get(1).data().get("action_kind"));captured.clear();events.protectionEnded(resolved);assertEquals("phoenix_down_invulnerability",captured.getFirst().data().get("effect_id"));
}
@Test void transientPreventionAndConfigFactsRemainTruthfulAndLoggingFailureCannotEscape() {
var captured = new ArrayList<EventInput>();
var events = new DownedEventBridge(captured::add, Clock.fixed(NOW, ZoneOffset.UTC));
@@ -59,6 +59,27 @@ final class LocalEventLogTest {
}
}
@Test void preservesRealNonPlayerAdminActorWithoutAllowingAContextFreePlayerImpersonation() throws Exception {
var clock = Clock.fixed(Instant.parse("2026-09-25T05:00:00Z"), ZoneOffset.UTC);
try (var events = LocalEventLog.open(data, clock,
new LocalEventLog.RuntimeInfo("Season 4", "26.2", "test"), 8, 134_217_728L,
message -> fail(message))) {
events.ready().toCompletableFuture().get();
for (var actor : java.util.List.of("console", "command_block", "player")) {
var action = UUID.randomUUID();
assertEquals(LocalEventLog.Delivery.EXPORTED,
events.emit(new EventInput(action, action, clock.instant(), "config.read", null,
Map.of("actor_kind", actor, "key", actor))).toCompletableFuture().get());
}
}
var lines = jsonLines(data.resolve("events"));
for (var actor : java.util.List.of("console", "command_block"))
assertTrue(java.util.Arrays.stream(lines).anyMatch(line -> line.contains("\"key\":\"" + actor + "\"")
&& line.contains("\"actor_kind\":\"" + actor + "\"")));
assertTrue(java.util.Arrays.stream(lines).anyMatch(line -> line.contains("\"key\":\"player\"")
&& line.contains("\"actor_kind\":\"system\"")));
}
@Test void pendingEvidenceAloneReportsRecoveryWithoutInventingPriorShutdown() throws Exception {
var root=data.resolve("events");Files.createDirectories(root.resolve("pending"));var server=UUID.randomUUID();Files.writeString(root.resolve("server-id"),server+"\n");var event=UUID.randomUUID();var exact="{\"specversion\":\"1.0\",\"id\":\""+event+"\",\"source\":\"urn:minecraft-server:"+server+"\",\"type\":\"games.dmg.minecraft.fantasystick.action.observed\",\"time\":\"2026-09-24T20:00:00Z\",\"datacontenttype\":\"application/json\",\"data\":{\"schema_version\":1}}\n";Files.writeString(root.resolve("pending/"+event+".pending"),exact);
try(var recovered=LocalEventLog.open(data,Clock.fixed(Instant.parse("2026-09-25T00:00:00Z"),ZoneOffset.UTC),new LocalEventLog.RuntimeInfo("Season 4","26.2","test"),8,134_217_728L,message->fail(message))){assertEquals(LocalEventLog.Delivery.EXPORTED,recovered.ready().toCompletableFuture().get());}
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff. Show more