Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ac4867821 | ||
|
|
b0996b0c6e | ||
|
|
850a04c4a0 | ||
|
|
439c1b9cdd | ||
|
|
2ae8f1ddf9 | ||
|
|
912c077da1 | ||
|
|
9b378b4237 | ||
|
|
93897ce516 | ||
|
|
fc49ee1cfb | ||
|
|
ee7d9036a2 | ||
|
|
4616ee80e3 | ||
|
|
e6b66d9e34 | ||
|
|
fc90af3a62 | ||
|
|
f6d95ca338 | ||
|
|
db4c3b2842 | ||
|
|
830049b339 | ||
|
|
fcbc2814c2 | ||
|
|
897a0606ba | ||
|
|
c13c3093cf | ||
|
|
bb8d1857b4 | ||
|
|
c7fce1a53d | ||
|
|
af7c75b6ed | ||
|
|
39881ad6fc | ||
|
|
9737ed0ddb | ||
|
|
53366b3c50 | ||
|
|
84205c0c14 | ||
|
|
2cc053e6ff | ||
|
|
3f104cb0e2 | ||
|
|
13828054e5 | ||
|
|
39c58ef4d9 | ||
|
|
ec73018f93 | ||
|
|
b86f703d14 | ||
|
|
2231dd058a | ||
|
|
ff28f2e98d | ||
|
|
21877f6820 | ||
|
|
228de3d08f | ||
|
|
e3bc854481 | ||
|
|
3163a491a0 | ||
|
|
08d13d2192 | ||
|
|
1d0cdc8f0f | ||
|
|
9674a8e5e6 | ||
|
|
dd75e6a76f | ||
|
|
22d92b5bbf | ||
|
|
26ccc95cdd | ||
|
|
8ae8cdb603 | ||
|
|
d18a41af6c | ||
|
|
6428ab999b | ||
|
|
6bb58cd52d | ||
|
|
25eb6e3a6b | ||
|
|
51b055da12 | ||
|
|
60ccb34ab6 | ||
|
|
0bdb43b2bb | ||
|
|
ae741496b3 | ||
|
|
fa917e57ba | ||
|
|
689877403f | ||
|
|
936e221c81 | ||
|
|
819a966160 | ||
|
|
1926dbb9f7 | ||
|
|
c776dca248 | ||
|
|
bce5391b70 | ||
|
|
f183d7daaa | ||
|
|
c960dbcf7b | ||
|
|
fa87b44cc8 | ||
|
|
70ac5f0801 | ||
|
|
d8efc41fb6 |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# Fantasy Stick
|
||||
|
||||
**Transferable characters and standalone names (US-001), equipment restoration (US-002), inventory safety (US-003), paginated character inspection (US-005), native session health/defeat (US-006), book parties/native damage protection (US-012), and switch delay (US-023) have automated implementation coverage. The full RPG remains in development and is not deployment-ready.**
|
||||
**Transferable characters, equipment restoration, inventory safety, character inspection, native session health, durable downed decisions and combat Revive, parties, and switch delay have automated implementation coverage. The full RPG remains in development and is not deployment-ready.**
|
||||
|
||||
Fantasy Stick is the proposed transferable-character RPG plugin for Social Minecraft. Its approved design uses Paper character sheets, native equipment storage and a book/basic-stick/seven-ability hotbar. Crafting, native-health offhand sessions, read-only character books and book-managed parties with native damage protection are assembled; active food overrides, stat/ability book actions, combat progression and spells remain incomplete. The server-independent progression core now tests initial stats/points, stat purchases, CON's equal maximum/current-health gain, capped XP and `MAX` presentation. YAML persistence, backend death/revival transitions and a native signed-book factory are also present. Saved-sheet crafting and accepted anvil-name persistence are wired. Stat purchases and spell execution remain incomplete.
|
||||
|
||||
@@ -20,6 +20,14 @@ Fantasy Stick is the proposed transferable-character RPG plugin for Social Minec
|
||||
|
||||
Build output is `build/libs/purpur-fantasy-stick-<version>.jar`. Server APIs, tests, credentials, and Node tooling are not bundled in the plugin. Runtime identity is `FantasyStick`, entrypoint `games.dmg.fantasystick.FantasyStickPlugin`, data folder `FantasyStick`, namespace `fantasystick`. Startup registers the manual crafting controller and automated-Crafter guard before the character-sheet recipe. Equipment sessions and permission-gated administration are registered; spell execution remains absent.
|
||||
|
||||
## 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. 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.
|
||||
|
||||
These files have no automatic deletion, compression or retention limit, so disk use grows until operators manage it under a separately approved policy. Current generic records include stable account/character/session IDs and precise locations; later feature-owned event slices may include complete public and recognized private-message text. Restrict event files and backups to operators accordingly. They are not encrypted or cryptographically tamper-evident, and feedback/particle preferences do not disable auditing.
|
||||
|
||||
## Character storage and keepsakes
|
||||
|
||||
`YamlCharacterRepository` stores schema-version-1 records at `<plugin data directory>/characters/<character UUID>.yml`. Character identity, name, progress, seven-slot loadout, ability cooldowns, explicit `dead` flag and latest death ID/time are preserved; the record has no holder-owned equipment or ordinary-health/nutrition snapshot. Integer values and decimal health retain exact precision, and timestamps use UTC RFC 3339 strings. Unknown learned ability IDs and unlearned cooldowns survive reload.
|
||||
@@ -175,11 +183,13 @@ 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 fantasy defeat records authoritative death once, removes session state, restores ordinary health to its current maximum and attempts return to the entry location. Ordinary health is restored before health-scale setters can send or queue client health packets: cancelled defeat must not deliver zero health and strand an otherwise alive player on a death screen. Refused/failed return leaves the player at the current location with a warning, not an alternate destination or invulnerability. A public red burst marks the defeat location. After the death acknowledgment, guarded conversion replaces the unchanged sheet stack with one signed Tail book. New Tails group ordinary-sized name/level/XP/points/stats on a summary page, then give each learned ability its own section with saved ID, assigned ability slots and cooldown deadline. Unknown IDs, saved unlearned cooldowns, empty abilities and death time stay explicit; long content continues onto additional pages without truncation. No ability descriptions are invented and existing books are not rewritten. Static Unicode-safe Tail pagination survives native serialization without a plugin; signed books use a different codec from the 100-page writable-book format. Stale or ambiguous conversion never overwrites another item or automatically reissues a book.
|
||||
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.
|
||||
|
||||
Interrupted recovery requires matching player/session receipt and native proof. It preserves current native modifier ownership and does not resurrect expired effects. Repeated/stale recovery cannot replay ordinary health or recreate an already released claim. This is **not crash-atomic** across player, inventory and plugin files; unresolved native setter phases and unacknowledged progress may require inspection. Legacy equipment-only receipts remain readable but do not grant health-restoration authority. Older binaries do not understand the new optional ordinary-vitals receipt field; do not downgrade across unresolved newer receipts.
|
||||
|
||||
Active hunger/exhaustion suppression and food-regeneration discrimination remain US-026 work. Book stat purchases, XP delivery, basic/learned attacks and Revive remain separate unfinished stories. The preceding equipment/progression checkpoint sections are historical evidence, not the current health scope.
|
||||
The preceding equipment/progression checkpoint sections retain their historical evidence; current downed, Revive, nutrition, progression and combat behavior is governed by their later delivered stories and native integration tests.
|
||||
|
||||
## Character inspection and inventory safety
|
||||
|
||||
|
||||
@@ -51,6 +51,18 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.shop.ArmorShopMenus armorShopMenus;
|
||||
private org.bukkit.scheduler.BukkitTask armorMarketPulse;
|
||||
private org.bukkit.scheduler.BukkitTask armorMenuPulse;
|
||||
private games.dmg.fantasystick.persistence.RareSettings rareSettings;
|
||||
private games.dmg.fantasystick.shop.RareInventoryDirectory rareInventory;
|
||||
private games.dmg.fantasystick.shop.RareMerchantDirectory rareMerchants;
|
||||
private games.dmg.fantasystick.shop.RareMerchantController rareMerchantController;
|
||||
private games.dmg.fantasystick.shop.RareMarket rareMarket;
|
||||
private games.dmg.fantasystick.shop.RareShop rareShop;
|
||||
private games.dmg.fantasystick.shop.RareShopMenus rareShopMenus;
|
||||
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;
|
||||
private games.dmg.fantasystick.session.AdminCharacterRecovery characterRecovery;
|
||||
@@ -112,6 +124,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings;
|
||||
private games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings;
|
||||
private games.dmg.fantasystick.ability.GreatSword greatSword;
|
||||
private games.dmg.fantasystick.persistence.GreatSwordSettings greatSwordSettings;
|
||||
private games.dmg.fantasystick.ability.Revive revive;
|
||||
private games.dmg.fantasystick.persistence.RevivalStore revivals;
|
||||
private games.dmg.fantasystick.session.AdminRevival adminRevival;
|
||||
@@ -130,6 +143,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.adventure.FishingDirectory fishing;
|
||||
private games.dmg.fantasystick.adventure.FishingController fishingController;
|
||||
private games.dmg.fantasystick.dungeon.DungeonDirectory dungeons;
|
||||
private games.dmg.fantasystick.session.DownedDirectory downed;
|
||||
private games.dmg.fantasystick.session.DownedController downedController;
|
||||
private games.dmg.fantasystick.persistence.DownedSettings downedSettings;
|
||||
private games.dmg.fantasystick.dungeon.DungeonController dungeonController;
|
||||
private games.dmg.fantasystick.dungeon.DungeonCommands dungeonCommands;
|
||||
private games.dmg.fantasystick.persistence.SheetResourcePackSettings sheetResourcePackSettings;
|
||||
@@ -147,8 +163,44 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.passive.LowLightVision lowLightVision;
|
||||
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.AdminRevivalEventBridge adminRevivalEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> adminRevivalAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.PartyEventBridge partyAuditEvents;
|
||||
private games.dmg.fantasystick.events.CombatRewardEventBridge combatRewardEvents;
|
||||
private games.dmg.fantasystick.events.CoinEventBridge coinEvents;
|
||||
private games.dmg.fantasystick.events.SessionEventBridge sessionAuditEvents;
|
||||
private games.dmg.fantasystick.events.NutritionEventBridge nutritionAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> nutritionAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.SwitchDelayEventBridge switchAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> switchAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.SwitchConfigEventBridge switchConfigEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> switchConfigAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> identityAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.EquipmentEventBridge equipmentAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> equipmentAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.SessionRecoveryEventBridge sessionRecoveryAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> sessionRecoveryAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.MessageEventBridge messageAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
|
||||
|
||||
private void dispatchOwnedHealing(org.bukkit.event.entity.EntityRegainHealthEvent event) {
|
||||
var policy = nutritionPolicy;
|
||||
if (policy == null) getServer().getPluginManager().callEvent(event);
|
||||
else policy.dispatchOwnedHealing(event, getServer().getPluginManager()::callEvent);
|
||||
}
|
||||
|
||||
@Override public void onEnable() {
|
||||
try {
|
||||
Object generation = new Object();
|
||||
@@ -178,6 +230,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var partyEconomyConfiguration = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.PartyEconomySettings.keys()) partyEconomyConfiguration.put(key, getConfig().getString(key,games.dmg.fantasystick.persistence.PartyEconomySettings.defaultValue(key)));
|
||||
var gearConfiguration=new java.util.LinkedHashMap<String,String>();for(String key:games.dmg.fantasystick.persistence.GearSettings.keys())gearConfiguration.put(key,getConfig().getString(key,games.dmg.fantasystick.persistence.GearSettings.defaultValue(key)));
|
||||
var rareConfiguration=new java.util.LinkedHashMap<String,String>();for(String key:games.dmg.fantasystick.persistence.RareSettings.keys())rareConfiguration.put(key,getConfig().getString(key,games.dmg.fantasystick.persistence.RareSettings.defaultValue(key)));
|
||||
var downedConfiguration=new java.util.LinkedHashMap<String,String>();for(String key:games.dmg.fantasystick.persistence.DownedSettings.keys())downedConfiguration.put(key,getConfig().getString(key,games.dmg.fantasystick.persistence.DownedSettings.defaultValue(key)));
|
||||
var coinConfiguration = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.CoinSettings.keys()) {
|
||||
coinConfiguration.put(key, getConfig().getString(key,
|
||||
@@ -212,7 +266,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var rewardRules = combatRewardRules(hardMobXp);
|
||||
var fireballConfiguration = fireballConfiguration();
|
||||
var fireballInitial = games.dmg.fantasystick.persistence.FireballSettings.parse(fireballConfiguration);
|
||||
var greatSwordSettings = greatSwordSettings();
|
||||
var greatSwordConfiguration = greatSwordConfiguration();
|
||||
var greatSwordInitial = games.dmg.fantasystick.persistence.GreatSwordSettings.parse(greatSwordConfiguration);
|
||||
var frostShardConfiguration = frostShardConfiguration();
|
||||
var frostShardInitial = games.dmg.fantasystick.persistence.FrostShardSettings.parse(frostShardConfiguration);
|
||||
var longbowConfiguration = longbowConfiguration();
|
||||
@@ -252,6 +307,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
partyEconomySettings = new games.dmg.fantasystick.persistence.PartyEconomySettings(
|
||||
getDataFolder().toPath(), partyEconomyConfiguration);
|
||||
gearSettings=new games.dmg.fantasystick.persistence.GearSettings(getDataFolder().toPath(),gearConfiguration);
|
||||
rareSettings=new games.dmg.fantasystick.persistence.RareSettings(getDataFolder().toPath(),rareConfiguration);
|
||||
downedSettings=new games.dmg.fantasystick.persistence.DownedSettings(getDataFolder().toPath(),downedConfiguration);
|
||||
coinSettings = new games.dmg.fantasystick.persistence.CoinSettings(getDataFolder().toPath(), coinConfiguration);
|
||||
hardMobXpSettings = new games.dmg.fantasystick.persistence.HardMobXpSettings(
|
||||
getDataFolder().toPath(), hardMobXp.toString());
|
||||
@@ -266,6 +323,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
new games.dmg.fantasystick.persistence.ArmorPurchaseOperationStore(getDataFolder().toPath()),
|
||||
characters,gearSettings::rules,Clock.systemUTC(),
|
||||
(minimum,maximum)->(int)armorRandom.nextLong(minimum,(long)maximum+1));
|
||||
try{rareInventory=new games.dmg.fantasystick.shop.RareInventoryDirectory(new games.dmg.fantasystick.persistence.YamlRareInventoryRepository(getDataFolder().toPath()));}catch(java.io.IOException invalidRareInventory){throw new java.io.UncheckedIOException("Rare Inventory could not be loaded",invalidRareInventory);}
|
||||
var rareRandom=java.util.random.RandomGenerator.getDefault();rareMarket=new games.dmg.fantasystick.shop.RareMarket(new games.dmg.fantasystick.persistence.YamlRareCatalogRepository(getDataFolder().toPath()),new games.dmg.fantasystick.persistence.RarePurchaseOperationStore(getDataFolder().toPath()),characters,rareInventory,rareSettings::rules,Clock.systemUTC(),(minimum,maximum)->(int)rareRandom.nextLong(minimum,(long)maximum+1));
|
||||
crafting = new ManualCraftingController(characters, () -> factory.create(rules),
|
||||
task -> getServer().getScheduler().runTask(this, task),
|
||||
player -> new PlayerSheetDrops(player, event -> getServer().getPluginManager().callEvent(event)),
|
||||
@@ -329,8 +388,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
dungeons = new games.dmg.fantasystick.dungeon.DungeonDirectory(
|
||||
new games.dmg.fantasystick.persistence.YamlDungeonRepository(getDataFolder().toPath()),
|
||||
() -> adventureQuestSettings.rules().dungeonRoomLinkDistance());
|
||||
downed = new games.dmg.fantasystick.session.DownedDirectory(getDataFolder().toPath(),Clock.systemUTC());
|
||||
if(adventureRepository.requiresDungeonRewardMigration())adventureQuests.migrateLegacyDungeonRewards(id->dungeons.find(id).flatMap(value->value.rooms().stream().filter(room->room.boss()!=null).findFirst().map(room->new games.dmg.fantasystick.adventure.AdventureQuestDirectory.LegacyDungeonReward(new games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward(value.completionReward().xp(),value.completionReward().coins()),room.id(),dungeons.runView(id,adventureQuests.snapshot().catalog()==null?new java.util.UUID(0,0):adventureQuests.snapshot().catalog().revision()).map(games.dmg.fantasystick.dungeon.DungeonState.Run::defeated).orElse(false))))).join();
|
||||
armorers=new games.dmg.fantasystick.shop.FantasyArmorerDirectory(new games.dmg.fantasystick.persistence.YamlFantasyArmorerRepository(getDataFolder().toPath()));
|
||||
rareMerchants=new games.dmg.fantasystick.shop.RareMerchantDirectory(new games.dmg.fantasystick.persistence.YamlRareMerchantRepository(getDataFolder().toPath()));
|
||||
} catch (java.io.IOException invalidManagedState) {
|
||||
throw new java.io.UncheckedIOException("Persistent party, shop, training or adventure state could not be loaded", invalidManagedState);
|
||||
}
|
||||
@@ -341,6 +402,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
notice -> notifyPlayer(generation, notice), diagnostics, presentation, adventureSettings::enabled, gameModes);
|
||||
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);
|
||||
parties = new games.dmg.fantasystick.party.PartyRoster(characterParties, sessions, partySettings::maximum);
|
||||
@@ -353,7 +418,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();
|
||||
@@ -370,7 +435,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
Clock.systemUTC(), nextTick, id -> getServer().getPlayer(id),
|
||||
problem -> getLogger().log(Level.WARNING, "Combat reward failed for character " + problem.character(), problem.cause()),
|
||||
particleDelivery);
|
||||
sessions.attachPvpDefeat(combatExperience::consumePvpDefeat);
|
||||
sessions.attachDefeatCapture(combatExperience::captureDownedDefeat);
|
||||
sessions.attachDownedRewardAllocation(combatExperience::allocateDownedDefeat);
|
||||
sessions.attachDownedDefeatRewards(combatExperience::settleDownedDefeat);
|
||||
abilityRegistry = new games.dmg.fantasystick.ability.AbilityRegistry();
|
||||
fireballSettings = new games.dmg.fantasystick.persistence.FireballSettings(
|
||||
getDataFolder().toPath(), fireballConfiguration);
|
||||
@@ -388,7 +455,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
secondWindSettings = new games.dmg.fantasystick.persistence.SecondWindSettings(
|
||||
getDataFolder().toPath(), secondWindConfiguration);
|
||||
secondWind = new games.dmg.fantasystick.ability.SecondWind(secondWindInitial, characters, sessions, nextTick,
|
||||
event -> getServer().getPluginManager().callEvent(event), games.dmg.fantasystick.session.NativeHealthLimit::read);
|
||||
this::dispatchOwnedHealing, games.dmg.fantasystick.session.NativeHealthLimit::read);
|
||||
stoneSkinSettings = new games.dmg.fantasystick.persistence.StoneSkinSettings(
|
||||
getDataFolder().toPath(), stoneSkinConfiguration);
|
||||
combatMitigation = new games.dmg.fantasystick.ability.CombatMitigation();
|
||||
@@ -409,7 +476,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
mendingLightSettings = new games.dmg.fantasystick.persistence.MendingLightSettings(
|
||||
getDataFolder().toPath(), mendingLightConfiguration);
|
||||
mendingLight = new games.dmg.fantasystick.ability.MendingLight(mendingLightInitial, characters, sessions, parties,
|
||||
nextTick, event -> getServer().getPluginManager().callEvent(event),
|
||||
nextTick, this::dispatchOwnedHealing,
|
||||
games.dmg.fantasystick.session.NativeHealthLimit::read,
|
||||
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()));
|
||||
purifySettings = new games.dmg.fantasystick.persistence.PurifySettings(getDataFolder().toPath(), purifyConfiguration);
|
||||
@@ -435,7 +502,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
sanctuarySettings = new games.dmg.fantasystick.persistence.SanctuarySettings(
|
||||
getDataFolder().toPath(), sanctuaryConfiguration);
|
||||
sanctuary = new games.dmg.fantasystick.ability.Sanctuary(sanctuaryInitial, characters, sessions, parties,
|
||||
combatMitigation, nextTick, event -> getServer().getPluginManager().callEvent(event),
|
||||
combatMitigation, nextTick, this::dispatchOwnedHealing,
|
||||
games.dmg.fantasystick.session.NativeHealthLimit::read,
|
||||
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()),
|
||||
particleDelivery);
|
||||
@@ -445,11 +512,14 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
warBannerInitial, sessions, parties, outgoingDamageBonus, particleDelivery);
|
||||
meteorSettings = new games.dmg.fantasystick.persistence.MeteorSettings(getDataFolder().toPath(), meteorConfiguration);
|
||||
meteor = new games.dmg.fantasystick.ability.Meteor(meteorInitial, sessions, parties, particleDelivery);
|
||||
greatSword = new games.dmg.fantasystick.ability.GreatSword(greatSwordSettings);
|
||||
greatSwordSettings = new games.dmg.fantasystick.persistence.GreatSwordSettings(
|
||||
getDataFolder().toPath(), greatSwordConfiguration);
|
||||
greatSword = new games.dmg.fantasystick.ability.GreatSword(greatSwordInitial);
|
||||
revivals = new games.dmg.fantasystick.persistence.RevivalStore(getDataFolder().toPath());
|
||||
revive = new games.dmg.fantasystick.ability.Revive(characters, revivals, Clock.systemUTC(), nextTick,
|
||||
(size, title) -> getServer().createInventory(null, size, title),
|
||||
problem -> getLogger().log(Level.WARNING, "Revive transition failed", problem));
|
||||
revive.attachDowned(downedController,downedSettings::protection);
|
||||
abilityRegistry.register(fireball.definition());
|
||||
abilityRegistry.register(frostShard.definition());
|
||||
abilityRegistry.register(longbow.definition());
|
||||
@@ -474,11 +544,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var gearItems=new games.dmg.fantasystick.items.GearItems(this);sessions.attachGear(gearItems);
|
||||
vitals = new games.dmg.fantasystick.session.SessionVitals();
|
||||
sessions.attachVitals(vitals);
|
||||
nutritionPolicy = new games.dmg.fantasystick.session.NutritionPolicy(sessions);
|
||||
nutritionPolicy = new games.dmg.fantasystick.session.NutritionPolicy(sessions, nextTick);
|
||||
sessions.attachNutritionPolicy(nutritionPolicy);
|
||||
outOfCombatRecoverySettings = new games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings(
|
||||
getDataFolder().toPath(), recoveryConfiguration);
|
||||
outOfCombatRecovery = new games.dmg.fantasystick.session.OutOfCombatRecovery(recoveryInitial, characters, sessions,
|
||||
Clock.systemUTC(), nextTick, event -> getServer().getPluginManager().callEvent(event),
|
||||
Clock.systemUTC(), nextTick, this::dispatchOwnedHealing,
|
||||
games.dmg.fantasystick.session.NativeHealthLimit::read,
|
||||
problem -> getLogger().log(Level.WARNING,
|
||||
"Out-of-combat recovery failed for character " + problem.character(), problem.cause()), combatFeedback);
|
||||
@@ -491,7 +562,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
healthStealSettings = new games.dmg.fantasystick.persistence.HealthStealSettings(
|
||||
getDataFolder().toPath(), healthStealInitial.healing().toString());
|
||||
healthSteal = new games.dmg.fantasystick.passive.HealthSteal(healthStealInitial, characters, sessions, nextTick,
|
||||
event -> getServer().getPluginManager().callEvent(event), games.dmg.fantasystick.session.NativeHealthLimit::read,
|
||||
this::dispatchOwnedHealing, games.dmg.fantasystick.session.NativeHealthLimit::read,
|
||||
problem -> getLogger().log(Level.WARNING, "Health Steal failed for character " + problem.character(), problem.cause()),
|
||||
combatFeedback);
|
||||
speedI = new games.dmg.fantasystick.passive.SpeedI(sessions);
|
||||
@@ -560,6 +631,14 @@ 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,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;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,this::dispatchOwnedHealing,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));
|
||||
rareMagicCores=new games.dmg.fantasystick.shop.RareMagicCoreService(rareInventory,characters,sessions,nextTick,failure->getLogger().log(Level.WARNING,"Rare Magic Core use failed",failure));
|
||||
rarePotions.install();rareReskills.install();rareMagicCores.install();books.attachRareInventory(rareInventory,rarePotions,rareReskills,rareMagicCores);
|
||||
adventureQuestController = new games.dmg.fantasystick.adventure.AdventureQuestController(this,
|
||||
adventureQuests, adventureQuestSettings, sessions, parties, characters, rules, nextTick,
|
||||
Clock.systemUTC(), java.util.random.RandomGenerator.getDefault(), particleDelivery);
|
||||
@@ -574,10 +653,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var bossRecoverySettings=outOfCombatRecoverySettings;var bossQuickerRegeneration=quickerRegeneration;var bossQuickerRegenerationII=quickerRegenerationII;var bossHealthStealSettings=healthStealSettings;
|
||||
dungeonController = new games.dmg.fantasystick.dungeon.DungeonController(this,dungeons,adventureQuests,sessions,parties,characters,rules,nextTick,java.util.random.RandomGenerator.getDefault(),abilityRegistry,passiveRegistry,bossRecoverySettings::settings,record->{if(!record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationI.ID))return java.math.BigInteger.ZERO;var bonus=bossQuickerRegeneration.bonus();return record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationII.ID)?bonus.add(bossQuickerRegenerationII.bonus()):bonus;},()->bossHealthStealSettings.settings().healing(),particleDelivery);
|
||||
dungeonController.registerFishing(fishingController);
|
||||
var custodyDirectory=dungeons;
|
||||
sessions.attachUnavailableCharacter(character->custodyDirectory.custody(character).isPresent());
|
||||
characters.attachUnavailableCharacter(character->custodyDirectory.custody(character).isPresent());
|
||||
characterParties.attachUnavailableCharacter(character->custodyDirectory.custody(character).isPresent());
|
||||
var custodyDirectory=dungeons;var downedDirectory=downed;
|
||||
java.util.function.Predicate<java.util.UUID> unavailableCharacter=character->custodyDirectory.custody(character).isPresent()||downedDirectory.all().stream().anyMatch(value->value.character().equals(character)&&value.phase()==games.dmg.fantasystick.session.DownedEpisode.Phase.PENDING);
|
||||
sessions.attachUnavailableCharacter(unavailableCharacter);
|
||||
characters.attachUnavailableCharacter(unavailableCharacter);
|
||||
characterParties.attachUnavailableCharacter(unavailableCharacter);
|
||||
dungeonCommands = new games.dmg.fantasystick.dungeon.DungeonCommands(this,dungeons,dungeonController,adventureQuests,nextTick,characters::find,character->sessions.activeCharacter(character).isPresent(),character->characterParties.partyOf(character).isPresent());
|
||||
adventureQuests.registerDungeonRewards(site->dungeons.find(site.id()).map(value->new games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward(value.maximumXp(),value.maximumCoins())).orElse(new games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward(java.math.BigInteger.ZERO,java.math.BigInteger.ZERO)));
|
||||
adventureQuests.registerDungeonSites(()->dungeons.snapshot().dungeons().stream().filter(value->dungeons.eligibleForFutureOffer(value.id())).map(value->new games.dmg.fantasystick.adventure.AdventureQuestState.Site(value.id(),games.dmg.fantasystick.adventure.AdventureQuestState.SiteType.DUNGEON,value.name(),value.world(),value.entrance().x(),value.entrance().y(),value.entrance().z(),null,value.participationMode())).toList());
|
||||
@@ -588,7 +668,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
combatExperience.attachManagedSettlement(dungeonController::settleKill);
|
||||
combatExperience.attachExcluded(adventureQuestController::managed);
|
||||
sessionEvents.attachManagedInteractionTarget(entity -> partyRegistrarController.tagged(entity)
|
||||
|| armorerController.tagged(entity) || adventureQuestController.giver(entity));
|
||||
|| armorerController.tagged(entity) || rareMerchantController.tagged(entity) || adventureQuestController.giver(entity));
|
||||
admin = new EquipmentAdminCommand(receipts, sessions, getServer(), nextTick,
|
||||
problem -> getLogger().log(Level.WARNING, "Equipment admin " + problem.operation()
|
||||
+ " for player " + problem.player(), problem.cause()), adventureSettings,
|
||||
@@ -597,6 +677,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
stoneSkinSettings, stoneSkin, shieldBashSettings, shieldBash, hexSettings, hex,
|
||||
inspireSettings, inspire, abilityRegistry);
|
||||
admin.attachFireball(fireballSettings, fireball);
|
||||
admin.attachGreatSword(greatSwordSettings, greatSword);
|
||||
admin.attachMendingLight(mendingLightSettings, mendingLight);
|
||||
admin.attachPurify(purifySettings, purify);
|
||||
admin.attachGuardianBond(guardianBondSettings, guardianBond);
|
||||
@@ -620,6 +701,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
admin.attachGearSettings(gearSettings);
|
||||
admin.attachArmorers(armorers,armorerController);
|
||||
admin.attachArmorMarket(armorMarket);
|
||||
admin.attachRareMarket(rareMerchants,rareMerchantController,rareMarket,rareSettings);
|
||||
admin.attachDownedSettings(downedSettings);
|
||||
admin.attachCoinSettings(coinSettings);
|
||||
admin.attachHardMobXpSettings(hardMobXpSettings, combatExperience);
|
||||
admin.attachAdventureQuests(adventureQuestCommands, adventureQuestSettings);
|
||||
@@ -650,10 +733,117 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
"Missing packaged fstickadmin command");
|
||||
adminCommand.setExecutor(admin);
|
||||
adminCommand.setTabCompleter(admin);
|
||||
try {
|
||||
eventLog = games.dmg.fantasystick.events.LocalEventLog.open(getDataFolder().toPath(), Clock.systemUTC(),
|
||||
new games.dmg.fantasystick.events.LocalEventLog.RuntimeInfo("FantasyStick",
|
||||
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(frostShard::ownsAcceptedDamage);
|
||||
eventCapture.excludeOwnedDamage(longbow::ownsAcceptedDamage);
|
||||
eventCapture.excludeOwnedDamage(greatSword::ownsAcceptedDamage);
|
||||
eventCapture.excludeOwnedDamage(shieldBash::ownsAcceptedDamage);
|
||||
eventCapture.excludeOwnedDamage(guardianBond::ownsAcceptedRedirect);
|
||||
eventCapture.excludeOwnedDamage(frostNova::ownsAcceptedDamage);
|
||||
eventCapture.excludeOwnedDamage(chainLightning::ownsAcceptedDamage);
|
||||
eventCapture.excludeOwnedDamage(meteor::ownsAcceptedDamage);
|
||||
eventCapture.excludeOwnedDamage(localBasicAttack::ownsAcceptedDamage);
|
||||
localBasicAttack.attachEvents(new games.dmg.fantasystick.events.BasicAttackEventBridge(
|
||||
eventLog::emit, Clock.systemUTC()));
|
||||
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("great_sword");
|
||||
abilityEngine.enableCastEventsFor("frost_shard");
|
||||
abilityEngine.enableCastEventsFor("longbow");
|
||||
abilityEngine.enableCastEventsFor("quickstep");
|
||||
abilityEngine.enableCastEventsFor("second_wind");
|
||||
abilityEngine.enableCastEventsFor("stone_skin");
|
||||
abilityEngine.enableCastEventsFor("shield_bash");
|
||||
abilityEngine.enableCastEventsFor("hex");
|
||||
abilityEngine.enableCastEventsFor("inspire");
|
||||
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);
|
||||
adminRevivalEvents = new games.dmg.fantasystick.events.AdminRevivalEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
|
||||
failure -> getLogger().log(Level.WARNING, "Admin revival event intent failed", failure));
|
||||
admin.attachAdminRevivalEvents(adminRevivalEvents);
|
||||
revive.attachAdminRevivalEvents(adminRevivalEvents);
|
||||
partyAuditEvents = new games.dmg.fantasystick.events.PartyEventBridge(eventLog::emit, Clock.systemUTC());partyManagement.attachEvents(partyAuditEvents);admin.attachPartyEvents(partyAuditEvents);
|
||||
partyCombat.attachProtectionEvents(new games.dmg.fantasystick.events.PartyProtectionEventBridge(
|
||||
eventLog::emit, Clock.systemUTC()));
|
||||
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);
|
||||
nutritionAuditEvents = new games.dmg.fantasystick.events.NutritionEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
|
||||
failure -> getLogger().log(Level.WARNING, "Nutrition event intent failed", failure));
|
||||
sessions.attachNutritionEvents(nutritionAuditEvents);
|
||||
nutritionPolicy.attachEvents(nutritionAuditEvents);
|
||||
switchAuditEvents = new games.dmg.fantasystick.events.SwitchDelayEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
|
||||
failure -> getLogger().log(Level.WARNING, "Switch deadline event intent failed", failure));
|
||||
sessions.attachSwitchEvents(switchAuditEvents);
|
||||
switchConfigEvents = new games.dmg.fantasystick.events.SwitchConfigEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
|
||||
failure -> getLogger().log(Level.WARNING, "Switch configuration event intent failed", failure));
|
||||
admin.attachSwitchConfigEvents(switchConfigEvents);
|
||||
var identitySessions = sessions;
|
||||
identityAuditEvents = new games.dmg.fantasystick.events.CharacterIdentityEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
|
||||
playerId -> identitySessions.active(playerId).map(games.dmg.fantasystick.session.EquipmentSessions.Active::session),
|
||||
failure -> getLogger().log(Level.WARNING, "Character identity event intent failed", failure));
|
||||
crafting.attachIdentityEvents(identityAuditEvents);
|
||||
admin.attachCharacterIssueEvents(identityAuditEvents);
|
||||
anvilNames.attachIdentityEvents(identityAuditEvents);
|
||||
sessions.attachIdentityEvents(identityAuditEvents);
|
||||
equipmentAuditEvents = new games.dmg.fantasystick.events.EquipmentEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
|
||||
failure -> getLogger().log(Level.WARNING, "Equipment event intent failed", failure));
|
||||
sessions.attachEquipmentEvents(equipmentAuditEvents);
|
||||
sessionRecoveryAuditEvents = new games.dmg.fantasystick.events.SessionRecoveryEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
|
||||
failure -> getLogger().log(Level.WARNING, "Session recovery event intent failed", failure));
|
||||
admin.attachSessionRecoveryEvents(sessionRecoveryAuditEvents);
|
||||
sessions.attachSessionRecoveryEvents(sessionRecoveryAuditEvents);
|
||||
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; rareEvents = null; bookEvents = null; progressionEvents = null; reviveEvents = null; adminRevivalEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null; nutritionAuditEvents = null; switchAuditEvents = null; switchConfigEvents = null; identityAuditEvents = null; equipmentAuditEvents = null; sessionRecoveryAuditEvents = null; messageAuditEvents = null;
|
||||
getLogger().log(Level.WARNING,"Fantasy Stick local events are unavailable; gameplay remains enabled",failure);
|
||||
}
|
||||
getServer().getPluginManager().registerEvents(crafting, this);
|
||||
getServer().getPluginManager().registerEvents(guard, this);
|
||||
getServer().getPluginManager().registerEvents(anvilNames, this);
|
||||
getServer().getPluginManager().registerEvents(sessionEvents, this);
|
||||
getServer().getPluginManager().registerEvents(downedController, this);
|
||||
getServer().getPluginManager().registerEvents(characterRecovery, this);
|
||||
getServer().getPluginManager().registerEvents(chatNames, this);
|
||||
getServer().getPluginManager().registerEvents(partyEvents, this);
|
||||
@@ -663,12 +853,16 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getServer().getPluginManager().registerEvents(partyRegistrarMenus, this);
|
||||
getServer().getPluginManager().registerEvents(armorerController,this);
|
||||
getServer().getPluginManager().registerEvents(armorShopMenus,this);
|
||||
getServer().getPluginManager().registerEvents(rareMerchantController,this);
|
||||
getServer().getPluginManager().registerEvents(rareShopMenus,this);
|
||||
getServer().getPluginManager().registerEvents(adventureQuestController,this);
|
||||
getServer().getPluginManager().registerEvents(fishingController,this);
|
||||
getServer().getPluginManager().registerEvents(dungeonController,this);
|
||||
downedController.install();
|
||||
partyRegistrarController.install();
|
||||
trainingDummyController.install();
|
||||
armorerController.install();
|
||||
rareMerchantController.install();
|
||||
dungeonController.install();
|
||||
adventureQuestController.install();
|
||||
fishingController.install();
|
||||
@@ -677,6 +871,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
localArmorMarket.current().whenComplete((catalog,failure)->{
|
||||
if(failure!=null&&lifetime==generation)getLogger().log(Level.WARNING,"Scheduled Fantasy Armorer restock check failed",failure);});},20L,20L);
|
||||
armorMenuPulse=getServer().getScheduler().runTaskTimer(this,localArmorMenus::refreshOpen,1200L,1200L);
|
||||
var localRareMarket=rareMarket;rareMarketPulse=getServer().getScheduler().runTaskTimer(this,()->{if(lifetime!=generation)return;localRareMarket.current().whenComplete((catalog,failure)->{if(failure!=null&&lifetime==generation)getLogger().log(Level.WARNING,"Scheduled Rare Items restock check failed",failure);});},20L,20L);
|
||||
rareMenuPulse=getServer().getScheduler().runTaskTimer(this,localRareMenus::refreshOpen,1200L,1200L);
|
||||
partyCombat.install(this);
|
||||
getServer().getPluginManager().registerEvents(healthSteal, this);
|
||||
getServer().getPluginManager().registerEvents(combatExperience, this);
|
||||
@@ -715,11 +911,63 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
recipeRegistered = getServer().addRecipe(new CharacterSheets().recipe());
|
||||
if (!recipeRegistered) throw new IllegalStateException("Character-sheet recipe key is already registered");
|
||||
recipeDiscovery = new RecipeDiscovery(CharacterSheets.RECIPE, getLogger());
|
||||
if (eventLog != null) {
|
||||
var recipeEvents = new games.dmg.fantasystick.events.RecipeDiscoveryEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
|
||||
failure -> getLogger().log(Level.WARNING, "Recipe discovery event intent failed", failure));
|
||||
recipeDiscovery.attachEvents(recipeEvents);
|
||||
eventLog.ready(); // Queue startup recovery before any recipe event handoff.
|
||||
var replayNutritionEvents = nutritionAuditEvents;
|
||||
nutritionAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous nutrition event intent drain failed", failure);
|
||||
if (replayNutritionEvents != null) replayNutritionEvents.replay();
|
||||
});
|
||||
var replaySwitchConfigEvents = switchConfigEvents;
|
||||
switchConfigAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous switch configuration intent drain failed", failure);
|
||||
if (replaySwitchConfigEvents != null) replaySwitchConfigEvents.replay();
|
||||
});
|
||||
var replaySwitchEvents = switchAuditEvents;
|
||||
switchAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous switch event intent drain failed", failure);
|
||||
if (replaySwitchEvents != null) replaySwitchEvents.replay();
|
||||
});
|
||||
var replayAdminRevivalEvents = adminRevivalEvents;
|
||||
adminRevivalAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous admin revival event intent drain failed", failure);
|
||||
if (replayAdminRevivalEvents != null) replayAdminRevivalEvents.replay();
|
||||
});
|
||||
var replayIdentityEvents = identityAuditEvents;
|
||||
identityAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous identity event intent drain failed", failure);
|
||||
if (replayIdentityEvents != null) replayIdentityEvents.replay();
|
||||
});
|
||||
var replayEquipmentEvents = equipmentAuditEvents;
|
||||
equipmentAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous equipment event intent drain failed", failure);
|
||||
if (replayEquipmentEvents != null) replayEquipmentEvents.replay();
|
||||
});
|
||||
var replaySessionRecoveryEvents = sessionRecoveryAuditEvents;
|
||||
sessionRecoveryAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous recovery event intent drain failed", failure);
|
||||
if (replaySessionRecoveryEvents != null) replaySessionRecoveryEvents.replay();
|
||||
});
|
||||
recipeEvents.replay(); // Serial worker replays old intents before new discoveries are captured.
|
||||
}
|
||||
getServer().getPluginManager().registerEvents(recipeDiscovery, this);
|
||||
var onlinePlayers = List.copyOf(getServer().getOnlinePlayers());
|
||||
recipeDiscovery.discoverAll(onlinePlayers);
|
||||
for (var player : onlinePlayers) { sessions.arrive(player); revive.reconcile(player); }
|
||||
sheetResourcePack.deliverAll(onlinePlayers);
|
||||
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); }
|
||||
@@ -741,6 +989,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
particleDelivery = null;
|
||||
combatFeedback = null;
|
||||
var closingEvents = sessionEvents; sessionEvents = null;
|
||||
var closingEventLog = eventLog; eventLog = null; preferenceEvents = null;
|
||||
var closingNutritionAuditEvents = nutritionAuditEvents; nutritionAuditEvents = null;
|
||||
var closingSwitchAuditEvents = switchAuditEvents; switchAuditEvents = null;
|
||||
var closingSwitchConfigEvents = switchConfigEvents; switchConfigEvents = null;
|
||||
var closingIdentityAuditEvents = identityAuditEvents; identityAuditEvents = null;
|
||||
var closingAdminRevivalAuditEvents = adminRevivalEvents; adminRevivalEvents = null;
|
||||
var closingEquipmentAuditEvents = equipmentAuditEvents; equipmentAuditEvents = null;
|
||||
var closingSessionRecoveryAuditEvents = sessionRecoveryAuditEvents; sessionRecoveryAuditEvents = null;
|
||||
var closingEventCapture = eventCapture; eventCapture = null; var closingMessageAuditEvents = messageAuditEvents; messageAuditEvents = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; abilityEvents = null; passiveEvents = null; recoveryEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null;
|
||||
var closingBooks = books; books = null;
|
||||
var closingPartyEvents = partyEvents; partyEvents = null;
|
||||
var closingPartyCombat = partyCombat; partyCombat = null;
|
||||
@@ -754,13 +1011,17 @@ 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 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;
|
||||
var closingArmorShopMenus=armorShopMenus;armorShopMenus=null;armorShop=null;
|
||||
var closingArmorMarket=armorMarket;armorMarket=null;var closingArmorMarketPulse=armorMarketPulse;armorMarketPulse=null;
|
||||
var closingArmorMenuPulse=armorMenuPulse;armorMenuPulse=null;
|
||||
var closingRareSettings=rareSettings;rareSettings=null;var closingDownedSettings=downedSettings;downedSettings=null;var closingRareInventory=rareInventory;rareInventory=null;
|
||||
var closingRareMerchants=rareMerchants;rareMerchants=null;var closingRareMerchantController=rareMerchantController;rareMerchantController=null;
|
||||
var closingRareMarket=rareMarket;rareMarket=null;var closingRareShopMenus=rareShopMenus;rareShopMenus=null;rareShop=null;rarePotions=null;rareReskills=null;rareMagicCores=null;
|
||||
var closingRareMarketPulse=rareMarketPulse;rareMarketPulse=null;var closingRareMenuPulse=rareMenuPulse;rareMenuPulse=null;
|
||||
var closingCrafting = crafting; crafting = null;
|
||||
var closingGuard = guard; guard = null;
|
||||
var closingAnvil = anvilNames; anvilNames = null;
|
||||
@@ -777,6 +1038,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingAbilityEngine = abilityEngine; abilityEngine = null;
|
||||
var closingFireball = fireball; fireball = null;
|
||||
var closingFireballSettings = fireballSettings; fireballSettings = null;
|
||||
var closingGreatSwordSettings = greatSwordSettings; greatSwordSettings = null;
|
||||
var closingFrostShard = frostShard; frostShard = null;
|
||||
var closingFrostSettings = frostShardSettings; frostShardSettings = null;
|
||||
var closingLongbow = longbow; longbow = null;
|
||||
@@ -851,6 +1113,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (closingFeedbackPreferences != null) cleanup("feedback preferences", closingFeedbackPreferences::close);
|
||||
if (closingSettings != null) cleanup("configuration", closingSettings::close);
|
||||
if (closingFireballSettings != null) cleanup("Fireball configuration", closingFireballSettings::close);
|
||||
if (closingGreatSwordSettings != null) cleanup("Great Sword configuration", closingGreatSwordSettings::close);
|
||||
if (closingFrostSettings != null) cleanup("Frost Shard configuration", closingFrostSettings::close);
|
||||
if (closingLongbowSettings != null) cleanup("Longbow configuration", closingLongbowSettings::close);
|
||||
if (closingQuickstepSettings != null) cleanup("Quickstep configuration", closingQuickstepSettings::close);
|
||||
@@ -882,6 +1145,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (closingPartySettings != null) cleanup("party configuration", closingPartySettings::close);
|
||||
if (closingPartyEconomySettings != null) cleanup("party economy configuration", closingPartyEconomySettings::close);
|
||||
if(closingGearSettings!=null)cleanup("gear configuration",closingGearSettings::close);
|
||||
if(closingRareSettings!=null)cleanup("rare configuration",closingRareSettings::close);
|
||||
if(closingDownedSettings!=null)cleanup("downed configuration",closingDownedSettings::close);
|
||||
if (closingCoinSettings != null) cleanup("coin configuration", closingCoinSettings::close);
|
||||
if (closingHardMobXpSettings != null) cleanup("hard-mob XP configuration", closingHardMobXpSettings::close);
|
||||
if (closingAdventureQuestSettings != null) cleanup("adventure quest configuration", closingAdventureQuestSettings::close);
|
||||
@@ -891,6 +1156,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if(closingFishing!=null)cleanup("fishing persistence",closingFishing::close);
|
||||
if (closingDungeonController != null) cleanup("dungeons", closingDungeonController::close);
|
||||
if (closingDungeons != null) cleanup("dungeon persistence", closingDungeons::close);
|
||||
if (closingDownedController != null) cleanup("downed controller", closingDownedController::close);
|
||||
if (closingDowned != null) cleanup("downed persistence", closingDowned::close);
|
||||
if (closingSheetResourcePackSettings != null) cleanup("sheet resource-pack configuration", closingSheetResourcePackSettings::close);
|
||||
if (closingSheetResourcePack != null) cleanup("sheet resource-pack", closingSheetResourcePack::close);
|
||||
if (closingOutOfCombatRecoverySettings != null) cleanup("out-of-combat recovery configuration", closingOutOfCombatRecoverySettings::close);
|
||||
@@ -911,6 +1178,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
closingArmorers==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingArmorers.drained().toCompletableFuture(),
|
||||
closingArmorMarket==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingArmorMarket.drained().toCompletableFuture(),
|
||||
closingGearSettings==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingGearSettings.drained().toCompletableFuture(),
|
||||
closingRareSettings==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingRareSettings.drained().toCompletableFuture(),
|
||||
closingDownedSettings==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingDownedSettings.drained().toCompletableFuture(),
|
||||
closingRareInventory==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingRareInventory.drained().toCompletableFuture(),
|
||||
closingRareMerchants==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingRareMerchants.drained().toCompletableFuture(),
|
||||
closingRareMarket==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingRareMarket.drained().toCompletableFuture(),
|
||||
closingPartyEconomySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingPartyEconomySettings.drained().toCompletableFuture(),
|
||||
closingHardMobXpSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
@@ -922,10 +1194,14 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
closingFishing==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingFishing.drained().toCompletableFuture(),
|
||||
closingDungeons == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingDungeons.drained().toCompletableFuture(),
|
||||
closingDowned == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingDowned.drained().toCompletableFuture(),
|
||||
closingSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingSettings.drained().toCompletableFuture(),
|
||||
closingFireballSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingFireballSettings.drained().toCompletableFuture(),
|
||||
closingGreatSwordSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingGreatSwordSettings.drained().toCompletableFuture(),
|
||||
closingFrostSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingFrostSettings.drained().toCompletableFuture(),
|
||||
closingLongbowSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
@@ -1017,22 +1293,83 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if(closingArmorerController!=null)cleanup("armorer entities",closingArmorerController::close);
|
||||
if(closingArmorMarket!=null)cleanup("armor market",closingArmorMarket::close);
|
||||
if(closingArmorers!=null)cleanup("armorers",closingArmorers::close);
|
||||
if(closingRareMarketPulse!=null)cleanup("rare market schedule",closingRareMarketPulse::cancel);
|
||||
if(closingRareMenuPulse!=null)cleanup("rare menu schedule",closingRareMenuPulse::cancel);
|
||||
if(closingRareShopMenus!=null)cleanup("rare shop menus",closingRareShopMenus::close);
|
||||
if(closingRareMerchantController!=null)cleanup("rare merchant entities",closingRareMerchantController::close);
|
||||
if(closingRareMarket!=null)cleanup("rare market",closingRareMarket::close);
|
||||
if(closingRareInventory!=null){if(closingRareMarket==null)cleanup("rare inventory",closingRareInventory::close);else closingRareMarket.drained().whenComplete((ignored,failure)->cleanup("rare inventory",closingRareInventory::close));}
|
||||
if(closingRareMerchants!=null)cleanup("rare merchants",closingRareMerchants::close);
|
||||
if (closingPartyCombat != null) cleanup("party combat", closingPartyCombat::close);
|
||||
if (closingEvents != null) cleanup("session events", closingEvents::close);
|
||||
if (closingCrafting != null) cleanup("craft attempts", closingCrafting::close);
|
||||
if (closingSessions != null) cleanup("equipment sessions", closingSessions::close);
|
||||
if (closingRecipeDiscovery != null) cleanup("recipe discovery event intents", closingRecipeDiscovery::close);
|
||||
if (closingNutritionAuditEvents != null) cleanup("nutrition event intent drain", () -> {
|
||||
var sessionsDrained = closingSessions == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingSessions.drained();
|
||||
nutritionAuditDrain = sessionsDrained.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Nutrition owner drain failed", failure);
|
||||
closingNutritionAuditEvents.close();
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingNutritionAuditEvents.drained());
|
||||
});
|
||||
if (closingSwitchConfigEvents != null) cleanup("switch configuration event intent drain", () -> {
|
||||
var settingsDrained = closingSwitchDelaySettings == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
|
||||
: closingSwitchDelaySettings.drained();
|
||||
switchConfigAuditDrain = settingsDrained.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Switch setting drain failed", failure);
|
||||
closingSwitchConfigEvents.close();
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingSwitchConfigEvents.drained());
|
||||
});
|
||||
if (closingSwitchAuditEvents != null) cleanup("switch event intent drain", () -> {
|
||||
var sessionsDrained = closingSessions == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingSessions.drained();
|
||||
switchAuditDrain = sessionsDrained.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Switch exit drain failed", failure);
|
||||
closingSwitchAuditEvents.close();
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingSwitchAuditEvents.drained());
|
||||
});
|
||||
if (closingEquipmentAuditEvents != null) cleanup("equipment event intent drain", () -> {
|
||||
var sessionsDrained = closingSessions == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingSessions.drained();
|
||||
equipmentAuditDrain = sessionsDrained.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Equipment session drain failed", failure);
|
||||
closingEquipmentAuditEvents.close();
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingEquipmentAuditEvents.drained());
|
||||
});
|
||||
if (closingSessionRecoveryAuditEvents != null) cleanup("recovery event intent drain", () -> {
|
||||
var sessionsDrained = closingSessions == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingSessions.drained();
|
||||
sessionRecoveryAuditDrain = java.util.concurrent.CompletableFuture.allOf(
|
||||
sessionsDrained.toCompletableFuture(), configurationDrain.toCompletableFuture())
|
||||
.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Recovery owner drain failed", failure);
|
||||
closingSessionRecoveryAuditEvents.close();
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingSessionRecoveryAuditEvents.drained());
|
||||
});
|
||||
if (closingEventLog != null) cleanup("local events", closingEventLog::close);
|
||||
if (closingVitals != null) cleanup("native health hooks", closingVitals::close);
|
||||
unregister(closingModes);
|
||||
unregister(closingChat);
|
||||
unregister(closingEventCapture);
|
||||
unregister(closingMessageAuditEvents);
|
||||
unregister(closingPartyEvents);
|
||||
unregister(closingPartyCombat);
|
||||
unregister(closingPartyRegistrarController);
|
||||
unregister(closingTrainingDummyController);
|
||||
unregister(closingPartyRegistrarMenus);
|
||||
unregister(closingArmorerController);unregister(closingArmorShopMenus);
|
||||
unregister(closingRareMerchantController);unregister(closingRareShopMenus);
|
||||
unregister(closingAdventureQuestController);
|
||||
unregister(closingFishingController);
|
||||
unregister(closingDungeonController);
|
||||
unregister(closingDownedController);
|
||||
unregister(closingCombatExperience);
|
||||
unregister(closingHealthSteal);
|
||||
unregister(closingFireball);
|
||||
@@ -1061,6 +1398,24 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
recipeRegistered = false; // Retain ownership for a later retry if removal failed.
|
||||
});
|
||||
if (closingCharacters != null) cleanup("character storage", closingCharacters::close);
|
||||
if (closingAdminRevivalAuditEvents != null) cleanup("admin revival event intent drain", () -> {
|
||||
var charactersDrained = closingCharacters == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
|
||||
adminRevivalAuditDrain = charactersDrained.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Character save drain failed", failure);
|
||||
closingAdminRevivalAuditEvents.close();
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingAdminRevivalAuditEvents.drained());
|
||||
});
|
||||
if (closingIdentityAuditEvents != null) cleanup("character identity event intent drain", () -> {
|
||||
var charactersDrained = closingCharacters == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
|
||||
identityAuditDrain = charactersDrained.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Character save drain failed", failure);
|
||||
closingIdentityAuditEvents.close();
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingIdentityAuditEvents.drained());
|
||||
});
|
||||
if (closingRevivals != null) cleanup("revival storage", closingRevivals::close);
|
||||
// Never await IO on the server thread or close receipts ahead of accepted preparations.
|
||||
if (closingReceipts != null) cleanup("equipment receipt drain", () -> {
|
||||
@@ -1292,15 +1647,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private games.dmg.fantasystick.ability.GreatSword.Settings greatSwordSettings() {
|
||||
try {
|
||||
var base = new java.math.BigInteger(getConfig().getString("great-sword-base-damage", "7"));
|
||||
var seconds = new java.math.BigDecimal(getConfig().getString("great-sword-recovery-seconds", "0.75"));
|
||||
var recovery = java.time.Duration.ofNanos(seconds.movePointRight(9).toBigIntegerExact().longValueExact());
|
||||
return new games.dmg.fantasystick.ability.GreatSword.Settings(base, recovery);
|
||||
} catch (ArithmeticException | NumberFormatException invalid) {
|
||||
throw new IllegalArgumentException("Great Sword settings must use supported nonnegative numeric values", invalid);
|
||||
}
|
||||
private java.util.Map<String, String> greatSwordConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (var key : games.dmg.fantasystick.persistence.GreatSwordSettings.keys())
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.GreatSwordSettings.defaultValue(key)));
|
||||
games.dmg.fantasystick.persistence.GreatSwordSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private void notifyPlayer(Object generation, EquipmentSessions.Notice notice) {
|
||||
|
||||
@@ -31,7 +31,7 @@ public record AbilityDefinition(String id, String name, Material icon, List<Stri
|
||||
Objects.requireNonNull(effect);
|
||||
lore = List.copyOf(lore); requirements = Map.copyOf(requirements); parameters = Map.copyOf(parameters);
|
||||
if (!ID.matcher(id).matches() || name.isBlank() || icon.isAir() || lore.isEmpty()
|
||||
|| lore.stream().anyMatch(String::isBlank) || instructions.isBlank() || pointCost.signum() <= 0
|
||||
|| lore.stream().anyMatch(String::isBlank) || instructions.isBlank() || !pointCost.equals(BigInteger.ONE)
|
||||
|| cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0 || requirements.values().stream().anyMatch(value -> value.compareTo(BigInteger.TEN) < 0)
|
||||
|| parameters.entrySet().stream().anyMatch(entry -> !PARAMETER.matcher(entry.getKey()).matches()
|
||||
|| entry.getValue().signum() < 0 || !Double.isFinite(entry.getValue().doubleValue()))) {
|
||||
|
||||
@@ -43,11 +43,42 @@ 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);
|
||||
}
|
||||
|
||||
private void refuseCast(ControlInput input, CharacterRecord record, AbilityDefinition definition, String reason) {
|
||||
if (castEvents == null || record == null
|
||||
|| !(definition.id().equals("great_sword") || definition.id().equals("longbow")
|
||||
|| definition.id().equals("quickstep") || definition.id().equals("second_wind")
|
||||
|| definition.id().equals("shield_bash") || definition.id().equals("hex")
|
||||
|| definition.id().equals("inspire"))
|
||||
|| !eventOwnedAbilities.contains(definition.id())) return;
|
||||
resolve(castEvents.begin(input, record, definition), "refused", reason);
|
||||
}
|
||||
|
||||
public AbilityEngine(AbilityRegistry registry, CharacterStorage characters, EquipmentSessions sessions,
|
||||
PartyRoster parties, Clock clock, Consumer<Runnable> nextTick, AbilityScheduler scheduler,
|
||||
Consumer<AbilityReport> reports, Consumer<Failure> failures) {
|
||||
@@ -149,11 +180,13 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
var record = sessions.currentRecord(input.session()).orElse(null);
|
||||
if (record == null || !record.learned().contains(definition.id())
|
||||
|| input.slot() < 2 || !record.loadout().get(input.slot() - 2).equals(definition.id())) {
|
||||
refuseCast(input, record, definition, "not_learned_or_assigned");
|
||||
input.player().sendMessage("That ability is not learned and assigned to this control."); return;
|
||||
}
|
||||
Instant now = clock.instant();
|
||||
Instant existing = record.cooldowns().get(definition.id());
|
||||
if (existing != null && now.isBefore(existing)) {
|
||||
refuseCast(input, record, definition, "cooldown_active");
|
||||
input.player().sendMessage("" + definition.name() + " is ready in "
|
||||
+ Math.max(1, Duration.between(now, existing).toSeconds()) + " seconds."); return;
|
||||
}
|
||||
@@ -164,11 +197,15 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
input.player().sendMessage("That target could not be validated for " + definition.name() + "."); return;
|
||||
}
|
||||
if (!accepted) {
|
||||
refuseCast(input, record, definition, "target_ineligible");
|
||||
input.player().sendMessage("That target is not eligible for " + definition.name() + "."); return;
|
||||
}
|
||||
var key = new Key(input.session().character(), definition.id());
|
||||
Object token = new Object();
|
||||
if (pending.putIfAbsent(key, token) != null) { input.player().sendMessage("That cast is still being saved."); return; }
|
||||
if (pending.putIfAbsent(key, token) != null) {
|
||||
refuseCast(input, record, definition, "save_in_progress");
|
||||
input.player().sendMessage("That cast is still being saved."); return;
|
||||
}
|
||||
long generation = generations.getOrDefault(key, 0L);
|
||||
Instant readyAt;
|
||||
try { readyAt = now.plus(definition.cooldown()); }
|
||||
@@ -176,22 +213,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 +245,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 +255,32 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
return;
|
||||
}
|
||||
CharacterRecord saved = mutation.record().orElseThrow();
|
||||
boolean deferNativeCast = audit != null && definition.id().equals("quickstep");
|
||||
if (castEvents != null && !deferNativeCast) 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) {
|
||||
if (deferNativeCast) castEvents.cast(audit, saved, readyAt, definition);
|
||||
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())) {
|
||||
if (deferNativeCast) castEvents.cast(audit, saved, readyAt, definition);
|
||||
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,
|
||||
deferNativeCast ? saved : null, readyAt);
|
||||
active.computeIfAbsent(key, ignored -> new HashSet<>()).add(runtime);
|
||||
try {
|
||||
definition.effect().activate(runtime);
|
||||
runtime.publishCast();
|
||||
if (definition.activation() == AbilityDefinition.Activation.IMMEDIATE) runtime.finish();
|
||||
} catch (RuntimeException effectFailure) {
|
||||
runtime.close(); report(key.character(), key.ability(), effectFailure);
|
||||
runtime.publishCast();
|
||||
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 +349,42 @@ 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 CharacterRecord deferredCastRecord;
|
||||
private final Instant deferredDeadline;
|
||||
private final Map<String, Object> castEvidence = new HashMap<>();
|
||||
private boolean castPublished;
|
||||
private final Map<UUID, String> observedEffects = new HashMap<>();
|
||||
private final Map<UUID, Map<String, Object>> observedEndEvidence = new HashMap<>();
|
||||
private final Set<AutoCloseable> owned = new HashSet<>();
|
||||
private String outcome;
|
||||
private Map<String, Object> resolutionEvidence = Map.of();
|
||||
private boolean finished;
|
||||
private boolean closing;
|
||||
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,
|
||||
CharacterRecord deferredCastRecord, Instant deferredDeadline) {
|
||||
this.key = key; this.input = input; this.definition = definition; this.generation = generation;
|
||||
this.audit = audit; this.deferredCastRecord = deferredCastRecord; this.deferredDeadline = deferredDeadline;
|
||||
}
|
||||
void publishCast() {
|
||||
if (deferredCastRecord == null || castPublished) return;
|
||||
castPublished = true;
|
||||
if (castEvents != null) castEvents.cast(audit, deferredCastRecord, deferredDeadline, definition,
|
||||
Map.copyOf(castEvidence));
|
||||
}
|
||||
@Override public void castEvidence(Map<String, Object> evidence) {
|
||||
if (!castPublished && !finished && deferredCastRecord != null)
|
||||
castEvidence.putAll(Objects.requireNonNull(evidence));
|
||||
}
|
||||
@Override public void resolutionEvidence(Map<String, Object> evidence) {
|
||||
if (!finished) {
|
||||
var combined = new java.util.LinkedHashMap<>(resolutionEvidence);
|
||||
combined.putAll(Objects.requireNonNull(evidence));
|
||||
resolutionEvidence = Map.copyOf(combined);
|
||||
}
|
||||
}
|
||||
@Override public AbilityDefinition definition() { return definition; }
|
||||
@Override public ControlInput input() { return input; }
|
||||
@@ -306,7 +392,7 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
return sessions.currentRecord(input.session()).orElseThrow(() -> new IllegalStateException("Ability session ended"));
|
||||
}
|
||||
@Override public boolean stillCurrent() {
|
||||
return !closed && !finished && input.stillCurrent() && sessions.current(input.session())
|
||||
return !closed && !finished && !closing && input.stillCurrent() && sessions.current(input.session())
|
||||
&& generations.getOrDefault(key, 0L) == generation
|
||||
&& sessions.currentRecord(input.session()).filter(record -> record.learned().contains(key.ability())).isPresent();
|
||||
}
|
||||
@@ -361,12 +447,110 @@ 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 java.time.Instant now() { return clock.instant(); }
|
||||
@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 acceptedOutgoingMitigation(UUID application, Entity debuffedAttacker,
|
||||
Entity protectedTarget, double incoming, double remaining, double actual) {
|
||||
if (castEvents == null || audit == null) return;
|
||||
var evidence = new java.util.LinkedHashMap<String, Object>();
|
||||
if (protectedTarget instanceof Player player)
|
||||
sessions.active(player.getUniqueId()).filter(sessions::current).ifPresent(active -> {
|
||||
evidence.put("protected_character_id", active.character().toString());
|
||||
evidence.put("protected_session_id", active.session().toString());
|
||||
});
|
||||
castEvents.outgoingPrevented(audit, application, debuffedAttacker, protectedTarget,
|
||||
incoming, remaining, actual, evidence);
|
||||
}
|
||||
@Override public void acceptedAbsorption(UUID application, Player target, Entity attacker,
|
||||
double incoming, double before, double after,
|
||||
double consumed, double remaining,
|
||||
org.bukkit.Location targetAtHit, org.bukkit.Location attackerAtHit) {
|
||||
if (castEvents == null || audit == null) return;
|
||||
try {
|
||||
var aided = sessions.active(target.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
castEvents.absorbed(audit, application, target, aided, attacker, incoming,
|
||||
before, after, consumed, remaining, targetAtHit, attackerAtHit);
|
||||
} catch (RuntimeException ignored) { /* Recording cannot change native absorption or support. */ }
|
||||
}
|
||||
@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) {
|
||||
effectEnded(application, reason, observedEndEvidence.getOrDefault(application, Map.of()));
|
||||
}
|
||||
@Override public void effectEndEvidence(UUID application, Map<String, Object> evidence) {
|
||||
if (observedEffects.containsKey(application))
|
||||
observedEndEvidence.put(application, Map.copyOf(Objects.requireNonNull(evidence)));
|
||||
}
|
||||
@Override public void effectEnded(UUID application, String reason, Map<String, Object> evidence) {
|
||||
Objects.requireNonNull(application); Objects.requireNonNull(reason); Objects.requireNonNull(evidence);
|
||||
var effectId = observedEffects.remove(application);
|
||||
observedEndEvidence.remove(application);
|
||||
if (effectId != null && castEvents != null)
|
||||
castEvents.effectEnded(audit, application, effectId, reason, evidence);
|
||||
}
|
||||
@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", "obstructed", "replaced").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 (finished || closing) return;
|
||||
closing = true;
|
||||
publishCast();
|
||||
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); }
|
||||
finished = true;
|
||||
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;
|
||||
@@ -15,12 +17,58 @@ public interface AbilityExecution extends AutoCloseable {
|
||||
ControlInput input();
|
||||
games.dmg.fantasystick.persistence.CharacterRecord character();
|
||||
boolean stillCurrent();
|
||||
/** Native-thread clock shared with cast and effect audit timestamps. */
|
||||
default java.time.Instant now() { return java.time.Instant.now(); }
|
||||
List<Entity> eligible(Collection<? extends Entity> candidates, int maximum);
|
||||
AbilityScheduler.Task later(Duration delay, Runnable action);
|
||||
void own(AutoCloseable resource);
|
||||
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 only the winning owned outgoing reduction after final uncancelled native damage. */
|
||||
default void acceptedOutgoingMitigation(UUID application, Entity debuffedAttacker, Entity protectedTarget,
|
||||
double incoming, double remaining, double actual) { }
|
||||
/** Record only observed native consumption of this execution's owned temporary health. */
|
||||
default void acceptedAbsorption(UUID application, org.bukkit.entity.Player target, Entity attacker,
|
||||
double incoming, double before, double after, double consumed,
|
||||
double remaining, org.bukkit.Location targetAtHit,
|
||||
org.bukkit.Location attackerAtHit) { }
|
||||
/** 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);
|
||||
/** End an observed effect with actual native cleanup/restoration evidence. */
|
||||
default void effectEnded(UUID applicationId, String reason, Map<String, Object> evidence) {
|
||||
effectEnded(applicationId, reason);
|
||||
}
|
||||
/** Capture remaining owned effect state during resource cleanup for the terminal effect record. */
|
||||
default void effectEndEvidence(UUID applicationId, Map<String, Object> evidence) { }
|
||||
/** Attach an observed native admission to the one pending cast record; never publish guessed velocity. */
|
||||
default void castEvidence(Map<String, Object> evidence) { }
|
||||
/** Attach actual final native position to an already chosen or interrupted resolution during cleanup. */
|
||||
default void resolutionEvidence(Map<String, Object> evidence) { }
|
||||
/** 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.Consumer<Accepted> acceptedObservation; boolean closed;
|
||||
Grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current,
|
||||
java.util.function.Consumer<Accepted> 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(); }
|
||||
@@ -32,7 +35,8 @@ public final class CombatMitigation implements Listener, AutoCloseable {
|
||||
if (owned != null) { owned.remove(this); if (owned.isEmpty()) grants.remove(player.getUniqueId()); }
|
||||
}
|
||||
}
|
||||
private record Prevention(Grant grant, BigDecimal amount) { }
|
||||
public record Accepted(EntityDamageByEntityEvent event, double incoming, double remaining, double prevented) { }
|
||||
private record Prevention(Grant grant, double incoming, double reduced) { }
|
||||
private final Map<UUID, List<Grant>> grants = new java.util.HashMap<>();
|
||||
private final Map<EntityDamageEvent, Prevention> pending = new IdentityHashMap<>();
|
||||
private boolean closed;
|
||||
@@ -42,10 +46,23 @@ 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);
|
||||
}
|
||||
|
||||
/** Legacy owner observation; keep existing prevention consumers unchanged. */
|
||||
public AutoCloseable grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current,
|
||||
java.util.function.BiConsumer<EntityDamageEvent, Double> observation) {
|
||||
return grantAccepted(execution, player, percent, current,
|
||||
observation == null ? null : accepted -> observation.accept(accepted.event(), accepted.prevented()));
|
||||
}
|
||||
|
||||
/** An owner may observe the selected, positive native prevention and its original hit value. */
|
||||
public AutoCloseable grantAccepted(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current,
|
||||
java.util.function.Consumer<Accepted> 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;
|
||||
}
|
||||
@@ -66,8 +83,7 @@ public final class CombatMitigation implements Listener, AutoCloseable {
|
||||
.multiply(BigDecimal.ONE.subtract(strongest.percent.movePointLeft(2))).doubleValue();
|
||||
if (!Double.isFinite(reduced) || reduced < 0) return;
|
||||
event.setDamage(reduced);
|
||||
BigDecimal prevented = BigDecimal.valueOf(original - reduced);
|
||||
if (prevented.signum() > 0) pending.put(event, new Prevention(strongest, prevented));
|
||||
if (original > reduced) pending.put(event, new Prevention(strongest, original, reduced));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
@@ -75,7 +91,16 @@ public final class CombatMitigation implements Listener, AutoCloseable {
|
||||
var prevention = pending.remove(event);
|
||||
if (prevention == null || event.isCancelled() || event.getFinalDamage() <= 0
|
||||
|| !prevention.grant.valid()) return;
|
||||
prevention.grant.execution.report(AbilityReport.Type.PREVENTION, event.getEntity(), prevention.amount);
|
||||
double remaining = event.getDamage();
|
||||
double actual = Math.min(prevention.incoming - prevention.reduced, prevention.incoming - remaining);
|
||||
if (!Double.isFinite(remaining) || !Double.isFinite(actual) || actual <= 0) return;
|
||||
try { prevention.grant.execution.report(AbilityReport.Type.PREVENTION, event.getEntity(),
|
||||
BigDecimal.valueOf(actual)); }
|
||||
catch (RuntimeException ignored) { /* Feedback cannot interrupt an accepted native hit or its audit. */ }
|
||||
if (prevention.grant.acceptedObservation != null) try {
|
||||
prevention.grant.acceptedObservation.accept(new Accepted((EntityDamageByEntityEvent) event,
|
||||
prevention.incoming, remaining, actual));
|
||||
} 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;
|
||||
|
||||
@@ -6,6 +6,7 @@ import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
@@ -58,7 +59,9 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
final BigInteger intelligence;
|
||||
final Settings settings;
|
||||
LivingEntity target;
|
||||
double requested;
|
||||
boolean accepted;
|
||||
boolean nativeCancelled;
|
||||
boolean finished;
|
||||
State(Arrow projectile, AbilityExecution execution, Location origin, BigInteger intelligence, Settings settings) {
|
||||
this.projectile = projectile; this.execution = execution; this.origin = origin;
|
||||
@@ -74,13 +77,33 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
private final LivingEntity target;
|
||||
private final PotionEffect previous;
|
||||
private final PotionEffect applied;
|
||||
private final AbilityExecution execution;
|
||||
private final long started = System.nanoTime();
|
||||
private UUID application;
|
||||
private String endReason = "interrupted";
|
||||
private boolean owned;
|
||||
private boolean closed;
|
||||
SlowLease(LivingEntity target, PotionEffect applied) {
|
||||
SlowLease(LivingEntity target, PotionEffect applied, AbilityExecution execution) {
|
||||
this.target = target; this.previous = target.getPotionEffect(PotionEffectType.SLOWNESS); this.applied = applied;
|
||||
this.execution = execution;
|
||||
}
|
||||
boolean apply() { owned = target.addPotionEffect(applied); return owned; }
|
||||
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());
|
||||
evidence.put("prior_layer", previous == null ? "none" : "replaced");
|
||||
if (previous != null) {
|
||||
evidence.put("prior_amplifier", previous.getAmplifier());
|
||||
evidence.put("prior_duration_ticks", previous.getDuration());
|
||||
}
|
||||
application = execution.effectApplied("frost_shard_slowness", target, evidence);
|
||||
} catch (RuntimeException ignored) { /* Native effect authority is independent of auditing. */ }
|
||||
return owned;
|
||||
}
|
||||
void expire() { endReason = "expired"; close(); }
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
@@ -88,13 +111,24 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
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;
|
||||
if (current == null) {
|
||||
end(endReason.equals("expired") ? "expired" : "native_layer_missing", "none"); return;
|
||||
}
|
||||
if (current.getAmplifier() != applied.getAmplifier()
|
||||
|| current.getDuration() > expected + 2 || current.getDuration() < expected - 2) {
|
||||
end("superseded", "preserved_newer"); return;
|
||||
}
|
||||
target.removePotionEffect(PotionEffectType.SLOWNESS);
|
||||
String restored = "none";
|
||||
if (previous != null) {
|
||||
int remaining = (int) Math.max(0, (long) previous.getDuration() - elapsed);
|
||||
if (remaining > 0) target.addPotionEffect(previous.withDuration(remaining));
|
||||
if (remaining > 0)
|
||||
restored = target.addPotionEffect(previous.withDuration(remaining)) ? "prior" : "refused";
|
||||
}
|
||||
end(endReason, restored);
|
||||
}
|
||||
private void end(String reason, String restored) {
|
||||
if (application != null) execution.effectEnded(application, reason, Map.of("restored_layer", restored));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +164,18 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
AbilityDefinition.TargetPolicy.PROJECTILE_HOSTILE, this);
|
||||
}
|
||||
|
||||
private static Settings captured(AbilityDefinition definition) {
|
||||
var values = definition.parameters();
|
||||
return new Settings(values.get("base-damage").toBigIntegerExact(), definition.cooldown(),
|
||||
values.get("range").doubleValue(), duration(values.get("lifetime")),
|
||||
values.get("projectile-speed").doubleValue(), values.get("slowness-amplifier").intValueExact(),
|
||||
duration(values.get("slowness-duration")));
|
||||
}
|
||||
|
||||
private static Duration duration(BigDecimal seconds) {
|
||||
return Duration.ofNanos(seconds.movePointRight(9).toBigIntegerExact().longValueExact());
|
||||
}
|
||||
|
||||
public static BigInteger damage(BigInteger intelligence) {
|
||||
return damage(BigInteger.valueOf(4), intelligence);
|
||||
}
|
||||
@@ -146,7 +192,7 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var current = settings;
|
||||
var current = captured(execution.definition());
|
||||
var player = execution.input().player();
|
||||
Location origin = player.getEyeLocation().clone();
|
||||
Vector direction = origin.getDirection();
|
||||
@@ -161,12 +207,15 @@ public final class FrostShard 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)), () -> {
|
||||
if (projectiles.containsKey(projectile.getUniqueId())) execution.finish();
|
||||
});
|
||||
execution.later(current.lifetime(), () -> {
|
||||
if (projectiles.containsKey(projectile.getUniqueId())) execution.finish();
|
||||
});
|
||||
Runnable expire = () -> {
|
||||
if (projectiles.containsKey(projectile.getUniqueId())) {
|
||||
try { execution.resolve("expired", Map.of("projectile_id", projectile.getUniqueId().toString())); }
|
||||
catch (RuntimeException ignored) { /* Audit capture cannot retain an expired Arrow. */ }
|
||||
execution.finish();
|
||||
}
|
||||
};
|
||||
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), expire);
|
||||
execution.later(current.lifetime(), expire);
|
||||
particles.world(Particle.SNOWFLAKE, origin, 8, 0.15, 0.15, 0.15, 0.01);
|
||||
}
|
||||
|
||||
@@ -183,25 +232,38 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
if (state == null) return;
|
||||
boolean retainExecution = false;
|
||||
try {
|
||||
if (closed || event.isCancelled() || !state.execution.stillCurrent()
|
||||
boolean currentSession = !closed && state.execution.stillCurrent();
|
||||
if (!currentSession || event.isCancelled()
|
||||
|| !(event.getHitEntity() instanceof LivingEntity target) || !inRange(state, event.getEntity().getLocation())
|
||||
|| state.execution.eligible(List.of(target), 1).isEmpty()) return;
|
||||
|| state.execution.eligible(List.of(target), 1).isEmpty()) {
|
||||
try { state.execution.resolve(!currentSession ? "interrupted" : event.isCancelled() ? "cancelled" : "miss",
|
||||
Map.of("projectile_id", state.projectile.getUniqueId().toString())); }
|
||||
catch (RuntimeException ignored) { /* Audit capture cannot retain an invalid hit. */ }
|
||||
return;
|
||||
}
|
||||
var current = state.settings;
|
||||
BigInteger amount = damage(current.baseDamage(), state.intelligence);
|
||||
double nativeDamage = amount.bitLength() > 1023 ? Double.MAX_VALUE : amount.doubleValue();
|
||||
var source = DamageSource.builder(DamageType.FREEZE).withCausingEntity(state.execution.input().player())
|
||||
.withDirectEntity(state.projectile).withDamageLocation(state.projectile.getLocation()).build();
|
||||
state.target = target; damaging = state;
|
||||
state.target = target; state.requested = nativeDamage; damaging = state;
|
||||
try { state.execution.damage(target, nativeDamage, source); }
|
||||
finally { if (damaging == state) damaging = null; }
|
||||
try { state.execution.resolve(state.accepted ? "hit" : state.nativeCancelled ? "cancelled" : "miss",
|
||||
Map.of("projectile_id", state.projectile.getUniqueId().toString(),
|
||||
"target_entity_id", target.getUniqueId().toString(),
|
||||
"impact_location", locationData(state.projectile.getLocation()))); }
|
||||
catch (RuntimeException ignored) { /* Capturing evidence cannot prevent native Slowness. */ }
|
||||
if (state.accepted && state.execution.stillCurrent()) {
|
||||
int ticks = Math.toIntExact(Math.max(1,
|
||||
(current.slownessDuration().toNanos() + 49_999_999L) / 50_000_000L));
|
||||
var lease = new SlowLease(target, new PotionEffect(PotionEffectType.SLOWNESS, ticks,
|
||||
current.slownessAmplifier(), false, true, true));
|
||||
current.slownessAmplifier(), false, true, true), state.execution);
|
||||
if (lease.apply()) {
|
||||
state.execution.own(lease);
|
||||
state.execution.later(current.slownessDuration(), state.execution::finish);
|
||||
state.execution.later(current.slownessDuration(), () -> {
|
||||
lease.expire(); state.execution.finish();
|
||||
});
|
||||
retainExecution = true;
|
||||
}
|
||||
}
|
||||
@@ -217,18 +279,38 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
&& current.distanceSquared(state.origin) <= state.settings.range() * state.settings.range();
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void acceptedDamage(EntityDamageEvent event) {
|
||||
var state = damaging;
|
||||
if (state == null || state.accepted || event.getEntity() != state.target || event.getFinalDamage() <= 0
|
||||
|| !Double.isFinite(event.getFinalDamage())) return;
|
||||
if (state == null || state.accepted || event.getEntity() != state.target) return;
|
||||
var direct = event.getDamageSource().getDirectEntity();
|
||||
if (direct != state.projectile && (!(event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity)
|
||||
|| byEntity.getDamager() != state.projectile)) return;
|
||||
if (event.isCancelled()) { state.nativeCancelled = true; return; }
|
||||
if (event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
|
||||
state.accepted = true;
|
||||
try { state.execution.acceptedDamage(state.target, state.requested, event.getFinalDamage(), state.projectile,
|
||||
Map.of("projectile_id", state.projectile.getUniqueId().toString(),
|
||||
"impact_location", locationData(state.projectile.getLocation()))); }
|
||||
catch (RuntimeException ignored) { /* An audit failure cannot remove an accepted combat contribution. */ }
|
||||
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
|
||||
}
|
||||
|
||||
public boolean ownsAcceptedDamage(EntityDamageEvent event) {
|
||||
var state = damaging;
|
||||
if (state == null || event.getEntity() != state.target) return false;
|
||||
var direct = event.getDamageSource().getDirectEntity();
|
||||
return direct == state.projectile || event instanceof EntityDamageByEntityEvent by
|
||||
&& by.getDamager() == state.projectile;
|
||||
}
|
||||
|
||||
private static Map<String, Object> locationData(Location location) {
|
||||
if (location == null || location.getWorld() == null) return Map.of("status", "unavailable");
|
||||
return Map.of("world_id", location.getWorld().getUID().toString(),
|
||||
"world_name", location.getWorld().getName(), "x", location.getX(), "y", location.getY(),
|
||||
"z", location.getZ(), "captured_at", java.time.Instant.now().toString());
|
||||
}
|
||||
|
||||
private static String level(int amplifier) {
|
||||
return switch (amplifier + 1) {
|
||||
case 1 -> "I"; case 2 -> "II"; case 3 -> "III"; case 4 -> "IV"; case 5 -> "V";
|
||||
|
||||
@@ -33,18 +33,25 @@ public final class GreatSword implements AbilityEffect, Listener, AutoCloseable
|
||||
private static final class Attempt {
|
||||
final AbilityExecution execution;
|
||||
final LivingEntity target;
|
||||
final double requested;
|
||||
final BigDecimal full;
|
||||
final float charge;
|
||||
boolean reported;
|
||||
Attempt(AbilityExecution execution, LivingEntity target) {
|
||||
boolean cancelled;
|
||||
Attempt(AbilityExecution execution, LivingEntity target, double requested, BigDecimal full, float charge) {
|
||||
this.execution = execution; this.target = target;
|
||||
this.requested = requested; this.full = full; this.charge = charge;
|
||||
}
|
||||
}
|
||||
|
||||
private final Settings settings;
|
||||
private Settings settings;
|
||||
private Attempt damaging;
|
||||
private boolean closed;
|
||||
|
||||
public GreatSword(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
return new AbilityDefinition("great_sword", "Great Sword", Material.WOODEN_SWORD,
|
||||
List.of("Charge a contained melee strike.",
|
||||
@@ -81,8 +88,11 @@ public final class GreatSword implements AbilityEffect, Listener, AutoCloseable
|
||||
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent() || !(execution.input().target() instanceof LivingEntity target)) return;
|
||||
BigDecimal amount = damage(settings.baseDamage(), execution.character().stats().get(Stat.STR),
|
||||
execution.input().attackStrength());
|
||||
BigInteger strength = execution.character().stats().get(Stat.STR);
|
||||
BigInteger baseDamage = execution.definition().parameters().get("base-damage").toBigIntegerExact();
|
||||
float charge = execution.input().attackStrength();
|
||||
BigDecimal full = damage(baseDamage, strength, 1.0f);
|
||||
BigDecimal amount = damage(baseDamage, strength, charge);
|
||||
double nativeDamage = amount.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0
|
||||
? Double.MAX_VALUE : amount.doubleValue();
|
||||
if (!Double.isFinite(nativeDamage) || nativeDamage <= 0) {
|
||||
@@ -91,23 +101,40 @@ public final class GreatSword implements AbilityEffect, Listener, AutoCloseable
|
||||
var player = execution.input().player();
|
||||
var source = DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(player)
|
||||
.withDirectEntity(player).build();
|
||||
var attempt = new Attempt(execution, target);
|
||||
var attempt = new Attempt(execution, target, nativeDamage, full, charge);
|
||||
damaging = attempt;
|
||||
try { execution.damage(target, nativeDamage, source); }
|
||||
finally { if (damaging == attempt) damaging = null; }
|
||||
try {
|
||||
execution.damage(target, nativeDamage, source);
|
||||
execution.resolve(attempt.reported ? "hit" : attempt.cancelled ? "cancelled" : "miss",
|
||||
Map.of("requested_amount", BigDecimal.valueOf(nativeDamage).toPlainString(),
|
||||
"full_amount", full.toPlainString(), "charge", Float.toString(charge)));
|
||||
} finally { if (damaging == attempt) damaging = null; }
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
/** The generic incoming-player adapter must yield to this contained strike's effect owner. */
|
||||
public boolean ownsAcceptedDamage(org.bukkit.event.entity.EntityDamageEvent event) {
|
||||
return event instanceof EntityDamageByEntityEvent by && matches(damaging, by);
|
||||
}
|
||||
|
||||
private boolean matches(Attempt attempt, EntityDamageByEntityEvent event) {
|
||||
if (closed || attempt == null || event.getDamager() != attempt.execution.input().player()
|
||||
|| event.getEntity() != attempt.target) return false;
|
||||
var source = event.getDamageSource();
|
||||
return (source.getCausingEntity() == null || source.getCausingEntity() == attempt.execution.input().player())
|
||||
&& (source.getDirectEntity() == null || source.getDirectEntity() == attempt.execution.input().player());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void acceptedDamage(EntityDamageByEntityEvent event) {
|
||||
var attempt = damaging;
|
||||
if (closed || attempt == null || attempt.reported || event.isCancelled()
|
||||
|| event.getDamager() != attempt.execution.input().player() || event.getEntity() != attempt.target
|
||||
|| (event.getDamageSource().getCausingEntity() != null
|
||||
&& event.getDamageSource().getCausingEntity() != attempt.execution.input().player())
|
||||
|| (event.getDamageSource().getDirectEntity() != null
|
||||
&& event.getDamageSource().getDirectEntity() != attempt.execution.input().player())
|
||||
|| event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
|
||||
if (attempt == null || attempt.reported || !matches(attempt, event)) return;
|
||||
if (event.isCancelled()) { attempt.cancelled = true; return; }
|
||||
if (event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
|
||||
attempt.reported = true;
|
||||
attempt.execution.acceptedDamage(attempt.target, attempt.requested, event.getFinalDamage(),
|
||||
attempt.execution.input().player(), Map.of("charge", Float.toString(attempt.charge),
|
||||
"full_amount", attempt.full.toPlainString(),
|
||||
"scaled_requested_amount", BigDecimal.valueOf(attempt.requested).toPlainString()));
|
||||
attempt.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(),
|
||||
BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros());
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -35,15 +35,37 @@ public final class Hex implements AbilityEffect,AutoCloseable{
|
||||
"duration-seconds",BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),"damage-reduction-percent",current.reductionPercent()),
|
||||
AbilityDefinition.TargetPolicy.HOSTILE,this);}
|
||||
@Override public boolean accepts(ControlInput input){return !closed&&input.target() instanceof LivingEntity target&&inRange(input,target,settings.range());}
|
||||
@Override public void activate(AbilityExecution execution){if(closed||!execution.stillCurrent()||!(execution.input().target() instanceof LivingEntity target))return;
|
||||
var current=from(execution.definition());if(!inRange(execution.input(),target,current.range())||execution.trainingDummy(target)){execution.finish();return;}
|
||||
var state=new State(execution,target,mitigation.grant(execution,target,current.reductionPercent()));var previous=hexes.put(target.getUniqueId(),state);
|
||||
if(previous!=null)previous.execution.finish();execution.own(state);execution.later(current.duration(),execution::finish);}
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent() || !(execution.input().target() instanceof LivingEntity target)) return;
|
||||
var current = from(execution.definition());
|
||||
if (!inRange(execution.input(), target, current.range()) || execution.trainingDummy(target)) {
|
||||
execution.resolve("miss", Map.of("reason", "target_ineligible_after_cooldown"));
|
||||
execution.finish(); return;
|
||||
}
|
||||
var application = new java.util.concurrent.atomic.AtomicReference<UUID>();
|
||||
var grant = mitigation.grantObserved(execution, target, current.reductionPercent(), accepted -> {
|
||||
try { execution.acceptedOutgoingMitigation(application.get(), accepted.debuffedAttacker(),
|
||||
accepted.recipient(), accepted.incoming(), accepted.remaining(), accepted.prevented()); }
|
||||
catch (RuntimeException ignored) { /* Native damage and support do not depend on auditing. */ }
|
||||
});
|
||||
var state = new State(execution, target, grant);
|
||||
var previous = hexes.put(target.getUniqueId(), state);
|
||||
if (previous != null) { previous.execution.resolve("replaced"); previous.execution.finish(); }
|
||||
execution.own(state);
|
||||
var evidence = new java.util.LinkedHashMap<String, Object>();
|
||||
evidence.put("reduction_percent", current.reductionPercent().stripTrailingZeros().toPlainString());
|
||||
evidence.put("duration_seconds", seconds(current.duration()));
|
||||
var activatedAt = execution.now();
|
||||
if (activatedAt != null) evidence.put("effect_until", activatedAt.plus(current.duration()).toString());
|
||||
application.set(execution.effectApplied("hex", target, evidence));
|
||||
execution.later(current.duration(), () -> { execution.resolve("expired"); execution.finish(); });
|
||||
}
|
||||
private static boolean inRange(ControlInput input,LivingEntity target,double range){try{Location source=input.player().getLocation(),destination=target.getLocation();
|
||||
return source.getWorld()==destination.getWorld()&&source.distanceSquared(destination)<=range*range&&input.player().hasLineOfSight(target);
|
||||
}catch(RuntimeException invalid){return false;}}
|
||||
private static Settings from(AbilityDefinition definition){var p=definition.parameters();return new Settings(definition.cooldown(),p.get("range").doubleValue(),
|
||||
Duration.ofNanos(p.get("duration-seconds").movePointRight(9).longValueExact()),p.get("damage-reduction-percent"));}
|
||||
private static String seconds(Duration value){return BigDecimal.valueOf(value.toNanos()).movePointLeft(9).stripTrailingZeros().toPlainString();}
|
||||
@Override public void close(){if(closed)return;closed=true;for(var state:List.copyOf(hexes.values()))state.execution.finish();hexes.clear();}
|
||||
@Override public void close(){if(closed)return;closed=true;for(var state:List.copyOf(hexes.values())){
|
||||
state.execution.resolve("interrupted"); state.execution.finish();}hexes.clear();}
|
||||
}
|
||||
@@ -8,7 +8,38 @@ public final class Inspire implements AbilityEffect,AutoCloseable{
|
||||
public AbilityDefinition definition(){var s=settings;return new AbilityDefinition("inspire","Inspire",Material.AMETHYST_SHARD,List.of("Grant "+s.baseTemporaryHealth()+" + CHR modifier temporary health for "+seconds(s.duration())+" seconds.","Range "+BigDecimal.valueOf(s.range()).stripTrailingZeros().toPlainString()+" blocks for an aimed fantasy player."),"Sneak+Use selects self; normal Use requires an aimed fantasy player.",Map.of(Stat.CHR,BigInteger.valueOf(12),Stat.WIS,BigInteger.TEN),BigInteger.ONE,AbilityDefinition.Category.BUF,AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,s.cooldown(),Map.of("base-temporary-health",new BigDecimal(s.baseTemporaryHealth()),"range",BigDecimal.valueOf(s.range()).stripTrailingZeros(),"duration-seconds",BigDecimal.valueOf(s.duration().toNanos()).movePointLeft(9)),AbilityDefinition.TargetPolicy.ALLY,this);}
|
||||
@Override public boolean accepts(ControlInput input){if(closed||!(input.target() instanceof Player target))return false;if(input.player().isSneaking())return target==input.player();return target!=input.player()&&inRange(input.player(),target,settings.range());}
|
||||
public static BigInteger amount(BigInteger charisma){return amount(BigInteger.valueOf(4),charisma);}static BigInteger amount(BigInteger base,BigInteger charisma){Objects.requireNonNull(base);Objects.requireNonNull(charisma);if(base.signum()<0||charisma.signum()<0)throw new IllegalArgumentException("Invalid Inspire amount");var delta=charisma.subtract(BigInteger.TEN);var divided=delta.divideAndRemainder(BigInteger.TWO);var modifier=divided[0];if(delta.signum()<0&÷d[1].signum()!=0)modifier=modifier.subtract(BigInteger.ONE);return base.add(modifier);}
|
||||
@Override public void activate(AbilityExecution execution){if(closed||!execution.stillCurrent()||!(execution.input().target() instanceof Player target))return;var s=from(execution.definition());if(execution.input().player().isSneaking()?target!=execution.input().player():target==execution.input().player()||!inRange(execution.input().player(),target,s.range())){execution.finish();return;}var exact=amount(s.baseTemporaryHealth(),execution.character().stats().get(Stat.CHR));double nativeAmount=exact.bitLength()>1023?Double.MAX_VALUE:exact.doubleValue();if(!Double.isFinite(nativeAmount)||nativeAmount<=0){execution.finish();return;}execution.own(temporaryHealth.grant(execution,target,nativeAmount));execution.later(s.duration(),execution::finish);}
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if(closed||!execution.stillCurrent()||!(execution.input().target() instanceof Player target))return;
|
||||
var s=from(execution.definition());
|
||||
if(execution.input().player().isSneaking()?target!=execution.input().player():target==execution.input().player()||!inRange(execution.input().player(),target,s.range())){
|
||||
execution.resolve("miss",Map.of("reason","target_ineligible_after_cooldown"));execution.finish();return;
|
||||
}
|
||||
var exact=amount(s.baseTemporaryHealth(),execution.character().stats().get(Stat.CHR));
|
||||
double nativeAmount=exact.bitLength()>1023?Double.MAX_VALUE:exact.doubleValue();
|
||||
if(!Double.isFinite(nativeAmount)||nativeAmount<=0){execution.resolve("miss",Map.of("reason","no_positive_temporary_health"));execution.finish();return;}
|
||||
var application=new java.util.concurrent.atomic.AtomicReference<UUID>();
|
||||
var grant=temporaryHealth.grantObserved(execution,target,nativeAmount,accepted->{
|
||||
try{execution.acceptedAbsorption(application.get(),accepted.target(),accepted.attacker(),accepted.incoming(),
|
||||
accepted.before(),accepted.after(),accepted.consumed(),accepted.remaining(),accepted.targetAtHit(),accepted.attackerAtHit());}
|
||||
catch(RuntimeException ignored){/* Audit cannot change native absorption or party rewards. */}
|
||||
},remaining->{
|
||||
var id=application.get();
|
||||
if(id!=null)execution.effectEndEvidence(id,Map.of("remaining_amount",
|
||||
BigDecimal.valueOf(remaining).stripTrailingZeros().toPlainString()));
|
||||
});
|
||||
execution.own(grant);
|
||||
var evidence=new LinkedHashMap<String,Object>();
|
||||
evidence.put("granted_amount",BigDecimal.valueOf(nativeAmount).stripTrailingZeros().toPlainString());
|
||||
evidence.put("remaining_amount",BigDecimal.valueOf(nativeAmount).stripTrailingZeros().toPlainString());
|
||||
evidence.put("duration_seconds",seconds(s.duration()));
|
||||
var activatedAt=execution.now();if(activatedAt!=null)evidence.put("effect_until",activatedAt.plus(s.duration()).toString());
|
||||
temporaryHealth.currentSession(target).ifPresent(active->{
|
||||
evidence.put("target_character_id",active.character().toString());
|
||||
evidence.put("target_session_id",active.session().toString());
|
||||
});
|
||||
application.set(execution.effectApplied("inspire",target,evidence));
|
||||
execution.later(s.duration(),()->{execution.resolve("expired");execution.finish();});
|
||||
}
|
||||
private static boolean inRange(Player source,Player target,double range){try{var a=source.getLocation();var b=target.getLocation();return a.getWorld()==b.getWorld()&&a.distanceSquared(b)<=range*range&&source.hasLineOfSight(target);}catch(RuntimeException invalid){return false;}}
|
||||
private static Settings from(AbilityDefinition d){var p=d.parameters();return new Settings(p.get("base-temporary-health").toBigIntegerExact(),d.cooldown(),p.get("range").doubleValue(),Duration.ofNanos(p.get("duration-seconds").movePointRight(9).longValueExact()));}
|
||||
private static String seconds(Duration d){return BigDecimal.valueOf(d.toNanos()).movePointLeft(9).stripTrailingZeros().toPlainString();}
|
||||
|
||||
@@ -58,7 +58,9 @@ public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
|
||||
final Settings settings;
|
||||
final BigDecimal damage;
|
||||
LivingEntity target;
|
||||
double requested;
|
||||
boolean accepted;
|
||||
boolean nativeCancelled;
|
||||
boolean finished;
|
||||
State(Arrow arrow, AbilityExecution execution, Location origin, Settings settings, BigDecimal damage) {
|
||||
this.arrow = arrow; this.execution = execution; this.origin = origin; this.settings = settings; this.damage = damage;
|
||||
@@ -141,12 +143,15 @@ public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
|
||||
}
|
||||
execution.own(state);
|
||||
long rangeTicks = Math.max(1, (long) Math.ceil(shot.range() / speed));
|
||||
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), () -> {
|
||||
if (arrows.containsKey(arrow.getUniqueId())) execution.finish();
|
||||
});
|
||||
execution.later(shot.lifetime(), () -> {
|
||||
if (arrows.containsKey(arrow.getUniqueId())) execution.finish();
|
||||
});
|
||||
Runnable expire = () -> {
|
||||
if (arrows.containsKey(arrow.getUniqueId())) {
|
||||
try { execution.resolve("expired", Map.of("arrow_id", arrow.getUniqueId().toString())); }
|
||||
catch (RuntimeException ignored) { /* Auditing cannot retain expired native arrows. */ }
|
||||
execution.finish();
|
||||
}
|
||||
};
|
||||
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), expire);
|
||||
execution.later(shot.lifetime(), expire);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
@@ -161,18 +166,32 @@ public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
|
||||
var state = arrows.remove(event.getEntity().getUniqueId());
|
||||
if (state == null) return;
|
||||
try {
|
||||
if (closed || event.isCancelled() || !state.execution.stillCurrent()
|
||||
|| !(event.getHitEntity() instanceof LivingEntity target)
|
||||
boolean currentSession = !closed && state.execution.stillCurrent();
|
||||
if (!currentSession || event.isCancelled() || !(event.getHitEntity() instanceof LivingEntity target)
|
||||
|| !inRange(state, event.getEntity().getLocation())
|
||||
|| state.execution.eligible(List.of(target), 1).isEmpty()) return;
|
||||
|| state.execution.eligible(List.of(target), 1).isEmpty()) {
|
||||
try { state.execution.resolve(!currentSession ? "interrupted" : event.isCancelled() ? "cancelled" : "miss",
|
||||
Map.of("arrow_id", state.arrow.getUniqueId().toString())); }
|
||||
catch (RuntimeException ignored) { /* Auditing cannot prevent native cleanup. */ }
|
||||
return;
|
||||
}
|
||||
double nativeDamage = state.damage.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0
|
||||
? Double.MAX_VALUE : state.damage.doubleValue();
|
||||
if (!Double.isFinite(nativeDamage) || nativeDamage <= 0) return;
|
||||
if (!Double.isFinite(nativeDamage) || nativeDamage <= 0) {
|
||||
try { state.execution.resolve("miss", Map.of("arrow_id", state.arrow.getUniqueId().toString())); }
|
||||
catch (RuntimeException ignored) { /* Auditing cannot prevent native cleanup. */ }
|
||||
return;
|
||||
}
|
||||
var source = DamageSource.builder(DamageType.ARROW).withCausingEntity(state.execution.input().player())
|
||||
.withDirectEntity(state.arrow).withDamageLocation(state.arrow.getLocation()).build();
|
||||
state.target = target; damaging = state;
|
||||
state.target = target; state.requested = nativeDamage; damaging = state;
|
||||
try { state.execution.damage(target, nativeDamage, source); }
|
||||
finally { if (damaging == state) damaging = null; }
|
||||
try { state.execution.resolve(state.accepted ? "hit" : state.nativeCancelled ? "cancelled" : "miss",
|
||||
Map.of("arrow_id", state.arrow.getUniqueId().toString(),
|
||||
"target_entity_id", target.getUniqueId().toString(),
|
||||
"impact_location", locationData(state.arrow.getLocation()))); }
|
||||
catch (RuntimeException ignored) { /* Audit capture cannot change native damage. */ }
|
||||
} finally {
|
||||
state.close(); state.execution.finish();
|
||||
}
|
||||
@@ -184,18 +203,38 @@ public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
|
||||
&& current.distanceSquared(state.origin) <= state.settings.range() * state.settings.range();
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void acceptedDamage(EntityDamageEvent event) {
|
||||
var state = damaging;
|
||||
if (state == null || state.accepted || event.getEntity() != state.target || event.getFinalDamage() <= 0
|
||||
|| !Double.isFinite(event.getFinalDamage())) return;
|
||||
if (state == null || state.accepted || event.getEntity() != state.target) return;
|
||||
var direct = event.getDamageSource().getDirectEntity();
|
||||
if (direct != state.arrow && (!(event instanceof EntityDamageByEntityEvent byEntity)
|
||||
|| byEntity.getDamager() != state.arrow)) return;
|
||||
if (event.isCancelled()) { state.nativeCancelled = true; return; }
|
||||
if (event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
|
||||
state.accepted = true;
|
||||
try { state.execution.acceptedDamage(state.target, state.requested, event.getFinalDamage(), state.arrow,
|
||||
Map.of("arrow_id", state.arrow.getUniqueId().toString(),
|
||||
"impact_location", locationData(state.arrow.getLocation()))); }
|
||||
catch (RuntimeException ignored) { /* Audit capture cannot change an accepted contribution. */ }
|
||||
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
|
||||
}
|
||||
|
||||
public boolean ownsAcceptedDamage(EntityDamageEvent event) {
|
||||
var state = damaging;
|
||||
if (state == null || event.getEntity() != state.target) return false;
|
||||
var direct = event.getDamageSource().getDirectEntity();
|
||||
return direct == state.arrow || event instanceof EntityDamageByEntityEvent by
|
||||
&& by.getDamager() == state.arrow;
|
||||
}
|
||||
|
||||
private static Map<String, Object> locationData(Location location) {
|
||||
if (location == null || location.getWorld() == null) return Map.of("status", "unavailable");
|
||||
return Map.of("world_id", location.getWorld().getUID().toString(),
|
||||
"world_name", location.getWorld().getName(), "x", location.getX(), "y", location.getY(),
|
||||
"z", location.getZ(), "captured_at", java.time.Instant.now().toString());
|
||||
}
|
||||
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
var p = definition.parameters();
|
||||
return new Settings(p.get("base-damage").toBigIntegerExact(), definition.cooldown(),
|
||||
|
||||
@@ -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&÷d[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) {
|
||||
|
||||
@@ -25,24 +25,36 @@ import org.bukkit.projectiles.ProjectileSource;
|
||||
public final class OutgoingDamageMitigation implements Listener, AutoCloseable {
|
||||
public record Support(UUID target, UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) { }
|
||||
private final class Grant implements AutoCloseable {
|
||||
final long sequence; final AbilityExecution execution; final LivingEntity target; final BigDecimal percent; boolean closed;
|
||||
Grant(AbilityExecution execution, LivingEntity target, BigDecimal percent) {
|
||||
final long sequence; final AbilityExecution execution; final LivingEntity target; final BigDecimal percent;
|
||||
final Consumer<Accepted> observation; boolean closed;
|
||||
Grant(AbilityExecution execution, LivingEntity target, BigDecimal percent, Consumer<Accepted> observation) {
|
||||
this.sequence=++sequences;this.execution=execution;this.target=target;this.percent=percent;
|
||||
this.observation = observation;
|
||||
}
|
||||
@Override public void close(){if(closed)return;closed=true;var owned=grants.get(target.getUniqueId());
|
||||
if(owned!=null){owned.remove(this);if(owned.isEmpty())grants.remove(target.getUniqueId());}}
|
||||
}
|
||||
private record Prevention(Grant grant, Entity recipient, BigDecimal amount) { }
|
||||
public record Accepted(LivingEntity debuffedAttacker, Entity recipient,
|
||||
double incoming, double remaining, double prevented) { }
|
||||
private record Prevention(Grant grant, Entity recipient, double incoming, double reduced) { }
|
||||
private final EquipmentSessions sessions; private final PartyRoster parties; private final Consumer<Support> supports;
|
||||
private final Map<UUID,List<Grant>> grants=new java.util.HashMap<>();
|
||||
private final Map<EntityDamageEvent,Prevention> pending=new IdentityHashMap<>();
|
||||
private long sequences; private boolean closed;
|
||||
public OutgoingDamageMitigation(EquipmentSessions sessions,PartyRoster parties,Consumer<Support> supports){
|
||||
this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.supports=Objects.requireNonNull(supports);}
|
||||
public AutoCloseable grant(AbilityExecution execution,LivingEntity target,BigDecimal percent){
|
||||
Objects.requireNonNull(execution);Objects.requireNonNull(target);Objects.requireNonNull(percent);
|
||||
if(closed||percent.signum()<0||percent.compareTo(BigDecimal.valueOf(100))>0)throw new IllegalArgumentException("Invalid outgoing mitigation grant");
|
||||
var grant=new Grant(execution,target,percent);grants.computeIfAbsent(target.getUniqueId(),ignored->new ArrayList<>()).add(grant);return grant;}
|
||||
public AutoCloseable grant(AbilityExecution execution, LivingEntity target, BigDecimal percent) {
|
||||
return grantObserved(execution, target, percent, null);
|
||||
}
|
||||
public AutoCloseable grantObserved(AbilityExecution execution, LivingEntity target, BigDecimal percent,
|
||||
Consumer<Accepted> observation) {
|
||||
Objects.requireNonNull(execution); Objects.requireNonNull(target); Objects.requireNonNull(percent);
|
||||
if (closed || percent.signum() < 0 || percent.compareTo(BigDecimal.valueOf(100)) > 0)
|
||||
throw new IllegalArgumentException("Invalid outgoing mitigation grant");
|
||||
var grant = new Grant(execution, target, percent, observation);
|
||||
grants.computeIfAbsent(target.getUniqueId(), ignored -> new ArrayList<>()).add(grant);
|
||||
return grant;
|
||||
}
|
||||
|
||||
@EventHandler(priority=EventPriority.NORMAL,ignoreCancelled=true)
|
||||
public void reduce(EntityDamageByEntityEvent event){
|
||||
@@ -53,20 +65,31 @@ public final class OutgoingDamageMitigation implements Listener, AutoCloseable {
|
||||
double original=event.getDamage();if(strongest==null||!Double.isFinite(original)||original<=0)return;
|
||||
double reduced=BigDecimal.valueOf(original).multiply(BigDecimal.ONE.subtract(strongest.percent.movePointLeft(2))).doubleValue();
|
||||
if(!Double.isFinite(reduced)||reduced<0)return;event.setDamage(reduced);var prevented=BigDecimal.valueOf(original-reduced);
|
||||
if(prevented.signum()>0)pending.put(event,new Prevention(strongest,event.getEntity(),prevented));
|
||||
if(prevented.signum()>0)pending.put(event,new Prevention(strongest,event.getEntity(),original,reduced));
|
||||
}
|
||||
|
||||
@EventHandler(priority=EventPriority.MONITOR)
|
||||
public void accepted(EntityDamageEvent event){
|
||||
var prevention=pending.remove(event);if(prevention==null||event.isCancelled()||!prevention.grant.execution.stillCurrent())return;
|
||||
prevention.grant.execution.report(AbilityReport.Type.PREVENTION,prevention.grant.target,prevention.amount);
|
||||
if(!(prevention.recipient instanceof Player player))return;
|
||||
var prevention = pending.remove(event);
|
||||
if (prevention == null || event.isCancelled() || !Double.isFinite(event.getFinalDamage())
|
||||
|| event.getFinalDamage() <= 0 || !prevention.grant.execution.stillCurrent()) return;
|
||||
double remaining = event.getDamage();
|
||||
double actual = Math.min(prevention.incoming - prevention.reduced, prevention.incoming - remaining);
|
||||
if (!Double.isFinite(remaining) || !Double.isFinite(actual) || actual <= 0) return;
|
||||
var amount = BigDecimal.valueOf(actual);
|
||||
try { prevention.grant.execution.report(AbilityReport.Type.PREVENTION, prevention.grant.target, amount); }
|
||||
catch (RuntimeException ignored) { /* Feedback cannot interrupt accepted native prevention. */ }
|
||||
if (prevention.grant.observation != null) try {
|
||||
prevention.grant.observation.accept(new Accepted(prevention.grant.target,
|
||||
prevention.recipient, prevention.incoming, remaining, actual));
|
||||
} catch (RuntimeException ignored) { /* Audit cannot alter damage or rewards. */ }
|
||||
if (!(prevention.recipient instanceof Player player)) return;
|
||||
var aided=sessions.active(player.getUniqueId()).orElse(null);if(aided==null||!sessions.current(aided))return;
|
||||
var input=prevention.grant.execution.input();if(aided.character().equals(input.session().character()))return;
|
||||
var sourceParty=parties.partyOf(input.player().getUniqueId()).orElse(null);
|
||||
var aidedParty=parties.partyOf(player.getUniqueId()).orElse(null);
|
||||
if(sourceParty==null||aidedParty==null||!sourceParty.id().equals(aidedParty.id()))return;
|
||||
supports.accept(new Support(prevention.grant.target.getUniqueId(),input.session().character(),aided,prevention.amount));
|
||||
supports.accept(new Support(prevention.grant.target.getUniqueId(),input.session().character(),aided,amount));
|
||||
}
|
||||
|
||||
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())) {
|
||||
|
||||
@@ -41,11 +41,20 @@ public final class Quickstep implements AbilityEffect, Listener, AutoCloseable {
|
||||
final Location origin;
|
||||
final Vector horizontal;
|
||||
final Settings settings;
|
||||
final long started = System.nanoTime();
|
||||
final long boundTicks;
|
||||
Location lastObserved;
|
||||
boolean owned = true;
|
||||
boolean closed;
|
||||
State(AbilityExecution execution, Player player, Location origin, Vector horizontal, Settings settings) {
|
||||
State(AbilityExecution execution, Player player, Location origin, Vector horizontal,
|
||||
Settings settings, long boundTicks) {
|
||||
this.execution = execution; this.player = player; this.origin = origin;
|
||||
this.horizontal = horizontal; this.settings = settings;
|
||||
this.horizontal = horizontal; this.settings = settings; this.boundTicks = boundTicks;
|
||||
}
|
||||
void finish(String outcome, Location observed) {
|
||||
lastObserved = observed == null ? null : observed.clone();
|
||||
execution.resolve(outcome);
|
||||
execution.finish();
|
||||
}
|
||||
boolean stillOwns(Vector current) {
|
||||
var horizontalCurrent = new Vector(current.getX(), 0, current.getZ());
|
||||
@@ -57,6 +66,20 @@ public final class Quickstep implements AbilityEffect, Listener, AutoCloseable {
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true; dashes.remove(player.getUniqueId(), this);
|
||||
try {
|
||||
var to = lastObserved == null ? player.getLocation() : lastObserved;
|
||||
if (to != null) {
|
||||
var evidence = new java.util.LinkedHashMap<String, Object>();
|
||||
evidence.put("to_location", locationData(to));
|
||||
evidence.put("elapsed_ms", Math.max(0, (System.nanoTime() - started) / 1_000_000L));
|
||||
evidence.put("bound_ticks", boundTicks);
|
||||
if (to.getWorld() != null && to.getWorld().equals(origin.getWorld())) {
|
||||
double dx = to.getX() - origin.getX(), dz = to.getZ() - origin.getZ();
|
||||
evidence.put("horizontal_distance", Math.hypot(dx, dz));
|
||||
} else evidence.put("horizontal_distance_status", "not_comparable_cross_world");
|
||||
execution.resolutionEvidence(evidence);
|
||||
}
|
||||
} catch (RuntimeException ignored) { /* Auditing cannot change native motion or cleanup. */ }
|
||||
if (!owned) return;
|
||||
Vector current = player.getVelocity();
|
||||
if (!stillOwns(current)) return;
|
||||
@@ -98,24 +121,41 @@ public final class Quickstep implements AbilityEffect, Listener, AutoCloseable {
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var current = from(execution.definition());
|
||||
var player = execution.input().player();
|
||||
if (!accepts(execution.input())) { execution.finish(); return; }
|
||||
var origin = player.getLocation().clone();
|
||||
try { execution.castEvidence(Map.of("from_location", locationData(origin))); }
|
||||
catch (RuntimeException ignored) { /* Capturing an origin cannot affect admission. */ }
|
||||
if (!accepts(execution.input())) {
|
||||
rejectedVelocity(execution, origin, "native_recheck_refused"); return;
|
||||
}
|
||||
Vector direction = player.getEyeLocation().getDirection(); direction.setY(0).normalize();
|
||||
Vector before = player.getVelocity();
|
||||
Vector horizontal = direction.multiply(current.speedPerTick());
|
||||
Vector horizontal = direction.clone().multiply(current.speedPerTick());
|
||||
var proposed = new Vector(horizontal.getX(), before.getY(), horizontal.getZ());
|
||||
try { execution.castEvidence(Map.of("intended_direction", vectorData(direction),
|
||||
"native_velocity_status", "pending_native_decision")); }
|
||||
catch (RuntimeException ignored) { /* Capturing intent cannot affect native velocity. */ }
|
||||
var event = new PlayerVelocityEvent(player, proposed.clone());
|
||||
velocityEvents.accept(event);
|
||||
if (event.isCancelled() || !finite(event.getVelocity())) { execution.finish(); return; }
|
||||
if (event.isCancelled() || !finite(event.getVelocity())) {
|
||||
rejectedVelocity(execution, origin, "native_velocity_cancelled_or_invalid"); return;
|
||||
}
|
||||
Vector accepted = event.getVelocity().clone(); accepted.setY(before.getY());
|
||||
Vector acceptedHorizontal = new Vector(accepted.getX(), 0, accepted.getZ());
|
||||
if (acceptedHorizontal.lengthSquared() < 1.0e-12) { execution.finish(); return; }
|
||||
var state = new State(execution, player, player.getLocation().clone(), acceptedHorizontal, current);
|
||||
if (acceptedHorizontal.lengthSquared() < 1.0e-12) {
|
||||
rejectedVelocity(execution, origin, "native_velocity_without_horizontal_motion"); return;
|
||||
}
|
||||
try { execution.castEvidence(Map.of("accepted_velocity", vectorData(accepted),
|
||||
"native_velocity_status", "accepted")); }
|
||||
catch (RuntimeException ignored) { /* Capturing accepted velocity cannot affect movement. */ }
|
||||
long ticks = Math.max(1, (long) Math.ceil(current.distance() / acceptedHorizontal.length()));
|
||||
var state = new State(execution, player, origin, acceptedHorizontal, current, ticks + 1);
|
||||
var previous = dashes.put(player.getUniqueId(), state);
|
||||
if (previous != null) previous.execution.finish();
|
||||
if (previous != null) previous.finish("replaced", previous.player.getLocation());
|
||||
execution.own(state);
|
||||
player.setVelocity(accepted);
|
||||
long ticks = Math.max(1, (long) Math.ceil(current.distance() / acceptedHorizontal.length()));
|
||||
execution.later(Duration.ofMillis(Math.multiplyExact(ticks + 1, 50)), execution::finish);
|
||||
execution.later(Duration.ofMillis(Math.multiplyExact(ticks + 1, 50)), () -> {
|
||||
execution.resolve("completed", Map.of("reason", "elapsed_bound")); execution.finish();
|
||||
});
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
@@ -123,18 +163,44 @@ public final class Quickstep implements AbilityEffect, Listener, AutoCloseable {
|
||||
var state = dashes.get(event.getPlayer().getUniqueId());
|
||||
if (state == null || state.player != event.getPlayer()) return;
|
||||
var to = event.getTo();
|
||||
if (to == null || !state.execution.stillCurrent()) { state.execution.finish(); return; }
|
||||
if (to == null || !state.execution.stillCurrent()) {
|
||||
state.finish("interrupted", to); return;
|
||||
}
|
||||
state.lastObserved = to.clone();
|
||||
if (to.getWorld() == null || !to.getWorld().equals(state.origin.getWorld())) {
|
||||
state.finish("interrupted", to); return;
|
||||
}
|
||||
Vector current = state.player.getVelocity();
|
||||
if (!state.stillOwns(current)) {
|
||||
state.owned = false; state.execution.finish(); return;
|
||||
state.owned = false; state.finish("replaced", to); return;
|
||||
}
|
||||
double dx = to.getX() - state.origin.getX(); double dz = to.getZ() - state.origin.getZ();
|
||||
if (dx * dx + dz * dz >= state.settings.distance() * state.settings.distance()
|
||||
|| new Vector(current.getX(), 0, current.getZ()).lengthSquared() < 1.0e-12) {
|
||||
state.execution.finish();
|
||||
if (dx * dx + dz * dz >= state.settings.distance() * state.settings.distance()) {
|
||||
state.finish("completed", to);
|
||||
} else if (new Vector(current.getX(), 0, current.getZ()).lengthSquared() < 1.0e-12) {
|
||||
state.finish("obstructed", to);
|
||||
}
|
||||
}
|
||||
|
||||
private static void rejectedVelocity(AbilityExecution execution, Location origin, String reason) {
|
||||
try { execution.castEvidence(Map.of("native_velocity_status", "cancelled"));
|
||||
execution.resolve("cancelled", Map.of("reason", reason,
|
||||
"to_location", locationData(origin), "horizontal_distance", 0)); }
|
||||
catch (RuntimeException ignored) { /* Native rejection is not contingent on audit capture. */ }
|
||||
execution.finish();
|
||||
}
|
||||
|
||||
private static Map<String, Object> vectorData(Vector vector) {
|
||||
return Map.of("x", vector.getX(), "y", vector.getY(), "z", vector.getZ());
|
||||
}
|
||||
|
||||
private static Map<String, Object> locationData(Location location) {
|
||||
if (location == null || location.getWorld() == null) return Map.of("status", "unavailable");
|
||||
return Map.of("world_id", location.getWorld().getUID().toString(),
|
||||
"world_name", location.getWorld().getName(), "x", location.getX(), "y", location.getY(),
|
||||
"z", location.getZ(), "captured_at", java.time.Instant.now().toString());
|
||||
}
|
||||
|
||||
private static boolean finite(Vector vector) {
|
||||
return Double.isFinite(vector.getX()) && Double.isFinite(vector.getY()) && Double.isFinite(vector.getZ());
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -50,6 +50,11 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
private final Consumer<Throwable> failures;
|
||||
private final TailBooks tails = new TailBooks();
|
||||
private final CharacterSheets sheets = new CharacterSheets();
|
||||
private games.dmg.fantasystick.session.DownedController downed;
|
||||
private games.dmg.fantasystick.events.DownedEventBridge downedEvents;
|
||||
private games.dmg.fantasystick.events.ReviveEventBridge reviveEvents;
|
||||
private games.dmg.fantasystick.events.AdminRevivalEventBridge adminRevivalEvents;
|
||||
private java.util.function.Supplier<Duration> downedProtection=()->Duration.ofSeconds(5);
|
||||
private final Map<Inventory, View> inventories = new IdentityHashMap<>();
|
||||
private final Map<UUID, View> targets = new LinkedHashMap<>();
|
||||
private boolean closed;
|
||||
@@ -59,16 +64,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); }
|
||||
}
|
||||
}
|
||||
@@ -80,6 +91,15 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
this.popups = Objects.requireNonNull(popups); this.failures = Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
public void attachDowned(games.dmg.fantasystick.session.DownedController controller,java.util.function.Supplier<Duration> protection){if(closed||downed!=null)throw new IllegalStateException("Downed Revive already attached or closed");downed=Objects.requireNonNull(controller);downedProtection=Objects.requireNonNull(protection);}
|
||||
public void attachDownedEvents(games.dmg.fantasystick.events.DownedEventBridge events){if(closed||downedEvents!=null)throw new IllegalStateException("Downed Revive events already attached or closed");downedEvents=Objects.requireNonNull(events);}
|
||||
public void attachEvents(games.dmg.fantasystick.events.ReviveEventBridge events){if(closed||reviveEvents!=null)throw new IllegalStateException("Revive events already attached or closed");reviveEvents=Objects.requireNonNull(events);}
|
||||
public void attachAdminRevivalEvents(games.dmg.fantasystick.events.AdminRevivalEventBridge events){if(closed||adminRevivalEvents!=null)throw new IllegalStateException("Admin revival events already attached or closed");adminRevivalEvents=Objects.requireNonNull(events);}
|
||||
private void recoveryEvent(RevivalStore.Reservation reservation,CharacterRecord restored,Player target){
|
||||
if(reservation.administrative()){if(adminRevivalEvents!=null)adminRevivalEvents.recovered(reservation,restored,target);}
|
||||
else if(reviveEvents!=null)reviveEvents.recovered(reservation,restored,target);
|
||||
}
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
return new AbilityDefinition("revive", "Revive", Material.ECHO_SHARD,
|
||||
List.of("Let another player continue one fallen character.", "A valid cast spends the daily opportunity."),
|
||||
@@ -100,15 +120,17 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
execution.input().player().sendMessage("Revive found no eligible player within 12 blocks and line of sight.");
|
||||
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++) {
|
||||
@@ -119,13 +141,15 @@ 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);
|
||||
}));
|
||||
}
|
||||
|
||||
private games.dmg.fantasystick.session.DownedEpisode downedEpisode(Player target){return downed==null?null:downed.episode(target);}
|
||||
|
||||
private Player target(ControlInput input) {
|
||||
Player caster = input.player();
|
||||
Entity supplied = input.target();
|
||||
@@ -165,7 +189,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"));
|
||||
@@ -178,7 +202,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);
|
||||
@@ -222,14 +246,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());
|
||||
@@ -240,17 +264,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);
|
||||
@@ -281,22 +305,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();
|
||||
}));
|
||||
}
|
||||
@@ -340,7 +364,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
: source != null && source.getAmount() == reservation.sourceAmount() - 1
|
||||
&& tails.identify(source).filter(id -> id.deathId().equals(reservation.death())).isPresent();
|
||||
if (!record.dead() && hasSheet && installFinalSheet(target, record)) {
|
||||
reservations.clear(reservation.character(), reservation.id()); return;
|
||||
recoveryEvent(reservation,record,target);reservations.clear(reservation.character(), reservation.id()); return;
|
||||
}
|
||||
if (record.dead() && unchangedTail && !hasSheet) {
|
||||
reservations.clear(reservation.character(), reservation.id()); return;
|
||||
@@ -357,7 +381,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
.whenComplete((restored, failure) -> schedule(() -> {
|
||||
if (failure != null) { report(failure); return; }
|
||||
if (restored.isPresent() && installFinalSheet(target, restored.orElseThrow())) {
|
||||
reservations.clear(reservation.character(), reservation.id());
|
||||
recoveryEvent(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;
|
||||
|
||||
@@ -80,17 +80,24 @@ public final class SecondWind implements AbilityEffect {
|
||||
BigInteger base = definition.parameters().get("base-healing").toBigIntegerExact();
|
||||
var player = execution.input().player();
|
||||
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
|
||||
if (attribute == null) { execution.finish(); return; }
|
||||
if (attribute == null) { execution.resolve("cancelled", Map.of("reason", "maximum_unavailable")); execution.finish(); return; }
|
||||
double current = 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("cancelled", Map.of("reason", "health_unavailable_or_full")); execution.finish(); return;
|
||||
}
|
||||
BigInteger exact = healing(base, 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("cancelled", Map.of("reason", "healing_nonpositive")); execution.finish(); return;
|
||||
}
|
||||
var event = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
|
||||
healingEvents.accept(event);
|
||||
double accepted = 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("cancelled", Map.of("reason", "native_healing_cancelled_or_nonpositive"));
|
||||
execution.finish(); return;
|
||||
}
|
||||
var record = execution.character(); var expected = record.revision();
|
||||
BigDecimal target = BigDecimal.valueOf(current + accepted);
|
||||
BigDecimal limit = nativeLimit.get();
|
||||
@@ -99,18 +106,33 @@ public final class SecondWind implements AbilityEffect {
|
||||
nextTick.accept(() -> {
|
||||
try {
|
||||
if (failure != null || saved == null || saved.isEmpty() || !execution.stillCurrent()) {
|
||||
execution.resolve(failure != null || saved == null || saved.isEmpty() ? "failed" : "interrupted",
|
||||
Map.of("reason", failure != null ? "health_save_failed" : saved == null || saved.isEmpty()
|
||||
? "health_save_unconfirmed" : "session_changed_before_healing"));
|
||||
player.sendMessage("Second Wind health could not be saved; no healing was applied."); return;
|
||||
}
|
||||
var persisted = saved.orElseThrow();
|
||||
if (!sessions.acknowledgeProgress(execution.input().session(), expected, persisted)) {
|
||||
execution.resolve("interrupted", Map.of("reason", "health_saved_session_changed"));
|
||||
player.sendMessage("Second Wind was saved, but the session changed; no native healing was applied."); return;
|
||||
}
|
||||
double nativeBefore = player.getHealth();
|
||||
double publish = Math.min(attribute.getValue(), Math.max(nativeBefore, persisted.health().doubleValue()));
|
||||
player.setHealth(publish);
|
||||
double restored = publish - nativeBefore;
|
||||
if (Double.isFinite(restored) && restored > 0) execution.report(AbilityReport.Type.HEALING,
|
||||
player, BigDecimal.valueOf(restored).stripTrailingZeros());
|
||||
try { player.setHealth(publish); }
|
||||
catch (RuntimeException nativeFailure) {
|
||||
execution.resolve("failed", Map.of("reason", "native_health_publication_failed"));
|
||||
throw nativeFailure;
|
||||
}
|
||||
double nativeAfter = player.getHealth();
|
||||
double restored = nativeAfter - nativeBefore;
|
||||
if (Double.isFinite(restored) && restored > 0) {
|
||||
try { execution.acceptedHeal(execution.input().session(), player, persisted, proposed,
|
||||
restored, nativeBefore, nativeAfter, maximum); }
|
||||
catch (RuntimeException ignored) { /* Auditing cannot change an applied native heal. */ }
|
||||
execution.resolve("healed", Map.of("reason", "native_self_heal_applied"));
|
||||
execution.report(AbilityReport.Type.HEALING, player,
|
||||
BigDecimal.valueOf(restored).stripTrailingZeros());
|
||||
} else execution.resolve("cancelled", Map.of("reason", "native_heal_not_observed"));
|
||||
} finally { execution.finish(); }
|
||||
});
|
||||
} catch (RuntimeException rejected) { execution.finish(); }
|
||||
|
||||
@@ -19,6 +19,7 @@ import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.player.PlayerVelocityEvent;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
@@ -39,9 +40,12 @@ public final class ShieldBash implements AbilityEffect, Listener, AutoCloseable
|
||||
}
|
||||
}
|
||||
private static final class Attempt {
|
||||
final AbilityExecution execution; final LivingEntity target; final Settings settings; boolean accepted;
|
||||
Attempt(AbilityExecution execution, LivingEntity target, Settings settings) {
|
||||
final AbilityExecution execution; final LivingEntity target; final Settings settings;
|
||||
final double requested; final double healthBefore;
|
||||
double finalDamage; boolean accepted; boolean cancelled;
|
||||
Attempt(AbilityExecution execution, LivingEntity target, Settings settings, double requested, double healthBefore) {
|
||||
this.execution = execution; this.target = target; this.settings = settings;
|
||||
this.requested = requested; this.healthBefore = healthBefore;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,42 +89,109 @@ public final class ShieldBash implements AbilityEffect, Listener, AutoCloseable
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent() || !(execution.input().target() instanceof LivingEntity target)) return;
|
||||
var current = from(execution.definition()); var player = execution.input().player();
|
||||
if (!inRange(player, target, current.range())) { execution.finish(); return; }
|
||||
if (!inRange(player, target, current.range())) {
|
||||
execution.resolve("miss", Map.of("reason", "target_out_of_range_or_obstructed"));
|
||||
execution.finish(); return;
|
||||
}
|
||||
var exact = damage(current.baseDamage(), execution.character().stats().get(Stat.STR));
|
||||
double amount = exact.bitLength() > 1023 ? Double.MAX_VALUE : exact.doubleValue();
|
||||
if (!Double.isFinite(amount) || amount <= 0) { execution.finish(); return; }
|
||||
if (!Double.isFinite(amount) || amount <= 0) {
|
||||
execution.resolve("miss", Map.of("reason", "damage_nonpositive")); execution.finish(); return;
|
||||
}
|
||||
var source = DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(player).withDirectEntity(player).build();
|
||||
var attempt = new Attempt(execution, target, current); damaging = attempt;
|
||||
try { execution.damage(target, amount, source); }
|
||||
finally { if (damaging == attempt) damaging = null; execution.finish(); }
|
||||
double beforeHealth;
|
||||
try { beforeHealth = target.getHealth(); }
|
||||
catch (RuntimeException unavailable) { beforeHealth = Double.NaN; }
|
||||
var attempt = new Attempt(execution, target, current, amount, beforeHealth); damaging = attempt;
|
||||
boolean nativeFailed = false;
|
||||
try {
|
||||
execution.damage(target, amount, source);
|
||||
if (attempt.accepted) publishAccepted(attempt);
|
||||
}
|
||||
catch (RuntimeException failed) {
|
||||
nativeFailed = true;
|
||||
if (!attempt.accepted) execution.resolve("failed", Map.of("reason", "native_damage_failed"));
|
||||
throw failed;
|
||||
} finally {
|
||||
if (damaging == attempt) damaging = null;
|
||||
if (!attempt.accepted && !nativeFailed)
|
||||
execution.resolve(attempt.cancelled ? "cancelled" : "miss",
|
||||
Map.of("reason", attempt.cancelled ? "native_damage_cancelled" : "native_damage_not_accepted"));
|
||||
execution.finish();
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
/** Yield generic incoming-player capture to the owning accepted Shield Bash strike. */
|
||||
public boolean ownsAcceptedDamage(EntityDamageEvent event) {
|
||||
return event instanceof EntityDamageByEntityEvent by && matches(damaging, by);
|
||||
}
|
||||
|
||||
private boolean matches(Attempt attempt, EntityDamageByEntityEvent event) {
|
||||
if (closed || attempt == null || event.getDamager() != attempt.execution.input().player()
|
||||
|| event.getEntity() != attempt.target) return false;
|
||||
var source = event.getDamageSource();
|
||||
return (source.getCausingEntity() == null || source.getCausingEntity() == attempt.execution.input().player())
|
||||
&& (source.getDirectEntity() == null || source.getDirectEntity() == attempt.execution.input().player());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
|
||||
public void acceptedDamage(EntityDamageByEntityEvent event) {
|
||||
var attempt = damaging;
|
||||
if (closed || attempt == null || attempt.accepted || event.isCancelled() || event.getFinalDamage() <= 0
|
||||
|| !Double.isFinite(event.getFinalDamage()) || event.getDamager() != attempt.execution.input().player()
|
||||
|| event.getEntity() != attempt.target || !attempt.execution.stillCurrent()) return;
|
||||
if (!matches(attempt, event) || attempt.accepted || !attempt.execution.stillCurrent()) return;
|
||||
if (event.isCancelled()) { attempt.cancelled = true; return; }
|
||||
if (event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
|
||||
attempt.accepted = true;
|
||||
attempt.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(),
|
||||
BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros());
|
||||
knockback(attempt);
|
||||
attempt.finalDamage = event.getFinalDamage();
|
||||
}
|
||||
|
||||
private void knockback(Attempt attempt) {
|
||||
var player = attempt.execution.input().player(); var target = attempt.target;
|
||||
Vector away = target.getLocation().toVector().subtract(player.getLocation().toVector()); away.setY(0);
|
||||
if (!finite(away) || away.lengthSquared() < 1.0e-12) return;
|
||||
Vector current = target.getVelocity();
|
||||
Vector proposed = current.clone().add(away.normalize().multiply(attempt.settings.horizontalKnockback()));
|
||||
proposed.setY(Math.max(current.getY(), attempt.settings.minimumVerticalKnockback()));
|
||||
if (!finite(proposed)) return;
|
||||
if (target instanceof Player targetPlayer) {
|
||||
var event = new PlayerVelocityEvent(targetPlayer, proposed.clone()); velocityEvents.accept(event);
|
||||
if (event.isCancelled() || !finite(event.getVelocity())) return;
|
||||
proposed = event.getVelocity().clone();
|
||||
}
|
||||
target.setVelocity(proposed);
|
||||
private void publishAccepted(Attempt attempt) {
|
||||
var evidence = new java.util.LinkedHashMap<String, Object>();
|
||||
evidence.put("stage", "native_damage_accepted");
|
||||
try {
|
||||
double afterHealth = attempt.target.getHealth();
|
||||
if (Double.isFinite(attempt.healthBefore) && attempt.healthBefore > 0
|
||||
&& Double.isFinite(afterHealth) && afterHealth >= 0) {
|
||||
evidence.put("target_health_before", BigDecimal.valueOf(attempt.healthBefore).stripTrailingZeros().toPlainString());
|
||||
evidence.put("target_health_after", BigDecimal.valueOf(afterHealth).stripTrailingZeros().toPlainString());
|
||||
}
|
||||
} catch (RuntimeException ignored) { /* Target health may be unavailable after a native hit. */ }
|
||||
try { attempt.execution.acceptedDamage(attempt.target, attempt.requested, attempt.finalDamage,
|
||||
attempt.execution.input().player(), evidence); }
|
||||
catch (RuntimeException ignored) { /* File auditing cannot interrupt accepted native damage. */ }
|
||||
try { attempt.execution.report(AbilityReport.Type.DAMAGE, attempt.target,
|
||||
BigDecimal.valueOf(attempt.finalDamage).stripTrailingZeros()); }
|
||||
catch (RuntimeException ignored) { /* Feedback cannot alter accepted damage or knockback. */ }
|
||||
String knockbackStatus = knockback(attempt);
|
||||
attempt.execution.resolve("hit", Map.of("reason", "native_damage_accepted",
|
||||
"knockback_status", knockbackStatus));
|
||||
}
|
||||
|
||||
private String knockback(Attempt attempt) {
|
||||
if (!attempt.execution.stillCurrent()) return "session_changed_after_damage";
|
||||
try {
|
||||
var player = attempt.execution.input().player(); var target = attempt.target;
|
||||
Vector away = target.getLocation().toVector().subtract(player.getLocation().toVector()); away.setY(0);
|
||||
if (!finite(away) || away.lengthSquared() < 1.0e-12) return "direction_unavailable";
|
||||
Vector current = target.getVelocity();
|
||||
Vector proposed = current.clone().add(away.normalize().multiply(attempt.settings.horizontalKnockback()));
|
||||
proposed.setY(Math.max(current.getY(), attempt.settings.minimumVerticalKnockback()));
|
||||
if (!finite(proposed)) return "velocity_invalid";
|
||||
if (target instanceof Player targetPlayer) {
|
||||
var event = new PlayerVelocityEvent(targetPlayer, proposed.clone()); velocityEvents.accept(event);
|
||||
if (event.isCancelled()) return "native_velocity_cancelled";
|
||||
if (!finite(event.getVelocity())) return "velocity_invalid";
|
||||
proposed = event.getVelocity().clone();
|
||||
}
|
||||
target.setVelocity(proposed);
|
||||
try { attempt.execution.effectApplied("shield_bash_knockback", target,
|
||||
Map.of("knockback_horizontal", decimal(attempt.settings.horizontalKnockback()),
|
||||
"accepted_velocity_x", decimal(proposed.getX()),
|
||||
"accepted_velocity_y", decimal(proposed.getY()),
|
||||
"accepted_velocity_z", decimal(proposed.getZ()),
|
||||
"status", "native_velocity_accepted")); }
|
||||
catch (RuntimeException ignored) { /* Accepted movement does not depend on auditing. */ }
|
||||
return "native_velocity_accepted";
|
||||
} catch (RuntimeException invalid) { return "native_velocity_failed"; }
|
||||
}
|
||||
|
||||
private static boolean inRange(Player player, LivingEntity target, double range) {
|
||||
|
||||
@@ -62,11 +62,24 @@ public final class StoneSkin implements AbilityEffect, AutoCloseable {
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var current = from(execution.definition()); var player = execution.input().player();
|
||||
var grant = mitigation.grant(execution, player, current.reductionPercent());
|
||||
var grant = mitigation.grantAccepted(execution, player, current.reductionPercent(), () -> true,
|
||||
accepted -> {
|
||||
try {
|
||||
execution.acceptedPrevention(execution.input().session(), player, accepted.event().getDamager(),
|
||||
accepted.incoming(), accepted.prevented(), accepted.remaining(), null);
|
||||
} catch (RuntimeException ignored) { /* Auditing cannot change an accepted native hit. */ }
|
||||
});
|
||||
var state = new State(execution, player, grant);
|
||||
var previous = protections.put(player.getUniqueId(), state);
|
||||
if (previous != null) previous.execution.finish();
|
||||
execution.own(state); execution.later(current.duration(), execution::finish);
|
||||
if (previous != null) { previous.execution.resolve("replaced"); previous.execution.finish(); }
|
||||
execution.own(state);
|
||||
var evidence = new java.util.LinkedHashMap<String, Object>();
|
||||
evidence.put("reduction_percent", current.reductionPercent().stripTrailingZeros().toPlainString());
|
||||
evidence.put("duration_seconds", BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9).stripTrailingZeros().toPlainString());
|
||||
var activatedAt = execution.now();
|
||||
if (activatedAt != null) evidence.put("effect_until", activatedAt.plus(current.duration()).toString());
|
||||
execution.effectApplied("stone_skin", player, evidence);
|
||||
execution.later(current.duration(), () -> { execution.resolve("expired"); execution.finish(); });
|
||||
}
|
||||
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
@@ -78,7 +91,9 @@ public final class StoneSkin implements AbilityEffect, AutoCloseable {
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true;
|
||||
for (var state : List.copyOf(protections.values())) state.execution.finish();
|
||||
for (var state : List.copyOf(protections.values())) {
|
||||
state.execution.resolve("interrupted"); state.execution.finish();
|
||||
}
|
||||
protections.clear();
|
||||
}
|
||||
}
|
||||
@@ -13,21 +13,83 @@ import org.bukkit.projectiles.ProjectileSource;
|
||||
/** Plugin-owned temporary-health ledger layered over unrelated native absorption. */
|
||||
public final class TemporaryHealth implements Listener,AutoCloseable{
|
||||
public record Support(UUID target,UUID supportingCharacter,EquipmentSessions.Active aided,BigDecimal amount){}
|
||||
private final class Grant implements AutoCloseable{final long sequence;final AbilityExecution execution;final Player target;double remaining;boolean closed;
|
||||
Grant(AbilityExecution e,Player t,double amount){sequence=++sequences;execution=e;target=t;remaining=amount;}
|
||||
@Override public void close(){if(closed)return;double before=owned(target.getUniqueId());closed=true;var list=grants.get(target.getUniqueId());if(list!=null){list.remove(this);if(list.isEmpty())grants.remove(target.getUniqueId());}double after=owned(target.getUniqueId());removeNative(target,Math.max(0,before-after));}}
|
||||
public record Accepted(Player target,Entity attacker,double incoming,double before,double after,
|
||||
double consumed,double remaining,org.bukkit.Location targetAtHit,
|
||||
org.bukkit.Location attackerAtHit){}
|
||||
private final class Grant implements AutoCloseable{final long sequence;final AbilityExecution execution;final Player target;
|
||||
final Consumer<Accepted> observation;final Consumer<Double> onEnd;double remaining;boolean closed;
|
||||
Grant(AbilityExecution e,Player t,double amount,Consumer<Accepted> observation,Consumer<Double> onEnd){
|
||||
sequence=++sequences;execution=e;target=t;remaining=amount;this.observation=observation;this.onEnd=onEnd;}
|
||||
@Override public void close(){if(closed)return;double before=owned(target.getUniqueId());closed=true;var list=grants.get(target.getUniqueId());if(list!=null){list.remove(this);if(list.isEmpty())grants.remove(target.getUniqueId());}double after=owned(target.getUniqueId());removeNative(target,Math.max(0,before-after));if(onEnd!=null)try{onEnd.accept(Math.max(0,remaining));}catch(RuntimeException ignored){/* Cleanup is independent of recording. */}}
|
||||
}
|
||||
private final EquipmentSessions sessions;private final PartyRoster parties;private final Consumer<Runnable> nextTick;private final Consumer<Support> supports;
|
||||
private final Map<UUID,List<Grant>> grants=new HashMap<>();private long sequences;private boolean closed;
|
||||
private final Map<UUID,List<Grant>> grants=new HashMap<>();
|
||||
private final Set<EntityDamageEvent> pending=Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
private long sequences;private boolean closed;
|
||||
public TemporaryHealth(EquipmentSessions sessions,PartyRoster parties,Consumer<Runnable> nextTick,Consumer<Support> supports){this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.nextTick=Objects.requireNonNull(nextTick);this.supports=Objects.requireNonNull(supports);}
|
||||
public AutoCloseable grant(AbilityExecution execution,Player target,double amount){Objects.requireNonNull(execution);Objects.requireNonNull(target);if(closed||!Double.isFinite(amount)||amount<=0)throw new IllegalArgumentException("Invalid temporary health grant");double before=owned(target.getUniqueId());var grant=new Grant(execution,target,amount);grants.computeIfAbsent(target.getUniqueId(),ignored->new ArrayList<>()).add(grant);double after=owned(target.getUniqueId());addNative(target,Math.max(0,after-before));return grant;}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void damage(EntityDamageEvent event){if(closed||!(event.getEntity() instanceof Player target))return;var owner=strongest(target.getUniqueId());if(owner==null||!owner.execution.stillCurrent())return;double before=target.getAbsorptionAmount(),owned=owned(target.getUniqueId());if(!Double.isFinite(before)||before<=0||owned<=0)return;Entity attacker=event instanceof EntityDamageByEntityEvent by?attacker(by):null;var aided=sessions.active(target.getUniqueId()).orElse(null);boolean qualifies=aided!=null&&sessions.current(aided)&&!aided.character().equals(owner.execution.input().session().character())&&sameParty(owner.execution.input().player(),target);UUID enemy=attacker==null?null:attacker.getUniqueId();
|
||||
try{nextTick.accept(()->consume(target,before,owned,owner,qualifies?aided:null,enemy));}catch(RuntimeException ignored){}}
|
||||
private void consume(Player target,double before,double ownedBefore,Grant owner,EquipmentSessions.Active aided,UUID enemy){if(closed)return;double after=target.getAbsorptionAmount();double consumed=Math.min(ownedBefore,Math.max(0,before-after));if(!Double.isFinite(consumed)||consumed<=0)return;var list=grants.get(target.getUniqueId());if(list==null)return;var amount=BigDecimal.valueOf(consumed).stripTrailingZeros();owner.execution.report(AbilityReport.Type.PREVENTION,target,amount);for(var grant:List.copyOf(list))grant.remaining=Math.max(0,grant.remaining-consumed);list.removeIf(g->g.closed||g.remaining<=1e-9);if(list.isEmpty())grants.remove(target.getUniqueId());if(aided!=null&&enemy!=null)supports.accept(new Support(enemy,owner.execution.input().session().character(),aided,amount));}
|
||||
public AutoCloseable grant(AbilityExecution execution,Player target,double amount){
|
||||
return grantObserved(execution,target,amount,null);
|
||||
}
|
||||
public AutoCloseable grantObserved(AbilityExecution execution,Player target,double amount,Consumer<Accepted> observation){
|
||||
return grantObserved(execution,target,amount,observation,null);
|
||||
}
|
||||
public AutoCloseable grantObserved(AbilityExecution execution,Player target,double amount,
|
||||
Consumer<Accepted> observation,Consumer<Double> onEnd){
|
||||
Objects.requireNonNull(execution);Objects.requireNonNull(target);
|
||||
if(closed||!Double.isFinite(amount)||amount<=0)throw new IllegalArgumentException("Invalid temporary health grant");
|
||||
double before=owned(target.getUniqueId());var grant=new Grant(execution,target,amount,observation,onEnd);
|
||||
addNative(target,Math.max(0,amount-before)); // Do not retain an owned grant if the native setter refuses it.
|
||||
grants.computeIfAbsent(target.getUniqueId(),ignored->new ArrayList<>()).add(grant);
|
||||
return grant;
|
||||
}
|
||||
public Optional<EquipmentSessions.Active> currentSession(Player target){
|
||||
return sessions.active(target.getUniqueId()).filter(sessions::current);
|
||||
}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void damage(EntityDamageEvent event){
|
||||
if(closed||pending.contains(event)||!(event.getEntity() instanceof Player target)
|
||||
||!Double.isFinite(event.getDamage())||event.getDamage()<=0)return;
|
||||
var owner=strongest(target.getUniqueId());if(owner==null||!owner.execution.stillCurrent())return;
|
||||
double before=target.getAbsorptionAmount(),owned=owned(target.getUniqueId());
|
||||
if(!Double.isFinite(before)||before<=0||owned<=0)return;
|
||||
Entity attacker=event instanceof EntityDamageByEntityEvent by?attacker(by):null;
|
||||
var targetAtHit=auditLocation(target);var attackerAtHit=auditLocation(attacker);
|
||||
var aided=sessions.active(target.getUniqueId()).orElse(null);
|
||||
boolean qualifies=aided!=null&&sessions.current(aided)&&!aided.character().equals(owner.execution.input().session().character())&&sameParty(owner.execution.input().player(),target);
|
||||
UUID enemy=attacker==null?null:attacker.getUniqueId();double incoming=event.getDamage();
|
||||
pending.add(event);
|
||||
try{nextTick.accept(()->{
|
||||
try{consume(event,target,before,owned,owner,qualifies?aided:null,enemy,attacker,incoming,targetAtHit,attackerAtHit);}
|
||||
finally{pending.remove(event);}
|
||||
});}
|
||||
catch(RuntimeException ignored){pending.remove(event);}
|
||||
}
|
||||
private void consume(EntityDamageEvent event,Player target,double before,double ownedBefore,Grant owner,
|
||||
EquipmentSessions.Active aided,UUID enemy,Entity attacker,double incoming,
|
||||
org.bukkit.Location targetAtHit,org.bukkit.Location attackerAtHit){
|
||||
if(closed||event.isCancelled()||!Double.isFinite(event.getDamage())||event.getDamage()<=0)return;
|
||||
double after=target.getAbsorptionAmount();double consumed=Math.min(ownedBefore,Math.max(0,before-after));
|
||||
if(!Double.isFinite(consumed)||consumed<=0)return;
|
||||
var list=grants.get(target.getUniqueId());if(list==null||owner.closed||!list.contains(owner)||!owner.execution.stillCurrent())return;
|
||||
var amount=BigDecimal.valueOf(consumed).stripTrailingZeros();
|
||||
try{owner.execution.report(AbilityReport.Type.PREVENTION,target,amount);}
|
||||
catch(RuntimeException ignored){/* Feedback must not affect accepted absorption. */}
|
||||
for(var grant:List.copyOf(list))grant.remaining=Math.max(0,grant.remaining-consumed);
|
||||
list.removeIf(g->g.closed||g.remaining<=1e-9);if(list.isEmpty())grants.remove(target.getUniqueId());
|
||||
if(owner.observation!=null)try{owner.observation.accept(new Accepted(target,attacker,incoming,before,after,
|
||||
consumed,Math.max(0,owner.remaining),targetAtHit,attackerAtHit));}
|
||||
catch(RuntimeException ignored){/* Audit must not affect native absorption or rewards. */}
|
||||
if(aided!=null&&enemy!=null)supports.accept(new Support(enemy,owner.execution.input().session().character(),aided,amount));
|
||||
}
|
||||
private static org.bukkit.Location auditLocation(Entity entity){
|
||||
if(entity==null)return null;
|
||||
try{var location=entity.getLocation();return location==null?null:location.clone();}
|
||||
catch(RuntimeException unavailable){return null;}
|
||||
}
|
||||
private boolean sameParty(Player source,Player target){var a=parties.partyOf(source.getUniqueId()).orElse(null);var b=parties.partyOf(target.getUniqueId()).orElse(null);return a!=null&&b!=null&&a.id().equals(b.id());}
|
||||
private Grant strongest(UUID target){Grant result=null;var list=grants.get(target);if(list!=null)for(var grant:List.copyOf(list))if(!grant.closed&&grant.remaining>0&&grant.execution.stillCurrent()&&(result==null||grant.remaining>result.remaining||grant.remaining==result.remaining&&grant.sequence>result.sequence))result=grant;return result;}
|
||||
private double owned(UUID target){double result=0;var list=grants.get(target);if(list!=null)for(var grant:List.copyOf(list))if(!grant.closed&&grant.remaining>result)result=grant.remaining;return result;}
|
||||
private static Entity attacker(EntityDamageByEntityEvent event){Entity causing=event.getDamageSource().getCausingEntity();if(causing!=null)return causing;if(event.getDamager() instanceof Projectile p){ProjectileSource shooter=p.getShooter();if(shooter instanceof Entity e)return e;}return event.getDamager();}
|
||||
private static void addNative(Player target,double amount){if(amount<=0)return;double current=target.getAbsorptionAmount(),next=current+amount;if(!Double.isFinite(current)||current<0||!Double.isFinite(next))throw new IllegalStateException("Temporary health exceeds native range");target.setAbsorptionAmount(next);}
|
||||
private static void removeNative(Player target,double amount){if(amount<=0)return;double current=target.getAbsorptionAmount();if(Double.isFinite(current)&¤t>=0)target.setAbsorptionAmount(Math.max(0,current-amount));}
|
||||
@Override public void close(){if(closed)return;for(var list:List.copyOf(grants.values()))for(var grant:List.copyOf(list))grant.close();closed=true;grants.clear();}
|
||||
@Override public void close(){if(closed)return;for(var list:List.copyOf(grants.values()))for(var grant:List.copyOf(list))grant.close();closed=true;grants.clear();pending.clear();}
|
||||
}
|
||||
@@ -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,7 +36,41 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
|
||||
public void open(Player player){open(player,0);}
|
||||
private void open(Player player,int requestedPage){var context=context(player);if(context==null){player.sendMessage("Quest item turn-in requires an active living character.");return;}for(var pending:fishing.pendingCatches())if(pending.character().equals(context.record().id()))recoverCatch(pending);var entries=new ArrayList<ItemStack>();var quest=fishingQuest(context);if(quest!=null){var terms=quest.fishing();entries.add(item(Material.EMERALD,"Submit "+terms.itemName(),"submit",List.of("Held: "+fishing.quantity(context.record().id(),terms.itemId())+" / "+terms.required(),"Reward: "+terms.rewardXp()+" XP, "+terms.rewardCoins()+" coins",quest.unitType()==AdventureQuestState.UnitType.PARTY?"One accepted member supplies all items":"Individual submission","Beneficiaries: "+beneficiaries(context,quest).size()+" accepted character(s)")));}for(var trophy:fishing.trophies(context.record().id())){var total=trophy.total();entries.add(item(Material.PLAYER_HEAD,"Redeem "+trophy.name(),"trophy:"+trophy.id(),List.of("Origin: "+trophy.origin(),"Reward pool: "+total.xp()+" XP, "+total.coins()+" coins","Beneficiaries: "+trophy.rewards().size()+" frozen character(s)")));}var legacy=adventures.unclaimedAll(context.record().id());if(legacy.xp().signum()>0||legacy.coins().signum()>0)entries.add(item(Material.NETHER_STAR,"Claim retained adventure rewards","legacy-claim",List.of(legacy.xp()+" XP, "+legacy.coins()+" coins","Previously earned shares do not expire")));var reward=fishing.unclaimed(context.record().id());if(reward.xp().signum()>0||reward.coins().signum()>0)entries.add(item(Material.GOLD_INGOT,"Claim earned quest rewards","claim",List.of(reward.xp()+" XP, "+reward.coins()+" coins","Earned shares do not expire")));int pages=Math.max(1,(entries.size()+44)/45),page=Math.max(0,Math.min(requestedPage,pages-1));UUID nonce=UUID.randomUUID();menus.put(player.getUniqueId(),nonce);var inventory=plugin.getServer().createInventory(new Holder(player.getUniqueId(),context.active().session(),context.record().id(),nonce,page),54,net.kyori.adventure.text.Component.text("Turn in quest items "+(page+1)+"/"+pages));int start=page*45;for(int index=start;index<Math.min(entries.size(),start+45);index++)inventory.setItem(index-start,entries.get(index));if(entries.isEmpty())inventory.setItem(22,item(Material.BARRIER,"No eligible fishing items or rewards",null,List.of()));if(page>0)inventory.setItem(45,item(Material.ARROW,"Previous","page:"+(page-1),List.of()));if(page+1<pages)inventory.setItem(53,item(Material.ARROW,"Next","page:"+(page+1),List.of()));player.openInventory(inventory);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof Holder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null||closed)return;String action=event.getCurrentItem().getItemMeta().getPersistentDataContainer().get(actionKey,PersistentDataType.STRING);if(action==null)return;var context=context(player);if(context==null||!holder.player().equals(player.getUniqueId())||!holder.session().equals(context.active().session())||!holder.character().equals(context.record().id())||!menus.remove(player.getUniqueId(),holder.nonce())){player.sendMessage("Quest item action refused: the menu, character or session changed.");return;}if(action.startsWith("page:")){try{open(player,Integer.parseInt(action.substring(5)));}catch(NumberFormatException invalid){player.sendMessage("Quest item page refused.");}return;}if(action.startsWith("trophy:")){redeemTrophy(player,context,action.substring(7));return;}if(action.equals("legacy-claim")){claimLegacy(player,context);return;}if(action.equals("claim")){claim(player,context);return;}if(action.equals("submit"))submit(player,context);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void fish(PlayerFishEvent event){if(closed)return;var player=event.getPlayer();var item=player.getInventory().getItemInMainHand();if(!nativeUse(player,item))return;var context=context(player);var quest=context==null?null:adventures.findQuest(AdventureQuestNavigation.fishingQuest(item)).orElse(null);if(context==null||quest==null||!eligible(context,quest))return;switch(event.getState()){case FISHING->{var hook=event.getHook();hooks.put(player.getUniqueId(),new HookClaim(player.getUniqueId(),context.active().session(),context.record().id(),quest.id(),hook.getUniqueId(),sampleXp()));}case CAUGHT_ENTITY->{event.setCancelled(true);hooks.remove(player.getUniqueId());event.getHook().remove();player.sendMessage("Quest Fishing Rods cannot pull entities.");}case CAUGHT_FISH->{var claim=hooks.remove(player.getUniqueId());if(claim==null||!claim.session().equals(context.active().session())||!claim.character().equals(context.record().id())||!claim.quest().equals(quest.id())||!claim.hook().equals(event.getHook().getUniqueId())||!(event.getCaught() instanceof Item caught)||!inside(player.getLocation(),quest)||!inside(event.getHook().getLocation(),quest)){event.setCancelled(true);event.setExpToDrop(0);if(event.getCaught()!=null)event.getCaught().remove();player.sendMessage("Quest catch refused: the owned hook and fishing area must remain valid.");return;}event.setCancelled(true);event.setExpToDrop(0);caught.remove();BigInteger xp=claim.sampledXp();boolean awarded=random.nextDouble(100d)<quest.fishing().chancePercent().doubleValue();UUID operation=UUID.nameUUIDFromBytes((quest.id()+":"+claim.hook()).getBytes(StandardCharsets.UTF_8));var plan=new FishingState.Catch(operation,context.record().id(),quest.id(),quest.site(),quest.fishing().itemId(),quest.fishing().itemName(),xp,awarded);fishing.prepareCatch(plan).whenComplete((status,failure)->{if(failure!=null){report(plan,"prepare",failure);return;}if(status==FishingDirectory.PrepareStatus.APPLIED||status==FishingDirectory.PrepareStatus.EXISTING)recoverCatch(plan);});}default->{if(event.getState()!=PlayerFishEvent.State.BITE)hooks.remove(player.getUniqueId());}}}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)
|
||||
public void fish(PlayerFishEvent event){
|
||||
if(closed)return;
|
||||
var player=event.getPlayer();
|
||||
var item=player.getInventory().getItemInMainHand();
|
||||
if(!nativeUse(player,item))return;
|
||||
var context=context(player);
|
||||
var quest=context==null?null:adventures.findQuest(AdventureQuestNavigation.fishingQuest(item)).orElse(null);
|
||||
if(context==null||quest==null||!eligible(context,quest))return;
|
||||
switch(event.getState()){
|
||||
case FISHING->{var hook=event.getHook();hooks.put(player.getUniqueId(),new HookClaim(player.getUniqueId(),context.active().session(),context.record().id(),quest.id(),hook.getUniqueId(),sampleXp()));}
|
||||
case LURED,BITE->{} // Approach and bite are not reels; retain the original owned hook.
|
||||
case CAUGHT_ENTITY->{event.setCancelled(true);hooks.remove(player.getUniqueId());event.getHook().remove();player.sendMessage("Quest Fishing Rods cannot pull entities.");}
|
||||
case CAUGHT_FISH->{
|
||||
var claim=hooks.get(player.getUniqueId());
|
||||
if(claim==null||!claim.session().equals(context.active().session())||!claim.character().equals(context.record().id())||!claim.quest().equals(quest.id())||!claim.hook().equals(event.getHook().getUniqueId())){
|
||||
refuseCatch(event,"Quest catch refused: the cast is no longer valid; cast again.");return;
|
||||
}
|
||||
hooks.remove(player.getUniqueId(),claim); // A late callback for another hook must not consume the current cast.
|
||||
if(!(event.getCaught() instanceof Item caught)){
|
||||
refuseCatch(event,"Quest catch refused: no native fish item was caught.");return;
|
||||
}
|
||||
if(!inside(player.getLocation(),quest)||!inside(event.getHook().getLocation(),quest)){
|
||||
refuseCatch(event,"Quest catch refused: keep yourself and the bobber inside the fishing area.");return;
|
||||
}
|
||||
event.setCancelled(true);event.setExpToDrop(0);caught.remove();event.getHook().remove();
|
||||
BigInteger xp=claim.sampledXp();boolean awarded=random.nextDouble(100d)<quest.fishing().chancePercent().doubleValue();
|
||||
UUID operation=UUID.nameUUIDFromBytes((quest.id()+":"+claim.hook()).getBytes(StandardCharsets.UTF_8));
|
||||
var plan=new FishingState.Catch(operation,context.record().id(),quest.id(),quest.site(),quest.fishing().itemId(),quest.fishing().itemName(),xp,awarded);
|
||||
fishing.prepareCatch(plan).whenComplete((status,failure)->{if(failure!=null){report(plan,"prepare",failure);return;}if(status==FishingDirectory.PrepareStatus.APPLIED||status==FishingDirectory.PrepareStatus.EXISTING)recoverCatch(plan);});
|
||||
}
|
||||
case IN_GROUND,FAILED_ATTEMPT,REEL_IN->hooks.remove(player.getUniqueId());
|
||||
}
|
||||
}
|
||||
private void refuseCatch(PlayerFishEvent event,String reason){event.setCancelled(true);event.setExpToDrop(0);if(event.getCaught()!=null)event.getCaught().remove();event.getPlayer().sendMessage(reason);}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void quit(org.bukkit.event.player.PlayerQuitEvent event){cleanup(event.getPlayer());}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void changed(org.bukkit.event.player.PlayerItemHeldEvent event){cleanup(event.getPlayer());}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void opened(org.bukkit.event.inventory.InventoryOpenEvent event){if(event.getPlayer() instanceof Player player&&hooks.containsKey(player.getUniqueId()))cleanup(player);}
|
||||
|
||||
@@ -30,8 +30,17 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
private final games.dmg.fantasystick.passive.PassiveRegistry passives;
|
||||
private final games.dmg.fantasystick.passive.PassiveEngine passiveEngine;
|
||||
private games.dmg.fantasystick.adventure.FishingDirectory fishing;
|
||||
private games.dmg.fantasystick.shop.RareInventoryDirectory rareInventory;
|
||||
private games.dmg.fantasystick.shop.RarePotionService rarePotions;
|
||||
private games.dmg.fantasystick.shop.RareReskillService rareReskills;
|
||||
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<>();
|
||||
@@ -45,21 +54,30 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
private static final net.kyori.adventure.key.Key ABILITY_UNLEARN = net.kyori.adventure.key.Key.key("fantasystick", "ability_unlearn");
|
||||
private static final net.kyori.adventure.key.Key PASSIVE_UNLOCK = net.kyori.adventure.key.Key.key("fantasystick", "passive_unlock");
|
||||
private static final net.kyori.adventure.key.Key PASSIVE_UNLEARN = net.kyori.adventure.key.Key.key("fantasystick", "passive_unlearn");
|
||||
private static final net.kyori.adventure.key.Key RARE_USE = net.kyori.adventure.key.Key.key("fantasystick", "rare_use");
|
||||
private static final net.kyori.adventure.key.Key RARE_RESKILL_CONFIRM = net.kyori.adventure.key.Key.key("fantasystick", "rare_reskill_confirm");
|
||||
private static final java.util.regex.Pattern PAGE_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([01])\"");
|
||||
private static final java.util.regex.Pattern STAT_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):(STR|DEX|CON|INT|WIS|CHR)\"");
|
||||
private static final java.util.regex.Pattern ABILITY_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([a-z][a-z0-9_]{0,63})\"");
|
||||
private record View(String nonce, ControlInput input, int previous, int next) { }
|
||||
private static final java.util.regex.Pattern RARE_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([a-z][a-z0-9-]{0,63})\"");
|
||||
private static final java.util.regex.Pattern RARE_CONFIRM_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36})\"");
|
||||
private record View(String nonce, ControlInput input, int start, int navigationPage,
|
||||
int previous, int next) { }
|
||||
private record AdminView(String nonce, UUID administrator, org.bukkit.entity.Player target,
|
||||
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<>();
|
||||
private final Map<UUID, StatView> statViews = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, AbilityView> abilityViews = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, PassiveView> passiveViews = new ConcurrentHashMap<>();
|
||||
private final Map<UUID,RareView> rareViews=new ConcurrentHashMap<>();
|
||||
private final Map<UUID,ReskillView> reskillViews=new ConcurrentHashMap<>();
|
||||
|
||||
/** Scheduler must always defer to the server thread. Diagnostics must not access players or throw. */
|
||||
public CharacterBookController(CharacterStorage characters, ProgressionRules rules,
|
||||
@@ -103,8 +121,14 @@ 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);
|
||||
@@ -220,7 +244,11 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
});
|
||||
}
|
||||
|
||||
private void load(ControlInput input, int offset) {
|
||||
private void load(ControlInput input, int offset, boolean userRequested) {
|
||||
load(input, offset, userRequested, null);
|
||||
}
|
||||
|
||||
private void load(ControlInput input, int offset, boolean userRequested, View from) {
|
||||
if (closed || input.kind() != ControlInput.Kind.BOOK || input.gesture() != ControlInput.Gesture.USE
|
||||
|| !input.stillCurrent()) return;
|
||||
var owner = input.session().player();
|
||||
@@ -229,32 +257,49 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
statViews.remove(owner);
|
||||
abilityViews.remove(owner);
|
||||
passiveViews.remove(owner);
|
||||
rareViews.remove(owner);
|
||||
reskillViews.remove(owner);
|
||||
var current=sessions.currentRecord(input.session()).orElse(null);
|
||||
var navigation=from==null?null:new games.dmg.fantasystick.events.BookEventBridge.Navigation(
|
||||
from.start(),from.navigationPage(),offset);
|
||||
var audit=userRequested&&bookEvents!=null&¤t!=null?bookEvents.request(input,current,offset,navigation):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);
|
||||
}
|
||||
@@ -280,7 +325,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());
|
||||
@@ -303,11 +348,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,
|
||||
@@ -360,8 +405,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
if (allOverview.size() > VOLUME_SIZE) {
|
||||
Component navigation = Component.text("Character pages\n\n" + (start + 1) + "–" + end + "/" + allOverview.size());
|
||||
if (interactive) {
|
||||
var view = new View(UUID.randomUUID().toString(), input, start > 0 ? start - VOLUME_SIZE : -1,
|
||||
end < allOverview.size() ? end : -1);
|
||||
var view = new View(UUID.randomUUID().toString(), input, start, pages.size() + 1,
|
||||
start > 0 ? start - VOLUME_SIZE : -1, end < allOverview.size() ? end : -1);
|
||||
views.put(input.session().player(), view);
|
||||
if (view.previous() >= 0) navigation = navigation.append(Component.newline())
|
||||
.append(pageButton("Previous pages", view, 0));
|
||||
@@ -387,7 +432,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
}
|
||||
pages.add(navigation);
|
||||
}
|
||||
if(end==allOverview.size())pages.addAll(inventoryPages(record.id()));
|
||||
if(end==allOverview.size())pages.addAll(inventoryPages(record.id(),input));
|
||||
if (end == allOverview.size()) pages.addAll(interactive
|
||||
? partyControls.pages(input) : savedState
|
||||
? partyControls.readOnlyCharacterPages(record.id())
|
||||
@@ -397,7 +442,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
return item;
|
||||
}
|
||||
|
||||
private java.util.List<Component> inventoryPages(UUID character){var directory=fishing;if(directory==null)return java.util.List.of();var values=directory.inventory(character);var trophies=directory.trophies(character);var builder=new PageBuilder();builder.text("Inventory");builder.blank();if(values.isEmpty()&&trophies.isEmpty())builder.text("No quest 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());}if(values.size()+trophies.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()));}
|
||||
private static String tier(games.dmg.fantasystick.persistence.GearTier tier){String value=tier.name().toLowerCase(java.util.Locale.ROOT);return Character.toUpperCase(value.charAt(0))+value.substring(1);}
|
||||
@@ -727,15 +773,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, view);
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -751,49 +802,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.";
|
||||
@@ -801,7 +856,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
|
||||
@@ -811,18 +866,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);
|
||||
@@ -830,10 +883,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());
|
||||
@@ -841,16 +896,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);
|
||||
@@ -858,10 +911,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) {
|
||||
@@ -895,6 +950,14 @@ 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,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,false);});}
|
||||
|
||||
@org.bukkit.event.EventHandler
|
||||
public void partyAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) { partyControls.click(event); }
|
||||
|
||||
@@ -902,6 +965,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);
|
||||
@@ -909,10 +973,12 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
statViews.remove(owner);
|
||||
abilityViews.remove(owner);
|
||||
passiveViews.remove(owner);
|
||||
rareViews.remove(owner);
|
||||
reskillViews.remove(owner);
|
||||
partyControls.forget(owner);
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
closed = true; pending.clear(); adminPending.clear(); adminViews.clear(); savedViews.clear(); views.clear(); statViews.clear(); abilityViews.clear(); passiveViews.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();
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
|
||||
private final CombatFeedback feedback;
|
||||
private final games.dmg.fantasystick.training.TrainingDamage trainingDamage;
|
||||
private final Map<UUID, Instant> readyAt = new HashMap<>();
|
||||
private games.dmg.fantasystick.events.BasicAttackEventBridge events;
|
||||
private Pending pending;
|
||||
private boolean closed;
|
||||
|
||||
@@ -36,9 +37,18 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
|
||||
final LivingEntity target;
|
||||
final Instant attackedAt;
|
||||
final boolean knockback;
|
||||
final games.dmg.fantasystick.events.BasicAttackEventBridge.Action action;
|
||||
final double requested;
|
||||
boolean accepted;
|
||||
Pending(ControlInput input, LivingEntity target, Instant attackedAt, boolean knockback) {
|
||||
boolean nativeSeen;
|
||||
boolean nativePriorCancelled;
|
||||
boolean nativeCancelled;
|
||||
Double nativeFinal;
|
||||
EntityDamageByEntityEvent acceptedEvent;
|
||||
Pending(ControlInput input, LivingEntity target, Instant attackedAt, boolean knockback,
|
||||
double requested, games.dmg.fantasystick.events.BasicAttackEventBridge.Action action) {
|
||||
this.input = input; this.target = target; this.attackedAt = attackedAt; this.knockback = knockback;
|
||||
this.requested = requested; this.action = action;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,26 +72,38 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
|
||||
this.trainingDamage = Objects.requireNonNull(trainingDamage);
|
||||
}
|
||||
|
||||
public void attachEvents(games.dmg.fantasystick.events.BasicAttackEventBridge bridge) {
|
||||
if (closed || events != null) throw new IllegalStateException("Basic attack events already attached");
|
||||
events = Objects.requireNonNull(bridge);
|
||||
}
|
||||
|
||||
@Override public void accept(ControlInput input) {
|
||||
if (closed || pending != null || input.kind() != ControlInput.Kind.BASIC_ATTACK
|
||||
|| input.gesture() != ControlInput.Gesture.ATTACK
|
||||
|| !(input.target() instanceof LivingEntity target) || !input.stillCurrent()) return;
|
||||
var record = sessions.currentRecord(input.session()).orElse(null);
|
||||
if (record == null) return;
|
||||
if (input.kind() != ControlInput.Kind.BASIC_ATTACK || input.gesture() != ControlInput.Gesture.ATTACK) return;
|
||||
Instant now = clock.instant();
|
||||
Instant ready = readyAt.get(input.session().character());
|
||||
if (ready != null && now.isBefore(ready)) return;
|
||||
if (closed || pending != null || !(input.target() instanceof LivingEntity) || !input.stillCurrent()) {
|
||||
refuse(input, null, now, ready, closed ? "closed" : pending != null ? "attack_in_progress"
|
||||
: !(input.target() instanceof LivingEntity) ? "ineligible_target" : "stale_input");
|
||||
return;
|
||||
}
|
||||
var target = (LivingEntity) input.target();
|
||||
var record = sessions.currentRecord(input.session()).orElse(null);
|
||||
if (record == null) { refuse(input, null, now, ready, "missing_character"); return; }
|
||||
if (ready != null && now.isBefore(ready)) { refuse(input, record, now, ready, "cadence_not_ready"); return; }
|
||||
BigInteger damage = damage(record.stats().get(Stat.STR));
|
||||
double nativeDamage = damage.bitLength() > 1023 ? Double.MAX_VALUE : damage.doubleValue();
|
||||
var action = events == null ? null : events.observed(input, record, target, now, ready, nativeDamage);
|
||||
var attempt = new Pending(input, target, now,
|
||||
record.passives().contains(games.dmg.fantasystick.passive.StickKnockback.ID));
|
||||
record.passives().contains(games.dmg.fantasystick.passive.StickKnockback.ID), nativeDamage, action);
|
||||
pending = attempt;
|
||||
boolean completed = false;
|
||||
try {
|
||||
var measured = trainingDamage.apply(input.player(), target, () -> target.damage(nativeDamage, input.player()));
|
||||
measured.ifPresent(amount -> {
|
||||
attempt.accepted = true;
|
||||
if (feedback != null) feedback.report("Basic Attack", input.player(), AbilityReport.Type.DAMAGE, target, amount);
|
||||
});
|
||||
completed = true;
|
||||
} finally {
|
||||
if (pending == attempt) pending = null;
|
||||
if (attempt.accepted) {
|
||||
@@ -91,9 +113,24 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
|
||||
games.dmg.fantasystick.passive.StickKnockback.apply(input.player(), target);
|
||||
}
|
||||
}
|
||||
if (events != null) events.resolved(action, attempt.accepted ? "accepted" : !completed ? "failed"
|
||||
: attempt.nativeCancelled ? "cancelled" : "refused",
|
||||
attempt.accepted ? attempt.nativeSeen ? "native_positive_hit" : "training_measurement"
|
||||
: !completed ? "native_damage_failed" : attempt.nativePriorCancelled ? "prior_cancelled"
|
||||
: attempt.nativeCancelled ? "native_cancelled" : attempt.nativeSeen
|
||||
? "zero_or_invalid_damage" : "native_hit_unverified",
|
||||
attempt.nativePriorCancelled, attempt.nativeCancelled, attempt.nativeFinal,
|
||||
attempt.accepted ? readyAt.get(input.session().character()) : null);
|
||||
}
|
||||
}
|
||||
|
||||
private void refuse(ControlInput input, games.dmg.fantasystick.persistence.CharacterRecord record,
|
||||
Instant now, Instant ready, String reason) {
|
||||
if (events == null) return;
|
||||
var action = events.observed(input, record, input.target(), now, ready, null);
|
||||
events.resolved(action, "refused", reason, false, false, null, null);
|
||||
}
|
||||
|
||||
static BigInteger damage(BigInteger strength) {
|
||||
Objects.requireNonNull(strength);
|
||||
if (strength.signum() < 0) throw new IllegalArgumentException("Strength must not be negative");
|
||||
@@ -101,13 +138,35 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
|
||||
}
|
||||
|
||||
/** MONITOR observes the synchronous native result; cancelled/zero damage never spends cadence. */
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
@EventHandler(priority = EventPriority.LOWEST)
|
||||
public void observedNativeDamage(EntityDamageByEntityEvent event) {
|
||||
var attempt = pending;
|
||||
if (attempt != null && event.getDamager() == attempt.input.player() && event.getEntity() == attempt.target)
|
||||
attempt.nativePriorCancelled = event.isCancelled();
|
||||
}
|
||||
|
||||
public boolean ownsAcceptedDamage(org.bukkit.event.entity.EntityDamageEvent event) {
|
||||
var attempt = pending;
|
||||
if (closed || attempt == null || !(event instanceof EntityDamageByEntityEvent by)) return false;
|
||||
if (attempt.accepted) return attempt.acceptedEvent == event;
|
||||
return by.getDamager() == attempt.input.player() && by.getEntity() == attempt.target
|
||||
&& !event.isCancelled() && Double.isFinite(event.getFinalDamage()) && event.getFinalDamage() > 0
|
||||
&& attempt.input.stillCurrent() && sessions.current(attempt.input.session());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
|
||||
public void acceptedDamage(EntityDamageByEntityEvent event) {
|
||||
var attempt = pending;
|
||||
if (closed || attempt == null || attempt.accepted || event.isCancelled() || event.getFinalDamage() <= 0
|
||||
|| !Double.isFinite(event.getFinalDamage()) || event.getDamager() != attempt.input.player() || event.getEntity() != attempt.target
|
||||
if (closed || attempt == null || attempt.accepted || event.getDamager() != attempt.input.player()
|
||||
|| event.getEntity() != attempt.target) return;
|
||||
attempt.nativeSeen = true;
|
||||
attempt.nativeCancelled = event.isCancelled();
|
||||
if (event.isCancelled() || event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())
|
||||
|| !attempt.input.stillCurrent() || !sessions.current(attempt.input.session())) return;
|
||||
attempt.accepted = true;
|
||||
attempt.acceptedEvent = event;
|
||||
attempt.nativeFinal = event.getFinalDamage();
|
||||
if (events != null) events.acceptedDamage(attempt.action, event, attempt.attackedAt.plus(CADENCE));
|
||||
if (feedback != null) feedback.report("Basic Attack", attempt.input.player(), AbilityReport.Type.DAMAGE,
|
||||
event.getEntity(), java.math.BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros());
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ public final class CharacterAnvilNames implements Listener {
|
||||
private final Consumer<Failure> failures;
|
||||
private final org.bukkit.inventory.ItemFactory items;
|
||||
private final Consumer<games.dmg.fantasystick.persistence.CharacterRecord> acknowledged;
|
||||
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityEvents;
|
||||
|
||||
public void attachIdentityEvents(games.dmg.fantasystick.events.CharacterIdentityEventBridge events) {
|
||||
if (identityEvents != null) throw new IllegalStateException("Anvil identity events already attached");
|
||||
identityEvents = java.util.Objects.requireNonNull(events);
|
||||
}
|
||||
|
||||
/** Diagnostics may run on the persistence worker and must not access native player state. */
|
||||
public CharacterAnvilNames(CharacterStorage storage, Consumer<Failure> failures, org.bukkit.inventory.ItemFactory items) {
|
||||
@@ -44,9 +50,19 @@ public final class CharacterAnvilNames implements Listener {
|
||||
if (name.equals(name(input))) return;
|
||||
var player = event.getPlayer().getUniqueId();
|
||||
var character = identity.orElseThrow().character();
|
||||
storage.renameCharacter(character, name, identity.orElseThrow().generation()).whenComplete((renamed, failure) -> {
|
||||
if (failure != null || renamed.isEmpty()) { failures.accept(new Failure(player, character, failure)); return; }
|
||||
try { acknowledged.accept(renamed.orElseThrow()); }
|
||||
var audit = identityEvents;
|
||||
var action = audit == null ? null : audit.rename(event.getPlayer(), character, name);
|
||||
storage.renameCharacterObserved(character, name, identity.orElseThrow().generation()).whenComplete((outcome, failure) -> {
|
||||
if (failure != null || outcome.record().isEmpty()) {
|
||||
failures.accept(new Failure(player, character, failure));
|
||||
if (audit != null) audit.resolved(action, "refused", failure == null ? outcome.status() : "save_failed");
|
||||
return;
|
||||
}
|
||||
if (audit != null) {
|
||||
if ("renamed".equals(outcome.status())) audit.renamed(action, outcome.before().orElseThrow(), outcome.record().orElseThrow());
|
||||
audit.resolved(action, "renamed".equals(outcome.status()) ? "accepted" : "refused", outcome.status());
|
||||
}
|
||||
try { acknowledged.accept(outcome.record().orElseThrow()); }
|
||||
catch (RuntimeException | LinkageError error) { failures.accept(new Failure(player, character, error)); }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,6 +34,14 @@ public final class ManualCraftingController implements Listener, AutoCloseable {
|
||||
private final Consumer<Failure> failures;
|
||||
private final java.util.concurrent.ConcurrentMap<UUID, Attempt> pending = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
private volatile boolean closed;
|
||||
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityEvents;
|
||||
private final java.util.Set<CraftItemEvent> handledCrafts =
|
||||
java.util.Collections.newSetFromMap(new java.util.WeakHashMap<>());
|
||||
|
||||
public void attachIdentityEvents(games.dmg.fantasystick.events.CharacterIdentityEventBridge events) {
|
||||
if (identityEvents != null || !pending.isEmpty()) throw new IllegalStateException("Craft identity events already attached or attempts started");
|
||||
identityEvents = java.util.Objects.requireNonNull(events);
|
||||
}
|
||||
// Server-thread reservation outlives close/reopen invalidation and includes client-sync callbacks.
|
||||
private final java.util.Set<UUID> delivering = new java.util.HashSet<>();
|
||||
|
||||
@@ -50,22 +58,30 @@ public final class ManualCraftingController implements Listener, AutoCloseable {
|
||||
public void onCraft(CraftItemEvent event) {
|
||||
if (event.isCancelled() || !(event.getRecipe() instanceof Keyed keyed)
|
||||
|| !CharacterSheets.RECIPE.equals(keyed.getKey())) return;
|
||||
handledCrafts.add(event); // MONITOR can distinguish our containment from an existing veto.
|
||||
java.util.Optional<ManualCraftPlan> plan;
|
||||
try {
|
||||
plan = ManualCraftPlan.capture(event);
|
||||
} catch (RuntimeException failure) {
|
||||
failures.accept(new Failure(event.getWhoClicked().getUniqueId(), List.of(), "plan", failure));
|
||||
refuseCraft(event, "failed", "plan");
|
||||
return;
|
||||
} finally {
|
||||
// capture must see the original cancellation state, but exceptions must never expose the plain preview.
|
||||
event.setCancelled(true);
|
||||
}
|
||||
if (closed || plan.isEmpty() || !(event.getWhoClicked() instanceof Player player) || !player.isOnline()
|
||||
|| !plan.orElseThrow().matches(player.getOpenInventory())) return;
|
||||
|| !plan.orElseThrow().matches(player.getOpenInventory())) {
|
||||
refuseCraft(event, "refused", "invalid_or_stale_preflight");
|
||||
return;
|
||||
}
|
||||
var attempt = new Attempt(player.getUniqueId(), player, plan.orElseThrow());
|
||||
if (delivering.contains(attempt.id) || pending.putIfAbsent(attempt.id, attempt) != null) return;
|
||||
var audit = identityEvents;
|
||||
if (audit != null) attempt.craft = audit.craft(player, attempt.plan.route().name().toLowerCase(java.util.Locale.ROOT), attempt.plan.quantity());
|
||||
try {
|
||||
var records = java.util.stream.IntStream.range(0, attempt.plan.quantity()).mapToObj(index -> allocate.get()).toList();
|
||||
attempt.records = records;
|
||||
attempt.characters = records.stream().map(CharacterRecord::id).toList();
|
||||
attempt.preparation = storage.prepareCraft(records);
|
||||
attempt.preparation.ready().whenComplete((batch, failure) -> schedule(attempt, batch, failure));
|
||||
@@ -73,9 +89,24 @@ public final class ManualCraftingController implements Listener, AutoCloseable {
|
||||
pending.remove(attempt.id, attempt);
|
||||
abandon(attempt);
|
||||
failures.accept(new Failure(attempt.id, attempt.characters, "prepare", failure));
|
||||
if (audit != null) audit.resolved(attempt.craft, "failed", "prepare", 0);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onCraftDecision(CraftItemEvent event) {
|
||||
if (!(event.getRecipe() instanceof Keyed keyed) || !CharacterSheets.RECIPE.equals(keyed.getKey())) return;
|
||||
if (handledCrafts.remove(event) || !event.isCancelled()) return;
|
||||
refuseCraft(event, "cancelled", "prior_cancellation");
|
||||
}
|
||||
|
||||
private void refuseCraft(CraftItemEvent event, String outcome, String reason) {
|
||||
var audit = identityEvents;
|
||||
if (audit == null || !(event.getWhoClicked() instanceof Player player)) return;
|
||||
var action = audit.craft(player, "unknown", 0);
|
||||
audit.resolved(action, outcome, reason, 0);
|
||||
}
|
||||
|
||||
private void schedule(Attempt attempt, CharacterStorage.CraftBatch batch, Throwable failure) {
|
||||
if (closed || pending.get(attempt.id) != attempt) {
|
||||
abandon(attempt);
|
||||
@@ -98,22 +129,45 @@ public final class ManualCraftingController implements Listener, AutoCloseable {
|
||||
}
|
||||
if (!delivering.add(attempt.id)) return;
|
||||
try {
|
||||
if (failure != null) failures.accept(new Failure(attempt.id, attempt.characters, "save", failure));
|
||||
else if (attempt.player.isOnline()) {
|
||||
var audit = identityEvents;
|
||||
if (failure != null) {
|
||||
failures.accept(new Failure(attempt.id, attempt.characters, "save", failure));
|
||||
if (audit != null) audit.resolved(attempt.craft, "failed", "save", 0);
|
||||
} else if (attempt.player.isOnline()) {
|
||||
attempt.handoffStarted = true;
|
||||
if (attempt.plan.route() == ManualCraftPlan.Route.DROP) {
|
||||
var result = ManualDropDelivery.commit(attempt.plan, batch, attempt.player::getOpenInventory,
|
||||
() -> !closed && pending.get(attempt.id) == attempt && attempt.player.isOnline(), drops.apply(attempt.player));
|
||||
if (audit != null) {
|
||||
for (int index = 0; index < result.completed(); index++) audit.created(attempt.craft, attempt.records.get(index), index);
|
||||
var outcome = switch (result.status()) {
|
||||
case COMPLETE -> "accepted";
|
||||
case CANCELLED -> "cancelled";
|
||||
case STALE -> "stale";
|
||||
case UNCONFIRMED -> "uncertain";
|
||||
case UNAVAILABLE, UNSUPPORTED_ROUTE -> "refused";
|
||||
};
|
||||
audit.resolved(attempt.craft, outcome,
|
||||
"drop_" + result.status().name().toLowerCase(java.util.Locale.ROOT), result.completed());
|
||||
}
|
||||
if (result.status() != ManualDropDelivery.Status.COMPLETE) {
|
||||
failures.accept(new Failure(attempt.id, attempt.characters, "drop-" + result.status().name().toLowerCase(java.util.Locale.ROOT), null));
|
||||
}
|
||||
} else {
|
||||
var result = ManualCraftDelivery.commit(attempt.plan, batch, attempt.player.getOpenInventory());
|
||||
attempt.handoffStarted = result == ManualCraftDelivery.Result.DELIVERED;
|
||||
if (audit != null) {
|
||||
if (attempt.handoffStarted) for (int index = 0; index < attempt.records.size(); index++)
|
||||
audit.created(attempt.craft, attempt.records.get(index), index);
|
||||
audit.resolved(attempt.craft, attempt.handoffStarted ? "accepted"
|
||||
: result == ManualCraftDelivery.Result.STALE ? "stale" : "refused",
|
||||
result.name().toLowerCase(java.util.Locale.ROOT), attempt.handoffStarted ? attempt.records.size() : 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException unexpected) {
|
||||
failures.accept(new Failure(attempt.id, attempt.characters, "delivery", unexpected));
|
||||
if (identityEvents != null) identityEvents.resolved(attempt.craft, "uncertain", "delivery", 0);
|
||||
} finally {
|
||||
pending.remove(attempt.id, attempt);
|
||||
abandon(attempt);
|
||||
@@ -132,6 +186,8 @@ public final class ManualCraftingController implements Listener, AutoCloseable {
|
||||
final Player player;
|
||||
final ManualCraftPlan plan;
|
||||
List<UUID> characters = List.of();
|
||||
List<CharacterRecord> records = List.of();
|
||||
games.dmg.fantasystick.events.CharacterIdentityEventBridge.Craft craft;
|
||||
CharacterStorage.CraftPreparation preparation;
|
||||
volatile boolean handoffStarted;
|
||||
final java.util.concurrent.atomic.AtomicBoolean cleanupObserved = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
|
||||
@@ -11,37 +11,50 @@ import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
|
||||
/** Idempotent recipe-book visibility; recipe registration and crafting remain separate authorities. */
|
||||
public final class RecipeDiscovery implements Listener {
|
||||
public final class RecipeDiscovery implements Listener, AutoCloseable {
|
||||
private final NamespacedKey recipe;
|
||||
private final Logger logger;
|
||||
private games.dmg.fantasystick.events.RecipeDiscoveryEventBridge events;
|
||||
|
||||
public RecipeDiscovery(NamespacedKey recipe, Logger logger) {
|
||||
this.recipe = Objects.requireNonNull(recipe, "recipe");
|
||||
this.logger = Objects.requireNonNull(logger, "logger");
|
||||
}
|
||||
|
||||
public void attachEvents(games.dmg.fantasystick.events.RecipeDiscoveryEventBridge bridge) {
|
||||
events = Objects.requireNonNull(bridge);
|
||||
}
|
||||
|
||||
public void discoverAll(Iterable<? extends Player> players) {
|
||||
for (var player : players) discover(player);
|
||||
for (var player : players) discover(player, "enable_online");
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onJoin(PlayerJoinEvent event) {
|
||||
discover(event.getPlayer());
|
||||
discover(event.getPlayer(), "join");
|
||||
}
|
||||
|
||||
public void discover(Player player) {
|
||||
public void discover(Player player) { discover(player, "join"); }
|
||||
|
||||
private void discover(Player player, String trigger) {
|
||||
Objects.requireNonNull(player, "player");
|
||||
try {
|
||||
if (player.hasDiscoveredRecipe(recipe)) return;
|
||||
player.discoverRecipes(List.of(recipe));
|
||||
int notifications = player.discoverRecipes(List.of(recipe));
|
||||
if (!player.hasDiscoveredRecipe(recipe)) {
|
||||
logger.warning("Could not discover character-sheet recipe for " + label(player));
|
||||
}
|
||||
if (events != null) events.failed(player, trigger, "native_unavailable");
|
||||
} else if (events != null) events.discovered(player, trigger, notifications);
|
||||
} catch (RuntimeException failure) {
|
||||
logger.log(Level.WARNING, "Could not discover character-sheet recipe for " + label(player), failure);
|
||||
if (events != null) events.failed(player, trigger, "native_failed");
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (events != null) events.close();
|
||||
}
|
||||
|
||||
private static String label(Player player) {
|
||||
try { return player.getName(); }
|
||||
catch (RuntimeException ignored) { return "an unidentified player"; }
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
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,
|
||||
float attackCharge, int heldTicks, int slot, UUID executionId) {
|
||||
public Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
|
||||
String targetType, Map<String, Object> targetLocation, Instant requestedAt,
|
||||
float attackCharge, int heldTicks, int slot) {
|
||||
this(actionId, context, abilityId, targetId, targetType, targetLocation, requestedAt,
|
||||
attackCharge, heldTicks, slot, UUID.randomUUID());
|
||||
}
|
||||
public Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
|
||||
String targetType, Map<String, Object> targetLocation, Instant requestedAt,
|
||||
float attackCharge, int heldTicks) {
|
||||
this(actionId, context, abilityId, targetId, targetType, targetLocation, requestedAt,
|
||||
attackCharge, heldTicks, -1);
|
||||
}
|
||||
public Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
|
||||
String targetType, Map<String, Object> targetLocation, Instant requestedAt, float attackCharge) {
|
||||
this(actionId, context, abilityId, targetId, targetType, targetLocation, requestedAt, attackCharge, 0);
|
||||
}
|
||||
public Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
|
||||
String targetType, Map<String, Object> targetLocation, Instant requestedAt) {
|
||||
this(actionId, context, abilityId, targetId, targetType, targetLocation, requestedAt, 1.0f);
|
||||
}
|
||||
}
|
||||
|
||||
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(), input.attackStrength(), input.heldTicks(), input.slot());
|
||||
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) {
|
||||
cast(cast, saved, deadline, definition, Map.of());
|
||||
}
|
||||
|
||||
public void cast(Cast cast, CharacterRecord saved, Instant deadline, AbilityDefinition definition,
|
||||
Map<String, Object> nativeEvidence) {
|
||||
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());
|
||||
if (cast.slot() >= 0) data.put("slot", cast.slot());
|
||||
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");
|
||||
if (cast.abilityId().equals("great_sword")) data.put("captured_charge", Float.toString(cast.attackCharge()));
|
||||
if (cast.abilityId().equals("quickstep")) {
|
||||
data.put("intended_distance", definition.parameters().get("distance").toPlainString());
|
||||
for (String key : java.util.List.of("from_location", "intended_direction", "accepted_velocity",
|
||||
"native_velocity_status"))
|
||||
if (nativeEvidence.containsKey(key)) data.put(key, nativeEvidence.get(key));
|
||||
}
|
||||
if (cast.abilityId().equals("longbow")) {
|
||||
data.put("draw_ticks", cast.heldTicks());
|
||||
data.put("draw_power", games.dmg.fantasystick.ability.Longbow.power(cast.heldTicks(),
|
||||
definition.parameters().get("full-draw-ticks").intValueExact()).stripTrailingZeros().toPlainString());
|
||||
}
|
||||
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));
|
||||
if (cast.abilityId().equals("stone_skin")) {
|
||||
var attackLocation = attacker.getLocation();
|
||||
if (attackLocation != null) data.put("attacker_location", locationData(attackLocation));
|
||||
}
|
||||
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. */ }
|
||||
}
|
||||
|
||||
/** Hex owns outgoing prevention even when the protected recipient is not a player. */
|
||||
public void outgoingPrevented(Cast cast, UUID effectApplication, Entity debuffedAttacker,
|
||||
Entity protectedTarget, double incoming, double remaining, double actual,
|
||||
Map<String, Object> recipientEvidence) {
|
||||
if (cast == null || !Double.isFinite(actual) || actual <= 0) return;
|
||||
try {
|
||||
var data = new LinkedHashMap<String, Object>(recipientEvidence);
|
||||
UUID application = UUID.randomUUID();
|
||||
data.put("application_id", application.toString());
|
||||
data.put("effect_application_id", effectApplication.toString());
|
||||
data.put("causation_id", cast.actionId().toString());
|
||||
data.put("debuffed_attacker_id", debuffedAttacker.getUniqueId().toString());
|
||||
data.put("debuffed_attacker_type", entityType(debuffedAttacker));
|
||||
data.put("attacker_location", locationData(debuffedAttacker.getLocation()));
|
||||
data.put("protected_target_id", protectedTarget.getUniqueId().toString());
|
||||
data.put("protected_target_type", entityType(protectedTarget));
|
||||
data.put("protected_location", locationData(protectedTarget.getLocation()));
|
||||
data.put("incoming_amount", incoming);
|
||||
data.put("remaining_amount", remaining);
|
||||
data.put("actual_amount", actual);
|
||||
emit(cast, "combat.prevented", "accepted", data, application);
|
||||
} catch (RuntimeException ignored) { /* Logging cannot alter accepted native damage. */ }
|
||||
}
|
||||
|
||||
/** Inspire reports actual accepted native absorption, not a displayed health grant. */
|
||||
public void absorbed(Cast cast, UUID effectApplication, org.bukkit.entity.Player protectedTarget,
|
||||
games.dmg.fantasystick.session.EquipmentSessions.Active aided, Entity attacker,
|
||||
double incoming, double before, double after, double consumed, double remaining,
|
||||
Location targetAtHit, Location attackerAtHit) {
|
||||
if (cast == null || effectApplication == null || !Double.isFinite(consumed) || consumed <= 0) return;
|
||||
try {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
UUID application = UUID.randomUUID();
|
||||
data.put("application_id", application.toString());
|
||||
data.put("effect_application_id", effectApplication.toString());
|
||||
data.put("causation_id", cast.actionId().toString());
|
||||
data.put("protected_target_id", protectedTarget.getUniqueId().toString());
|
||||
data.put("protected_target_type", "PLAYER");
|
||||
if (aided != null) {
|
||||
data.put("protected_character_id", aided.character().toString());
|
||||
data.put("protected_session_id", aided.session().toString());
|
||||
}
|
||||
if (targetAtHit != null) data.put("protected_location", locationData(targetAtHit));
|
||||
if (attacker != null) {
|
||||
data.put("attacker_entity_id", attacker.getUniqueId().toString());
|
||||
data.put("attacker_entity_type", entityType(attacker));
|
||||
if (attackerAtHit != null) data.put("attacker_location", locationData(attackerAtHit));
|
||||
} else data.put("attacker_status", "not_applicable");
|
||||
data.put("incoming_amount", decimal(incoming));
|
||||
data.put("absorption_before", decimal(before));
|
||||
data.put("absorption_after", decimal(after));
|
||||
data.put("actual_amount", decimal(consumed));
|
||||
data.put("remaining_amount", decimal(remaining));
|
||||
emit(cast, "combat.prevented", "accepted", data, application);
|
||||
} catch (RuntimeException ignored) { /* Native absorption and party rewards are independent of logging. */ }
|
||||
}
|
||||
|
||||
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) {
|
||||
effectEnded(cast, application, effectId, reason, Map.of());
|
||||
}
|
||||
|
||||
public void effectEnded(Cast cast, UUID application, String effectId, String reason,
|
||||
Map<String, Object> evidence) {
|
||||
if (cast == null) return;
|
||||
try {
|
||||
var details = new LinkedHashMap<String, Object>(evidence);
|
||||
details.put("application_id", application.toString());
|
||||
details.put("effect_id", effectId);
|
||||
details.put("reason", reason);
|
||||
emit(cast, "effect.ended", "ended", details, application);
|
||||
} catch (RuntimeException ignored) { /* Native cleanup is not conditional on audit evidence. */ }
|
||||
}
|
||||
|
||||
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.abilityId().equals("frost_shard") || cast.abilityId().equals("longbow"))
|
||||
data.put("execution_id", cast.executionId().toString());
|
||||
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,197 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.AdminReviveSettings;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.RevivalStore;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.command.BlockCommandSender;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Real administrator Tail restoration and acknowledged selection-timeout configuration. */
|
||||
public final class AdminRevivalEventBridge implements AutoCloseable {
|
||||
public record Actor(String kind, EventInput.Context context, UUID id) { }
|
||||
public record Selection(Actor actor, UUID targetId, String targetName,
|
||||
EventInput.Location targetLocation,
|
||||
games.dmg.fantasystick.session.EquipmentSessions.Active targetSession) { }
|
||||
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final FeatureEventOutbox outbox;
|
||||
private final Clock clock;
|
||||
private final games.dmg.fantasystick.session.EquipmentSessions sessions;
|
||||
|
||||
public AdminRevivalEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Clock clock, Path data, games.dmg.fantasystick.session.EquipmentSessions sessions,
|
||||
Consumer<Throwable> failures) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
outbox = new FeatureEventOutbox(data, "admin-revival-event-intents",
|
||||
java.util.Set.of("character.revived", "config.changed"), sink, failures);
|
||||
}
|
||||
|
||||
public Actor actor(CommandSender sender) {
|
||||
try {
|
||||
var player = sender instanceof Player value ? value : null;
|
||||
var kind = player != null ? "player" : sender instanceof BlockCommandSender ? "command_block" : "console";
|
||||
var active = player == null ? null : sessions.active(player.getUniqueId()).orElse(null);
|
||||
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
|
||||
return new Actor(kind, player == null ? null : new EventInput.Context(player.getUniqueId(),
|
||||
player.getName(), record == null ? null : record.id(),
|
||||
record == null ? null : record.name(), record == null ? null : active.session(), safeLocation(player)),
|
||||
player == null ? null : player.getUniqueId());
|
||||
} catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
public Selection selection(Player admin, Player target) {
|
||||
try { return new Selection(actor(admin), target.getUniqueId(), target.getName(),
|
||||
safeLocation(target), sessions.active(target.getUniqueId()).orElse(null)); }
|
||||
catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
public void revived(Selection selection, RevivalStore.Reservation reservation,
|
||||
CharacterRecord dead, CharacterRecord restored, Player target) {
|
||||
if (selection == null || selection.actor() == null) return;
|
||||
try {
|
||||
var data = revivalData(reservation, restored, target);
|
||||
data.put("revision_before", dead.revision().toString());
|
||||
data.put("death_time", dead.diedAt().toString());
|
||||
data.put("death_age_seconds", Long.toString(Math.max(0, Duration.between(dead.diedAt(), clock.instant()).getSeconds())));
|
||||
data.put("recovery_reconciliation", false);
|
||||
location(data, "target_location", selection.targetLocation());
|
||||
targetSession(data, selection.targetSession());
|
||||
var actor = selection.actor();
|
||||
data.put("admin_player_id", selection.actor().id().toString());
|
||||
data.put("admin_player_name", actor.context().playerName());
|
||||
if (actor.context().location() == null) data.put("actor_location_status", "unavailable");
|
||||
durable("character.revived", reservation.id(), actor.context(), data);
|
||||
} catch (RuntimeException ignored) { /* Audit failure cannot consume or restore a Tail. */ }
|
||||
}
|
||||
|
||||
/** Reconciliation has the reservation's exact admin UUID, but never invents its old location or name. */
|
||||
public void recovered(RevivalStore.Reservation reservation, CharacterRecord restored, Player target) {
|
||||
if (!reservation.administrative()) return;
|
||||
try {
|
||||
var data = revivalData(reservation, restored, target);
|
||||
data.put("actor_kind", "system");
|
||||
data.put("admin_player_id", reservation.casterCharacter().toString());
|
||||
data.put("admin_name_status", "unavailable");
|
||||
data.put("actor_location_status", "unavailable");
|
||||
data.put("revision_before", restored.revision().subtract(BigInteger.ONE).toString());
|
||||
data.put("death_time", restored.diedAt().toString());
|
||||
data.put("death_age_seconds", Long.toString(Math.max(0,
|
||||
Duration.between(restored.diedAt(), reservation.createdAt()).getSeconds())));
|
||||
data.put("recovery_reconciliation", true);
|
||||
location(data, "target_location", safeLocation(target));
|
||||
targetSession(data, sessions.active(target.getUniqueId()).orElse(null));
|
||||
durable("character.revived", reservation.id(), null, data);
|
||||
} catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
private Map<String, Object> revivalData(RevivalStore.Reservation reservation, CharacterRecord restored, Player target) {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("revival_kind", "admin_tail");
|
||||
data.put("revival_operation_id", reservation.id().toString());
|
||||
data.put("target_player_id", reservation.targetPlayer().toString());
|
||||
data.put("target_player_name", target.getName());
|
||||
data.put("character_id", restored.id().toString());
|
||||
data.put("character_name", restored.name());
|
||||
data.put("death_id", reservation.death().toString());
|
||||
data.put("revision_after", restored.revision().toString());
|
||||
data.put("tail_slot", reservation.sourceSlot());
|
||||
data.put("tail_custody_id", reservation.death().toString());
|
||||
data.put("sheet_slot", reservation.sourceAmount() == 1 ? reservation.sourceSlot() : reservation.destinationSlot());
|
||||
data.put("source_quantity_before", Integer.toString(reservation.sourceAmount()));
|
||||
data.put("source_quantity_after", Integer.toString(reservation.sourceAmount() - 1));
|
||||
data.put("restored_health", restored.health().toPlainString());
|
||||
data.put("maximum_health", restored.maximumHealth().toPlainString());
|
||||
data.put("cooldowns_retained", true);
|
||||
data.put("native_delivery", "acknowledged");
|
||||
data.put("outcome", "acknowledged");
|
||||
return data;
|
||||
}
|
||||
|
||||
public void read(Actor actor, String effective) {
|
||||
if (actor == null) return;
|
||||
var data = config(actor);
|
||||
data.put("effective", effective);
|
||||
data.put("outcome", "acknowledged_read");
|
||||
emit(UUID.randomUUID(), "config.read", actor.context(), data);
|
||||
}
|
||||
|
||||
public void changed(Actor actor, String before, AdminReviveSettings.Update saved) {
|
||||
if (actor == null) return;
|
||||
var data = config(actor);
|
||||
data.put("before", before);
|
||||
data.put("after", saved.value());
|
||||
data.put("effective", saved.value());
|
||||
data.put("outcome", "acknowledged");
|
||||
durable("config.changed", UUID.randomUUID(), actor.context(), data);
|
||||
}
|
||||
|
||||
private static Map<String, Object> config(Actor actor) {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("actor_kind", actor.kind());
|
||||
data.put("key", AdminReviveSettings.KEY);
|
||||
data.put("applies_to", "later_selections");
|
||||
if (actor.context() == null) data.put("location_status", "not_applicable");
|
||||
else if (actor.context().location() == null) data.put("location_status", "unavailable");
|
||||
return data;
|
||||
}
|
||||
|
||||
private static void targetSession(Map<String, Object> data,
|
||||
games.dmg.fantasystick.session.EquipmentSessions.Active active) {
|
||||
if (active == null) { data.put("target_session_status", "no_active_session"); return; }
|
||||
data.put("target_session_id", active.session().toString());
|
||||
data.put("target_active_character_id", active.character().toString());
|
||||
data.put("target_session_status", "captured");
|
||||
}
|
||||
|
||||
private void location(Map<String, Object> data, String key, EventInput.Location value) {
|
||||
if (value == null) { data.put(key + "_status", "unavailable"); return; }
|
||||
data.put(key, Map.of("world_id", value.worldId().toString(), "world_name", value.worldName(),
|
||||
"x", value.x(), "y", value.y(), "z", value.z(),
|
||||
"captured_at", value.capturedAt().toString(), "quality", value.quality()));
|
||||
}
|
||||
|
||||
private EventInput.Location safeLocation(Player player) {
|
||||
try { return location(player.getLocation()); }
|
||||
catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
private EventInput.Location location(Location value) {
|
||||
try {
|
||||
if (value == null || value.getWorld() == null) return null;
|
||||
return new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(),
|
||||
value.getX(), value.getY(), value.getZ(), clock.instant(), "exact");
|
||||
} catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
private void durable(String type, UUID action, EventInput.Context context, Map<String, Object> data) {
|
||||
try { outbox.accepted(new EventInput(id(action, type), action, clock.instant(), type, context, data)); }
|
||||
catch (RuntimeException ignored) { }
|
||||
}
|
||||
private void emit(UUID action, String type, EventInput.Context context, Map<String, Object> data) {
|
||||
try { sink.apply(new EventInput(id(action, type), action, clock.instant(), type, context, data)); }
|
||||
catch (RuntimeException ignored) { }
|
||||
}
|
||||
private static UUID id(UUID action, String kind) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:admin-revival:" + action + ":" + kind)
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
public void replay() { outbox.replay(); }
|
||||
public CompletionStage<Void> drained() { return outbox.drained(); }
|
||||
@Override public void close() { outbox.close(); }
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
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;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
|
||||
/** Immutable server-thread snapshots for the owned bound-stick input and its native hit. */
|
||||
public final class BasicAttackEventBridge {
|
||||
public record Action(UUID id, Instant observedAt, EventInput.Context actor, Map<String, Object> data) { }
|
||||
private final Consumer<EventInput> sink;
|
||||
private final Clock clock;
|
||||
|
||||
public BasicAttackEventBridge(Consumer<EventInput> sink, Clock clock) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
}
|
||||
|
||||
public Action observed(ControlInput input, CharacterRecord record, Entity target, Instant at,
|
||||
Instant ready, Double requested) {
|
||||
try {
|
||||
var player = input.player();
|
||||
var context = new EventInput.Context(player.getUniqueId(), player.getName(),
|
||||
record == null ? null : record.id(), record == null ? null : record.name(),
|
||||
record == null ? null : input.session().session(), location(player.getLocation()));
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("action_kind", "basic_attack");
|
||||
data.put("entry_point", "bound_slot_2_attack");
|
||||
data.put("slot_index", input.slot());
|
||||
data.put("control_kind", input.kind().name().toLowerCase(java.util.Locale.ROOT));
|
||||
data.put("source_player_id", player.getUniqueId().toString());
|
||||
data.put("source_character_id", input.session().character().toString());
|
||||
data.put("source_session_id", input.session().session().toString());
|
||||
if (record != null) data.put("strength", record.stats().get(games.dmg.fantasystick.progression.Stat.STR).toString());
|
||||
else data.put("strength_status", "unavailable");
|
||||
data.put("ready_deadline", (ready == null ? at : ready).toString());
|
||||
if (ready == null) data.put("ready_deadline_status", "ready_at_observation_no_prior_hit");
|
||||
if (requested != null) data.put("requested_amount", Double.toString(requested));
|
||||
else data.put("requested_amount_status", "not_computed");
|
||||
data.put("prior_cancelled", false);
|
||||
if (target != null && target.getUniqueId() != null) data.put("target_entity_id", target.getUniqueId().toString());
|
||||
if (target instanceof org.bukkit.entity.Player) data.put("target_entity_type", "player");
|
||||
else if (target != null && target.getType() != null)
|
||||
data.put("target_entity_type", target.getType().name().toLowerCase(java.util.Locale.ROOT));
|
||||
var sourceLocation = location(player.getLocation());
|
||||
var targetLocation = target == null ? null : location(target.getLocation());
|
||||
if (sourceLocation != null) data.put("source_location", coordinates(sourceLocation));
|
||||
else data.put("source_location_status", "unavailable");
|
||||
if (targetLocation != null) data.put("target_location", coordinates(targetLocation));
|
||||
else data.put("target_location_status", "unavailable");
|
||||
var action = new Action(UUID.randomUUID(), at, context, Map.copyOf(data));
|
||||
var values = new LinkedHashMap<String, Object>(data);
|
||||
values.put("outcome", "observed");
|
||||
emit(action, "action.observed", "observed", at, values);
|
||||
return action;
|
||||
} catch (RuntimeException ignored) { return null; }
|
||||
}
|
||||
|
||||
public void resolved(Action action, String outcome, String reason, boolean nativePriorCancelled,
|
||||
boolean finalCancelled, Double finalAmount, Instant nextReady) {
|
||||
if (action == null) return;
|
||||
var values = new LinkedHashMap<String, Object>(action.data());
|
||||
values.put("outcome", outcome);
|
||||
values.put("reason", reason);
|
||||
values.put("native_prior_cancelled", nativePriorCancelled);
|
||||
values.put("final_cancelled", finalCancelled);
|
||||
if (finalAmount != null) values.put("final_amount", Double.toString(finalAmount));
|
||||
else values.put("final_amount_status", "unavailable");
|
||||
if (nextReady != null) values.put("next_ready_deadline", nextReady.toString());
|
||||
emit(action, "action.resolved", "resolved", clock.instant(), values);
|
||||
}
|
||||
|
||||
public void acceptedDamage(Action action, EntityDamageByEntityEvent event, Instant nextReady) {
|
||||
if (action == null) return;
|
||||
try {
|
||||
var values = new LinkedHashMap<String, Object>(action.data());
|
||||
values.put("outcome", "accepted");
|
||||
values.put("reason", "native_positive_hit");
|
||||
values.put("final_cancelled", false);
|
||||
values.put("final_amount", Double.toString(event.getFinalDamage()));
|
||||
values.put("next_ready_deadline", nextReady.toString());
|
||||
var target = event.getEntity();
|
||||
if (target instanceof org.bukkit.entity.Player player) values.put("target_player_id", player.getUniqueId().toString());
|
||||
var captured = location(target.getLocation());
|
||||
if (captured != null) values.put("target_location", coordinates(captured));
|
||||
else values.put("target_location_status", "unavailable");
|
||||
emit(action, "combat.damage", "damage", clock.instant(), values);
|
||||
} catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
private EventInput.Location location(Location nativeLocation) {
|
||||
return nativeLocation == null || nativeLocation.getWorld() == null ? null
|
||||
: new EventInput.Location(nativeLocation.getWorld().getUID(), nativeLocation.getWorld().getName(),
|
||||
nativeLocation.getX(), nativeLocation.getY(), nativeLocation.getZ(), clock.instant(), "exact");
|
||||
}
|
||||
|
||||
private Map<String, Object> coordinates(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());
|
||||
}
|
||||
|
||||
private void emit(Action action, String type, String stage, Instant time, Map<String, Object> values) {
|
||||
try {
|
||||
sink.accept(new EventInput(UUID.nameUUIDFromBytes(("fantasystick:basic-attack:" + action.id() + ':'
|
||||
+ stage).getBytes(StandardCharsets.UTF_8)), action.id(), time, type, action.actor(), values));
|
||||
} catch (RuntimeException ignored) { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
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 Navigation(int sourceVolumeStart, int sourceNavigationPage, int destinationVolumeStart) {
|
||||
public Navigation {
|
||||
if (sourceVolumeStart < 0 || sourceNavigationPage < 1 || sourceNavigationPage > 100
|
||||
|| destinationVolumeStart < 0 || sourceVolumeStart == destinationVolumeStart)
|
||||
throw new IllegalArgumentException("Invalid book volume navigation");
|
||||
}
|
||||
}
|
||||
|
||||
public record Request(UUID actionId, EventInput.Context context, String actionKind, String viewKind,
|
||||
int offset, BigInteger requestedRevision, Navigation navigation) {
|
||||
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);
|
||||
return request(input, current, offset, null);
|
||||
}
|
||||
|
||||
public Request request(ControlInput input, CharacterRecord current, int offset, Navigation navigation) {
|
||||
Objects.requireNonNull(input); Objects.requireNonNull(current);
|
||||
var request = new Request(UUID.randomUUID(), context(input, current.name()),
|
||||
navigation == null ? "character_book_open" : "character_book_navigate", "active", offset,
|
||||
current.revision(), navigation);
|
||||
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(),null);
|
||||
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.navigation() == null ? "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", request.navigation() == null ? "overview" : "volume_navigation");
|
||||
data.put("control_kind", "book"); data.put("control_slot", "0");
|
||||
if (request.navigation() != null) {
|
||||
var navigation = request.navigation();
|
||||
data.put("source_volume_start", Integer.toString(navigation.sourceVolumeStart()));
|
||||
data.put("source_navigation_page", navigation.sourceNavigationPage());
|
||||
data.put("destination_volume_start", Integer.toString(navigation.destinationVolumeStart()));
|
||||
data.put("destination_page", "1");
|
||||
data.put("destination_section", navigation.destinationVolumeStart() == 0 ? "overview" : "volume_start");
|
||||
data.put("volume_direction", navigation.destinationVolumeStart() > navigation.sourceVolumeStart()
|
||||
? "next" : "previous");
|
||||
}
|
||||
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,246 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
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.Optional;
|
||||
import java.util.UUID;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.command.BlockCommandSender;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Captures actual identity-creating and identity-changing native actions, never previews. */
|
||||
public final class CharacterIdentityEventBridge implements AutoCloseable {
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final FeatureEventOutbox outbox;
|
||||
private final Clock clock;
|
||||
private final Function<UUID, Optional<UUID>> activeSession;
|
||||
private static final int MAX_RECENT_HOLDERS = 8192;
|
||||
private final Map<UUID, Holder> lastObserved = new LinkedHashMap<>(16, .75f, true) {
|
||||
@Override protected boolean removeEldestEntry(Map.Entry<UUID, Holder> eldest) {
|
||||
return size() > MAX_RECENT_HOLDERS; // Eviction is uncertainty, never an invented transfer.
|
||||
}
|
||||
};
|
||||
private record Holder(UUID player, UUID session, EventInput.Location entryLocation) { }
|
||||
|
||||
public CharacterIdentityEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Clock clock, Path dataFolder, Function<UUID, Optional<UUID>> activeSession,
|
||||
Consumer<Throwable> failures) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.activeSession = Objects.requireNonNull(activeSession);
|
||||
this.outbox = new FeatureEventOutbox(Objects.requireNonNull(dataFolder),
|
||||
"character-identity-event-intents", java.util.Set.of("character.created", "character.renamed", "character.issued"),
|
||||
sink, Objects.requireNonNull(failures));
|
||||
}
|
||||
|
||||
public void replay() { outbox.replay(); }
|
||||
@Override public void close() { outbox.close(); }
|
||||
public CompletionStage<Void> drained() { return outbox.drained(); }
|
||||
|
||||
/** A later acknowledged activation proves a transfer only if we observed a different prior holder. */
|
||||
public void entered(Player player, UUID session, CharacterRecord record) {
|
||||
try {
|
||||
var actor = new EventInput.Context(player.getUniqueId(), player.getName(), record.id(), record.name(),
|
||||
session, location(player.getLocation()));
|
||||
Holder previous;
|
||||
synchronized (lastObserved) {
|
||||
previous = lastObserved.put(record.id(), new Holder(actor.playerId(), session, actor.location()));
|
||||
}
|
||||
if (previous == null || previous.player().equals(actor.playerId())) return;
|
||||
var action = id(previous.session(), session.toString());
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
values.put("character_id", record.id().toString());
|
||||
values.put("generation", record.sheetGeneration().toString());
|
||||
values.put("from_player_id", previous.player().toString());
|
||||
values.put("to_player_id", actor.playerId().toString());
|
||||
values.put("from_session_id", previous.session().toString());
|
||||
values.put("to_session_id", session.toString());
|
||||
values.put("observation_boundary", "acknowledged_activation");
|
||||
values.put("custody_quality", "activation_after_prior_holder");
|
||||
if (previous.entryLocation() != null) values.put("from_location", locationMap(previous.entryLocation(), "cached_at_previous_activation"));
|
||||
else values.put("from_location_status", "unavailable");
|
||||
if (actor.location() != null) values.put("to_location", locationMap(actor.location(), "exact"));
|
||||
else values.put("to_location_status", "unavailable");
|
||||
values.put("outcome", "observed");
|
||||
emit("character.transfer_observed", action, clock.instant(), actor, values, "transfer_observed");
|
||||
} catch (RuntimeException ignored) { /* Observation never authorizes a character session. */ }
|
||||
}
|
||||
|
||||
public record Issue(UUID operation, String actorKind, EventInput.Context actor,
|
||||
UUID targetPlayer, String targetName) { }
|
||||
|
||||
/** Capture the administrator before asynchronous saving; no second admin.action is produced. */
|
||||
public Issue issue(CommandSender sender, Player target) {
|
||||
try {
|
||||
var player = sender instanceof Player actor ? actor : null;
|
||||
var kind = player != null ? "player" : sender instanceof BlockCommandSender ? "command_block" : "console";
|
||||
EventInput.Context context = player == null ? null : new EventInput.Context(player.getUniqueId(),
|
||||
player.getName(), null, null, null, safeLocation(player));
|
||||
return new Issue(UUID.randomUUID(), kind, context, target.getUniqueId(), target.getName());
|
||||
} catch (RuntimeException ignored) { return null; }
|
||||
}
|
||||
|
||||
/** The saved character and exact native inventory slot were acknowledged before this call. */
|
||||
public void issued(Issue issue, Player target, CharacterRecord record, int slot) {
|
||||
if (issue == null) return;
|
||||
try {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("actor_kind", issue.actorKind());
|
||||
data.put("issue_operation_id", issue.operation().toString());
|
||||
data.put("target_player_id", issue.targetPlayer().toString());
|
||||
data.put("target_player_name", issue.targetName());
|
||||
data.put("character_id", record.id().toString());
|
||||
data.put("character_name", record.name());
|
||||
data.put("revision", record.revision().toString());
|
||||
data.put("generation", record.sheetGeneration().toString());
|
||||
data.put("starting_level", Integer.toString(record.level()));
|
||||
data.put("starting_unspent_points", record.unspentPoints().toString());
|
||||
data.put("starting_health", record.health().toPlainString());
|
||||
data.put("starting_coins", record.coins().toString());
|
||||
var stats = new LinkedHashMap<String, Object>();
|
||||
record.stats().forEach((stat, value) -> stats.put(stat.name().toLowerCase(java.util.Locale.ROOT), value.toString()));
|
||||
data.put("starting_stats", stats);
|
||||
data.put("delivered_slot", slot);
|
||||
data.put("delivery_outcome", "native_inventory_acknowledged");
|
||||
data.put("outcome", "acknowledged_delivered");
|
||||
if (issue.actor() == null) data.put("actor_location_status", "not_applicable");
|
||||
else if (issue.actor().location() == null) data.put("actor_location_status", "unavailable");
|
||||
var targetLocation = safeLocation(target);
|
||||
if (targetLocation != null) data.put("target_location", locationMap(targetLocation, "exact"));
|
||||
else data.put("target_location_status", "unavailable");
|
||||
durable("character.issued", issue.operation(), clock.instant(), issue.actor(), data, "issued");
|
||||
} catch (RuntimeException ignored) { /* Audit cannot change acknowledged native issuance. */ }
|
||||
}
|
||||
|
||||
private EventInput.Location safeLocation(Player player) {
|
||||
try { return location(player.getLocation()); }
|
||||
catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
public record Craft(UUID action, Instant time, EventInput.Context actor, String route, int quantity,
|
||||
UUID holderSession) { }
|
||||
|
||||
public Craft craft(Player player, String route, int quantity) {
|
||||
try {
|
||||
var context = new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null,
|
||||
location(player.getLocation()));
|
||||
var craft = new Craft(UUID.randomUUID(), clock.instant(), context, route, quantity,
|
||||
activeSession.apply(context.playerId()).orElse(null));
|
||||
emit("action.observed", craft.action(), craft.time(), context, Map.of(
|
||||
"action_kind", "character_craft", "recipe_id", "fantasystick:character_sheet",
|
||||
"delivery_route", route, "quantity", Integer.toString(quantity), "outcome", "observed"), "observed");
|
||||
return craft;
|
||||
} catch (RuntimeException ignored) { return null; }
|
||||
}
|
||||
|
||||
public record Rename(UUID action, Instant time, EventInput.Context actor, UUID character,
|
||||
String requestedName, UUID holderSession) { }
|
||||
|
||||
public Rename rename(Player player, UUID character, String requestedName) {
|
||||
try {
|
||||
var actor = new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null,
|
||||
location(player.getLocation()));
|
||||
var action = new Rename(UUID.randomUUID(), clock.instant(), actor, character, requestedName,
|
||||
activeSession.apply(actor.playerId()).orElse(null));
|
||||
emit("action.observed", action.action(), action.time(), actor, Map.of(
|
||||
"action_kind", "character_rename", "character_id", character.toString(),
|
||||
"outcome", "observed"), "observed");
|
||||
return action;
|
||||
} catch (RuntimeException ignored) { return null; }
|
||||
}
|
||||
|
||||
public void renamed(Rename action, CharacterRecord before, CharacterRecord after) {
|
||||
if (action == null) return;
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
values.put("action_kind", "character_rename");
|
||||
values.put("anvil_action_id", action.action().toString());
|
||||
holder(values, action.actor().playerId(), action.holderSession());
|
||||
values.put("character_id", after.id().toString());
|
||||
values.put("generation", after.sheetGeneration().toString());
|
||||
values.put("before_name", before.name());
|
||||
values.put("after_name", after.name());
|
||||
values.put("revision_before", before.revision().toString());
|
||||
values.put("revision_after", after.revision().toString());
|
||||
values.put("native_item_outcome", "unverified");
|
||||
values.put("outcome", "acknowledged");
|
||||
durable("character.renamed", action.action(), action.time(), action.actor(), values, "renamed");
|
||||
}
|
||||
|
||||
public void resolved(Rename action, String outcome, String reason) {
|
||||
if (action == null) return;
|
||||
emit("action.resolved", action.action(), clock.instant(), action.actor(), Map.of(
|
||||
"action_kind", "character_rename", "character_id", action.character().toString(),
|
||||
"outcome", outcome, "reason", reason), "resolved");
|
||||
}
|
||||
|
||||
public void created(Craft craft, CharacterRecord record, int index) {
|
||||
if (craft == null) return;
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
values.put("action_kind", "character_craft");
|
||||
values.put("craft_operation_id", craft.action().toString());
|
||||
holder(values, craft.actor().playerId(), craft.holderSession());
|
||||
values.put("character_id", record.id().toString());
|
||||
values.put("character_name", record.name());
|
||||
values.put("generation", record.sheetGeneration().toString());
|
||||
values.put("revision", record.revision().toString());
|
||||
values.put("recipe_id", "fantasystick:character_sheet");
|
||||
values.put("quantity", Integer.toString(craft.quantity()));
|
||||
values.put("index", index);
|
||||
values.put("delivery_route", craft.route());
|
||||
values.put("outcome", "acknowledged_delivered");
|
||||
durable("character.created", craft.action(), craft.time(), craft.actor(), values, record.id().toString());
|
||||
}
|
||||
|
||||
public void resolved(Craft craft, String outcome, String reason, int delivered) {
|
||||
if (craft == null) return;
|
||||
emit("action.resolved", craft.action(), clock.instant(), craft.actor(), Map.of(
|
||||
"action_kind", "character_craft", "recipe_id", "fantasystick:character_sheet",
|
||||
"delivery_route", craft.route(), "quantity", Integer.toString(craft.quantity()),
|
||||
"delivered", Integer.toString(delivered), "outcome", outcome, "reason", reason), "resolved");
|
||||
}
|
||||
|
||||
private void durable(String kind, UUID action, Instant time, EventInput.Context context,
|
||||
Map<String, Object> values, String stage) {
|
||||
try { outbox.accepted(new EventInput(id(action, stage), action, time, kind, context, values)); }
|
||||
catch (RuntimeException ignored) { /* Auditing never changes acknowledged gameplay. */ }
|
||||
}
|
||||
|
||||
private void emit(String kind, UUID action, Instant time, EventInput.Context context,
|
||||
Map<String, Object> values, String stage) {
|
||||
try { sink.apply(new EventInput(id(action, stage), action, time, kind, context, values)); }
|
||||
catch (RuntimeException ignored) { /* The audit cannot change native craft outcomes. */ }
|
||||
}
|
||||
|
||||
private static void holder(Map<String, Object> data, UUID player, UUID session) {
|
||||
data.put("holder_player_id", player.toString());
|
||||
data.put("holder_session_status", session == null ? "no_active_session" : "active_at_request");
|
||||
if (session != null) data.put("holder_session_id", session.toString());
|
||||
}
|
||||
|
||||
private static Map<String, Object> locationMap(EventInput.Location location, String quality) {
|
||||
return Map.of("world_id", location.worldId().toString(), "world_name", location.worldName(),
|
||||
"x", location.x(), "y", location.y(), "z", location.z(),
|
||||
"captured_at", location.capturedAt().toString(), "quality", quality);
|
||||
}
|
||||
|
||||
private EventInput.Location location(Location value) {
|
||||
return value == null || value.getWorld() == null ? null
|
||||
: 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 stage) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:character-identity:" + action + ':' + stage)
|
||||
.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));}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.session.DownedEpisode;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/** Failure-isolated event adapter for durable downed, rescue, death and protection boundaries. */
|
||||
public final class DownedEventBridge {
|
||||
private final Consumer<EventInput> sink;
|
||||
private final Clock clock;
|
||||
|
||||
public DownedEventBridge(Consumer<EventInput> sink, Clock clock) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
}
|
||||
|
||||
/** Re-emits stable IDs from durable proof; at-least-once consumers deduplicate by source and ID. */
|
||||
public void reconcile(Collection<DownedEpisode> episodes) {
|
||||
for (var episode : List.copyOf(episodes)) {
|
||||
downed(episode);
|
||||
if (episode.phase() != DownedEpisode.Phase.RESOLVED) continue;
|
||||
if (episode.decision() != null && "revive".equals(episode.decision().source())) {
|
||||
abilityCast(episode, episode.decision());
|
||||
}
|
||||
if (!"phoenix_down".equals(decisionSource(episode)) || episode.published()) resolved(episode);
|
||||
if (episode.decision() != null && "revive".equals(episode.decision().source())) {
|
||||
abilityResolved(episode);
|
||||
}
|
||||
if (episode.outcome() == DownedEpisode.Outcome.FINAL_DEATH && episode.published()) {
|
||||
finalDeathRecorded(episode);
|
||||
}
|
||||
if (episode.published()) outcomePublished(episode);
|
||||
if (episode.outcome() == DownedEpisode.Outcome.REVIVED && episode.protectionUntil() != null
|
||||
&& !clock.instant().isBefore(episode.protectionUntil())) protectionEnded(episode);
|
||||
}
|
||||
}
|
||||
|
||||
public void downed(DownedEpisode episode) {
|
||||
var data = common(episode);
|
||||
data.put("stage", "acknowledged");
|
||||
data.put("outcome", "downed");
|
||||
emit(stable(episode.id(), "downed"), episode.id(), episode.downedAt(), "character.downed", data);
|
||||
}
|
||||
|
||||
public void resolved(DownedEpisode episode) {
|
||||
if (episode.phase() != DownedEpisode.Phase.RESOLVED) return;
|
||||
var data = common(episode);
|
||||
String outcome = decisionSource(episode);
|
||||
data.put("stage", "decision_acknowledged");
|
||||
data.put("outcome", outcome);
|
||||
data.put("resolved_at", episode.resolvedAt());
|
||||
data.put("publication_outcome", "pending");
|
||||
if (episode.protectionUntil() != null) data.put("protection_until", episode.protectionUntil());
|
||||
if (!episode.finalRewards().isEmpty()) {
|
||||
data.put("final_rewards", episode.finalRewards().stream().map(reward -> Map.of(
|
||||
"character_id", reward.character().toString(), "experience", reward.experience().toString(),
|
||||
"coins", reward.coins().toString())).toList());
|
||||
}
|
||||
var decision = episode.decision();
|
||||
if (decision != null) {
|
||||
if (decision.actorPlayer() != null) {
|
||||
data.put("actor_kind", "player");
|
||||
data.put("actor_player_id", decision.actorPlayer().toString());
|
||||
data.put("actor_player_name", decision.actorPlayerName());
|
||||
}
|
||||
if (decision.actorCharacter() != null) {
|
||||
data.put("actor_character_id", decision.actorCharacter().toString());
|
||||
data.put("actor_character_name", decision.actorCharacterName());
|
||||
}
|
||||
if (decision.actorSession() != null) data.put("actor_session_id", decision.actorSession().toString());
|
||||
if (decision.actorCharacter() != null && !decision.actorCharacter().equals(episode.character())) {
|
||||
data.put("actor_location_status", "unavailable");
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
public void finalDeathRecorded(DownedEpisode episode) {
|
||||
if (episode.outcome() != DownedEpisode.Outcome.FINAL_DEATH) return;
|
||||
var data = common(episode);
|
||||
data.put("stage", "death_recorded");
|
||||
data.put("outcome", "acknowledged");
|
||||
data.put("effective_death_time", episode.resolvedAt());
|
||||
data.put("death_id", episode.id().toString());
|
||||
data.put("tail_outcome", "pending");
|
||||
data.put("return_outcome", "pending");
|
||||
data.put("reward_outcome", "pending");
|
||||
if (!episode.finalRewards().isEmpty()) {
|
||||
data.put("final_rewards", episode.finalRewards().stream().map(reward -> Map.of(
|
||||
"character_id", reward.character().toString(), "experience", reward.experience().toString(),
|
||||
"coins", reward.coins().toString())).toList());
|
||||
}
|
||||
data.put("consequence_operation_id", episode.outcomeOperation().toString());
|
||||
emit(stable(episode.id(), "died"), episode.outcomeOperation(), episode.resolvedAt(),
|
||||
"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
|
||||
? 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();
|
||||
var action = UUID.randomUUID();
|
||||
var data = common(episode);
|
||||
data.put("stage", "native_damage");
|
||||
data.put("outcome", "prevented");
|
||||
data.put("reason", Objects.requireNonNull(reason));
|
||||
data.put("prevented_amount", Double.toString(amount));
|
||||
emit(UUID.randomUUID(), action, now, "combat.prevented", data);
|
||||
}
|
||||
|
||||
public void protectionEnded(DownedEpisode episode) {
|
||||
if (episode.outcome() != DownedEpisode.Outcome.REVIVED || episode.protectionUntil() == null) return;
|
||||
var data = common(episode);
|
||||
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(),
|
||||
"effect.ended", data);
|
||||
}
|
||||
|
||||
public void abilityCast(DownedEpisode episode, DownedEpisode.Decision decision) {
|
||||
var data = common(episode);
|
||||
data.put("actor_kind", "player");
|
||||
data.put("ability_id", "revive");
|
||||
data.put("stage", "cooldown_committed");
|
||||
data.put("outcome", "accepted");
|
||||
data.put("cooldown_until", decision.cooldownUntil());
|
||||
data.put("actor_player_id", decision.actorPlayer().toString());
|
||||
data.put("actor_character_id", decision.actorCharacter().toString());
|
||||
data.put("actor_session_id", decision.actorSession().toString());
|
||||
data.put("final_range", decision.finalRange());
|
||||
data.put("line_of_sight", decision.lineOfSight());
|
||||
emit(stable(decision.actionId(), "ability-cast"), decision.actionId(), decision.castAt(),
|
||||
"ability.cast", actor(decision), data);
|
||||
}
|
||||
|
||||
public void abilityResolved(DownedEpisode episode) {
|
||||
var decision = episode.decision();
|
||||
if (decision == null || !"revive".equals(decision.source())) return;
|
||||
abilityResolved(episode, decision, "decision_won", episode.resolvedAt());
|
||||
}
|
||||
|
||||
public void abilityResolved(DownedEpisode episode, DownedEpisode.Decision decision,
|
||||
String outcome, Instant time) {
|
||||
var data = common(episode);
|
||||
if (decision != null && decision.actorPlayer() != null) {
|
||||
data.put("actor_kind", "player");
|
||||
data.put("actor_player_id", decision.actorPlayer().toString());
|
||||
data.put("actor_character_id", decision.actorCharacter().toString());
|
||||
data.put("actor_session_id", decision.actorSession().toString());
|
||||
}
|
||||
data.put("ability_id", "revive");
|
||||
data.put("stage", "downed_target_resolution");
|
||||
data.put("outcome", Objects.requireNonNull(outcome));
|
||||
var actionId = decision == null ? episode.outcomeOperation() : decision.actionId();
|
||||
emit(stable(actionId, "ability-resolved"), actionId, time, "ability.resolved", actor(decision), data);
|
||||
}
|
||||
|
||||
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 config(String type, String actorKind, UUID player, String playerName, String key,
|
||||
String before, String after) {
|
||||
var now = clock.instant();
|
||||
var action = UUID.randomUUID();
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("actor_kind", Objects.requireNonNull(actorKind));
|
||||
if (player != null) data.put("actor_player_id", player.toString());
|
||||
if (playerName != null) data.put("actor_player_name", playerName);
|
||||
data.put("key", Objects.requireNonNull(key));
|
||||
if (before != null) data.put("before", before);
|
||||
data.put("after", Objects.requireNonNull(after));
|
||||
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);
|
||||
}
|
||||
|
||||
private LinkedHashMap<String, Object> common(DownedEpisode episode) {
|
||||
Objects.requireNonNull(episode);
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("actor_kind", "system");
|
||||
data.put("episode_id", episode.id().toString());
|
||||
data.put("subject_character_id", episode.character().toString());
|
||||
if(episode.characterName()!=null)data.put("subject_character_name",episode.characterName());
|
||||
data.put("subject_player_id", episode.player().toString());
|
||||
if(episode.playerName()!=null)data.put("subject_player_name",episode.playerName());
|
||||
data.put("custody_session_id", episode.session().toString());
|
||||
data.put("custody", "active_or_recoverable_session");
|
||||
if (episode.characterRevision() != null) data.put("character_revision", episode.characterRevision().toString());
|
||||
data.put("downed_at", episode.downedAt());
|
||||
data.put("deadline", episode.expiresAt());
|
||||
data.put("lethal_cause", episode.attribution().cause());
|
||||
if (episode.attribution().attacker() != null) {
|
||||
data.put("lethal_source_id", episode.attribution().attacker().toString());
|
||||
}
|
||||
data.put("player_defeat", episode.attribution().playerDefeat());
|
||||
data.put("contributors", episode.attribution().contributors().stream().map(UUID::toString).toList());
|
||||
data.put("victim_level", Integer.toString(episode.attribution().victimLevel()));
|
||||
data.put("victim_coins", episode.attribution().victimCoins().toString());
|
||||
data.put("location", location(episode.world(), episode.x(), episode.y(), episode.z(), episode.downedAt()));
|
||||
data.put("location_role", "downed_subject_anchor");
|
||||
data.put("entry_return", location(episode.entryWorld(), episode.entryX(), episode.entryY(),
|
||||
episode.entryZ(), episode.downedAt()));
|
||||
return data;
|
||||
}
|
||||
|
||||
private static Map<String, Object> location(UUID world, double x, double y, double z, Instant capturedAt) {
|
||||
return Map.of("world_id", world.toString(), "x", Double.toString(x), "y", Double.toString(y),
|
||||
"z", Double.toString(z), "captured_at", capturedAt, "quality", "durable_native_capture");
|
||||
}
|
||||
|
||||
private static String decisionSource(DownedEpisode episode) {
|
||||
if (episode.decision() != null) return episode.decision().source();
|
||||
if (episode.outcome() == DownedEpisode.Outcome.REVIVED) return "revive";
|
||||
return episode.resolvedAt().equals(episode.expiresAt()) ? "timeout" : "accept_death";
|
||||
}
|
||||
|
||||
private static EventInput.Context actor(DownedEpisode.Decision decision){if(decision==null||decision.actorPlayer()==null)return null;return new EventInput.Context(decision.actorPlayer(),decision.actorPlayerName(),decision.actorCharacter(),decision.actorCharacterName(),decision.actorSession(),null);}
|
||||
|
||||
private static UUID stable(UUID authority, String kind) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:" + authority + ':' + kind)
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private void emit(UUID id, UUID action, Instant time, String type, Map<String, Object> data) {
|
||||
emit(id,action,time,type,null,data);
|
||||
}
|
||||
|
||||
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, data));
|
||||
} catch (RuntimeException ignored) {
|
||||
// Logging is observability only and cannot alter the durable gameplay decision.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.items.EquipmentStorage;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
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.nio.file.Path;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Observes only proved native box custody and the owning acknowledged receipt boundary. */
|
||||
public final class EquipmentEventBridge implements AutoCloseable {
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final FeatureEventOutbox outbox;
|
||||
private final Clock clock;
|
||||
|
||||
public EquipmentEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Clock clock, Path dataFolder, Consumer<Throwable> failures) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.outbox = new FeatureEventOutbox(Objects.requireNonNull(dataFolder), "equipment-event-intents",
|
||||
Set.of("equipment.packed", "equipment.restored", "equipment.recovery_resolved"), sink,
|
||||
Objects.requireNonNull(failures));
|
||||
}
|
||||
|
||||
public void replay() { outbox.replay(); }
|
||||
@Override public void close() { outbox.close(); }
|
||||
public CompletionStage<Void> drained() { return outbox.drained(); }
|
||||
|
||||
public record Capture(EventInput.Context context, EquipmentStorage.Snapshot box, Instant observedAt,
|
||||
UUID session, UUID character) { }
|
||||
|
||||
public record Action(UUID id, Instant time, EventInput.Context context, UUID session, UUID character,
|
||||
String kind) { }
|
||||
|
||||
public Action action(Player player, UUID session, UUID character, String kind) {
|
||||
try {
|
||||
// A receipt proves the character UUID, not its current display name. Keep that UUID
|
||||
// in the payload rather than inventing an incomplete character envelope context.
|
||||
var context = new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null,
|
||||
location(player.getLocation()));
|
||||
var action = new Action(UUID.randomUUID(), clock.instant(), context, session, character, kind);
|
||||
var values = actionFields(action);
|
||||
values.put("outcome", "observed");
|
||||
emitAction(action, "action.observed", "observed", values);
|
||||
return action;
|
||||
} catch (RuntimeException ignored) { return null; }
|
||||
}
|
||||
|
||||
public void resolved(Action action, String outcome, String reason) {
|
||||
if (action == null) return;
|
||||
var values = actionFields(action);
|
||||
values.put("outcome", outcome);
|
||||
values.put("reason", reason);
|
||||
emitAction(action, "action.resolved", "resolved", values);
|
||||
}
|
||||
|
||||
public void recovery(Action action, UUID session, UUID character, String priorPhase, String currentPhase,
|
||||
String quality, String outcome, String reason, BigInteger receiptRevision) {
|
||||
if (action == null) return;
|
||||
var values = actionFields(action);
|
||||
values.put("recovery_operation_id", action.id().toString());
|
||||
values.put("session_id", session.toString());
|
||||
values.put("character_id", character.toString());
|
||||
values.put("prior_phase", priorPhase);
|
||||
values.put("current_phase", currentPhase);
|
||||
values.put("custody_quality", quality);
|
||||
values.put("outcome", outcome);
|
||||
values.put("reason", reason);
|
||||
if (receiptRevision != null) values.put("receipt_revision", receiptRevision.toString());
|
||||
try { outbox.accepted(new EventInput(id(action.id(), "recovery_resolved"), action.id(), clock.instant(),
|
||||
"equipment.recovery_resolved", action.context(), values)); }
|
||||
catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
private LinkedHashMap<String, Object> actionFields(Action action) {
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
values.put("action_kind", action.kind());
|
||||
if (action.session() != null) values.put("session_id", action.session().toString());
|
||||
if (action.character() != null) values.put("character_id", action.character().toString());
|
||||
return values;
|
||||
}
|
||||
|
||||
private void emitAction(Action action, String kind, String stage, Map<String, Object> values) {
|
||||
try { sink.apply(new EventInput(id(action.id(), stage + ':' + values.get("outcome") + ':' + values.getOrDefault("reason", "")), action.id(),
|
||||
stage.equals("observed") ? action.time() : clock.instant(), kind, action.context(), values)); }
|
||||
catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
public Capture capture(Player player, UUID session, CharacterRecord character, EquipmentStorage.Snapshot box) {
|
||||
return capture(player, session, character.id(), character.name(), box);
|
||||
}
|
||||
|
||||
/** Recovery can prove a receipt's character UUID without proving a current character name. */
|
||||
public Capture capture(Player player, UUID session, UUID character, String characterName,
|
||||
EquipmentStorage.Snapshot box) {
|
||||
try {
|
||||
return new Capture(new EventInput.Context(player.getUniqueId(), player.getName(),
|
||||
characterName == null ? null : character, characterName,
|
||||
characterName == null ? null : session, location(player.getLocation())),
|
||||
box, clock.instant(), session, character);
|
||||
} catch (RuntimeException ignored) { return null; }
|
||||
}
|
||||
|
||||
public void packed(Capture captured, BigInteger revision) {
|
||||
if (captured == null || revision == null) return;
|
||||
var values = fields(captured, revision);
|
||||
values.put("box_custody", "main_storage");
|
||||
values.put("outcome", "acknowledged_packed");
|
||||
emit(captured, "equipment.packed", "packed", values);
|
||||
}
|
||||
|
||||
public void restored(Capture captured, BigInteger revision) {
|
||||
if (captured == null || revision == null) return;
|
||||
var values = fields(captured, revision);
|
||||
values.put("displaced_head_handling", captured.box().displacedHead()
|
||||
? captured.box().boxSource().equals("cursor") ? "cursor" : "freed_box_slot" : "none");
|
||||
values.put("outcome", "acknowledged_restored");
|
||||
emit(captured, "equipment.restored", "restored", values);
|
||||
}
|
||||
|
||||
private LinkedHashMap<String, Object> fields(Capture captured, BigInteger revision) {
|
||||
var box = captured.box();
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
values.put("session_id", captured.session().toString());
|
||||
values.put("character_id", captured.character().toString());
|
||||
values.put("equipment_operation_id", captured.session().toString());
|
||||
values.put("receipt_revision", revision.toString());
|
||||
values.put("slot_count", box.packedSlots());
|
||||
values.put("slot_classes", box.slotClasses());
|
||||
values.put("hotbar_slot_count", box.hotbarSlots());
|
||||
values.put("armor_slot_count", box.armorSlots());
|
||||
values.put("box_source", box.boxSource());
|
||||
values.put("box_slot", box.boxSlot());
|
||||
return values;
|
||||
}
|
||||
|
||||
private void emit(Capture captured, String type, String phase, Map<String, Object> values) {
|
||||
try { outbox.accepted(new EventInput(id(captured.session(), phase), captured.session(), captured.observedAt(),
|
||||
type, captured.context(), values)); }
|
||||
catch (RuntimeException ignored) { /* Audit failures cannot change native equipment custody. */ }
|
||||
}
|
||||
|
||||
private EventInput.Location location(Location value) {
|
||||
return value == null || value.getWorld() == null ? null
|
||||
: new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(), value.getX(), value.getY(),
|
||||
value.getZ(), clock.instant(), "exact");
|
||||
}
|
||||
|
||||
private static UUID id(UUID session, String stage) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:equipment:" + session + ':' + stage)
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.time.Clock;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.WeakHashMap;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.entity.Entity;
|
||||
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 {
|
||||
private record Captured(UUID action, EventInput.Context context, String kind, Map<String, Object> details) { }
|
||||
private final LocalEventLog events;
|
||||
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);
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = false)
|
||||
public void observe(PlayerInteractEvent event) {
|
||||
if (event.getHand() != EquipmentSlot.HAND) return;
|
||||
var snapshot = context(event.getPlayer()).orElse(null);
|
||||
if (snapshot == null) return;
|
||||
String kind = switch (event.getAction()) {
|
||||
case RIGHT_CLICK_BLOCK, LEFT_CLICK_BLOCK, PHYSICAL -> "interact_block";
|
||||
case RIGHT_CLICK_AIR, LEFT_CLICK_AIR -> "interact_item";
|
||||
};
|
||||
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);
|
||||
emit("action.observed", value, "observed", event.isCancelled());
|
||||
}
|
||||
|
||||
@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;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){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(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){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){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);}
|
||||
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(VehicleEnterEvent event){if(event.getEntered() instanceof Player player)capture(event,player,"vehicle_enter",entity(event.getVehicle(),"VehicleEnterEvent"),event);}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(VehicleEnterEvent event){resolveCaptured(event,event);}
|
||||
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(VehicleExitEvent event){if(event.getExited() instanceof Player player)capture(event,player,"vehicle_exit",entity(event.getVehicle(),"VehicleExitEvent"),event);}
|
||||
@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());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));
|
||||
}
|
||||
|
||||
private Optional<EventInput.Context> context(Player player) {
|
||||
var active = sessions.active(player.getUniqueId()).orElse(null);
|
||||
if (active == null) return Optional.empty();
|
||||
var record = sessions.currentRecord(active).orElse(null);
|
||||
if (record == null) return Optional.empty();
|
||||
return Optional.of(new EventInput.Context(player.getUniqueId(), player.getName(), active.character(), record.name(),
|
||||
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 Map<String,Object> locationMap(Location location){var value=new LinkedHashMap<String,Object>();value.put("world_id",location.getWorld().getUID().toString());value.put("world_name",location.getWorld().getName());value.put("x",location.getX());value.put("y",location.getY());value.put("z",location.getZ());return value;}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Immutable event input captured before leaving the native/gameplay thread. */
|
||||
public record EventInput(UUID id, UUID actionId, Instant time, String type, Context context,
|
||||
Map<String, Object> data) {
|
||||
public EventInput {
|
||||
Objects.requireNonNull(id);
|
||||
Objects.requireNonNull(actionId);
|
||||
Objects.requireNonNull(time);
|
||||
if (type == null || !type.matches("[a-z][a-z0-9_.-]*")) throw new IllegalArgumentException("Invalid event type");
|
||||
data = snapshotMap(Objects.requireNonNull(data));
|
||||
}
|
||||
|
||||
private static Map<String, Object> snapshotMap(Map<String, ?> source) {
|
||||
var copy = new LinkedHashMap<String, Object>();
|
||||
source.forEach((key, value) -> copy.put(Objects.requireNonNull(key), snapshot(value)));
|
||||
return Collections.unmodifiableMap(copy);
|
||||
}
|
||||
|
||||
private static Object snapshot(Object value) {
|
||||
if (value == null || value instanceof String || value instanceof Boolean || value instanceof UUID
|
||||
|| value instanceof java.time.temporal.TemporalAccessor) return value;
|
||||
if (value instanceof Number number) {
|
||||
if (number instanceof Double doubleValue && !Double.isFinite(doubleValue)
|
||||
|| number instanceof Float floatValue && !Float.isFinite(floatValue)) {
|
||||
throw new IllegalArgumentException("Non-finite event number");
|
||||
}
|
||||
return number;
|
||||
}
|
||||
if (value instanceof Map<?, ?> map) {
|
||||
var copy = new LinkedHashMap<String, Object>();
|
||||
map.forEach((key, nested) -> {
|
||||
if (!(key instanceof String text)) throw new IllegalArgumentException("Non-string event key");
|
||||
copy.put(text, snapshot(nested));
|
||||
});
|
||||
return Collections.unmodifiableMap(copy);
|
||||
}
|
||||
if (value instanceof Collection<?> collection) {
|
||||
return List.copyOf(collection.stream().map(EventInput::snapshot).toList());
|
||||
}
|
||||
throw new IllegalArgumentException("Unsupported event value " + value.getClass().getName());
|
||||
}
|
||||
|
||||
public record Context(UUID playerId, String playerName, UUID characterId, String characterName,
|
||||
UUID sessionId, Location location) {
|
||||
public Context {
|
||||
if (playerId == null || playerName == null) throw new IllegalArgumentException("Incomplete player identity");
|
||||
if ((characterId == null) != (characterName == null) || characterId != null && sessionId == null
|
||||
|| characterId == null && sessionId != null) {
|
||||
throw new IllegalArgumentException("Incomplete character identity");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record Location(UUID worldId, String worldName, double x, double y, double z,
|
||||
Instant capturedAt, String quality) {
|
||||
public Location {
|
||||
Objects.requireNonNull(worldId);
|
||||
Objects.requireNonNull(worldName);
|
||||
Objects.requireNonNull(capturedAt);
|
||||
Objects.requireNonNull(quality);
|
||||
if (!Double.isFinite(x) || !Double.isFinite(y) || !Double.isFinite(z)) {
|
||||
throw new IllegalArgumentException("Non-finite event location");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
/** Per-feature recoverable intent for acknowledged domain outcomes. Never replays gameplay. */
|
||||
final class FeatureEventOutbox implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 16_384;
|
||||
private final Set<String> types;
|
||||
private final Path directory;
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final Consumer<Throwable> failures;
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor(task ->
|
||||
new Thread(task, "fantasy-feature-event-intents"));
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
FeatureEventOutbox(Path data, String folder, Set<String> types,
|
||||
Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Consumer<Throwable> failures) {
|
||||
if (!folder.matches("[a-z-]+")) throw new IllegalArgumentException("Unsafe event intent folder");
|
||||
directory = data.toAbsolutePath().resolve(folder);
|
||||
this.types = Set.copyOf(types);
|
||||
this.sink = sink;
|
||||
this.failures = failures;
|
||||
}
|
||||
|
||||
void accepted(EventInput event) {
|
||||
try { worker.execute(() -> {
|
||||
try { publish(write(event)); } catch (IOException | RuntimeException failure) { report(failure); }
|
||||
}); } catch (RuntimeException failure) { report(failure); }
|
||||
}
|
||||
|
||||
void replay() {
|
||||
if (worker.isShutdown()) return;
|
||||
try { worker.execute(() -> {
|
||||
try {
|
||||
if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) return;
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe character event directory");
|
||||
try (var files = Files.list(directory)) {
|
||||
for (var path : files.filter(value -> value.getFileName().toString().endsWith(".pending")).sorted().toList()) {
|
||||
try { publish(read(path)); } catch (IOException | RuntimeException failure) { report(failure); }
|
||||
}
|
||||
}
|
||||
} catch (IOException | RuntimeException failure) { report(failure); }
|
||||
}); } catch (RuntimeException failure) { report(failure); }
|
||||
}
|
||||
|
||||
private Path path(EventInput event) { return directory.resolve(event.id() + ".pending"); }
|
||||
|
||||
private EventInput write(EventInput event) throws IOException {
|
||||
if (!types.contains(event.type())) throw new IOException("Unsupported character intent type");
|
||||
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)
|
||||
&& !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe character event directory");
|
||||
Files.createDirectories(directory);
|
||||
var target = path(event);
|
||||
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) return read(target);
|
||||
var fields = new Properties();
|
||||
fields.setProperty("id", event.id().toString());
|
||||
fields.setProperty("action", event.actionId().toString());
|
||||
fields.setProperty("time", event.time().toString());
|
||||
fields.setProperty("type", event.type());
|
||||
var context = event.context();
|
||||
if (context != null) {
|
||||
fields.setProperty("player", context.playerId().toString());
|
||||
fields.setProperty("player_name", context.playerName());
|
||||
}
|
||||
if (context != null && context.characterId() != null) {
|
||||
fields.setProperty("character", context.characterId().toString());
|
||||
fields.setProperty("character_name", context.characterName());
|
||||
fields.setProperty("session", context.sessionId().toString());
|
||||
}
|
||||
if (context != null && context.location() != null) {
|
||||
var location = context.location();
|
||||
fields.setProperty("world", location.worldId().toString());
|
||||
fields.setProperty("world_name", location.worldName());
|
||||
fields.setProperty("x", Double.toString(location.x()));
|
||||
fields.setProperty("y", Double.toString(location.y()));
|
||||
fields.setProperty("z", Double.toString(location.z()));
|
||||
fields.setProperty("captured_at", location.capturedAt().toString());
|
||||
fields.setProperty("quality", location.quality());
|
||||
}
|
||||
event.data().forEach((key, value) -> {
|
||||
if (value instanceof java.util.Map<?, ?> nested) {
|
||||
if (key.contains(".")) throw new IllegalArgumentException("Unsafe nested feature intent key");
|
||||
nested.forEach((name, part) -> {
|
||||
if (!(name instanceof String child) || child.contains("."))
|
||||
throw new IllegalArgumentException("Unsafe nested feature intent key");
|
||||
encode(fields, "data." + key + "." + child, part);
|
||||
});
|
||||
} else encode(fields, "data." + key, value);
|
||||
});
|
||||
var output = new ByteArrayOutputStream();
|
||||
fields.store(output, "acknowledged character identity event");
|
||||
var bytes = output.toByteArray();
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Character event intent too large");
|
||||
var temporary = Files.createTempFile(directory, ".identity-", ".tmp");
|
||||
try {
|
||||
try (var channel = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var buffer = ByteBuffer.wrap(bytes);
|
||||
while (buffer.hasRemaining()) channel.write(buffer);
|
||||
channel.force(true);
|
||||
}
|
||||
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
forceDirectory();
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
return event;
|
||||
}
|
||||
|
||||
private static void encode(Properties fields, String key, Object value) {
|
||||
if (value instanceof String text) fields.setProperty(key, "s" + text);
|
||||
else if (value instanceof Boolean bool) fields.setProperty(key, "b" + bool);
|
||||
else if (value instanceof Integer number) fields.setProperty(key, "i" + number);
|
||||
else if (value instanceof Double number && Double.isFinite(number)) fields.setProperty(key, "d" + number);
|
||||
else if (value instanceof java.util.List<?> list && list.stream().allMatch(item -> item instanceof String text
|
||||
&& (text.equals("hotbar") || text.equals("armor"))))
|
||||
fields.setProperty(key, "l" + String.join(",", list.stream().map(String.class::cast).toList()));
|
||||
else throw new IllegalArgumentException("Unsupported feature intent value");
|
||||
}
|
||||
|
||||
private EventInput read(Path path) throws IOException {
|
||||
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe character event intent");
|
||||
var fields = new Properties();
|
||||
try (var input = Files.newInputStream(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var bytes = input.readNBytes(MAX_BYTES + 1);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Character event intent too large");
|
||||
fields.load(new ByteArrayInputStream(bytes));
|
||||
}
|
||||
try {
|
||||
var id = UUID.fromString(fields.getProperty("id"));
|
||||
var action = UUID.fromString(fields.getProperty("action"));
|
||||
var type = fields.getProperty("type");
|
||||
if (!path.equals(directory.resolve(id + ".pending")) || !types.contains(type))
|
||||
throw new IOException("Invalid character event intent identity");
|
||||
EventInput.Location location = fields.containsKey("world")
|
||||
? new EventInput.Location(UUID.fromString(fields.getProperty("world")), fields.getProperty("world_name"),
|
||||
Double.parseDouble(fields.getProperty("x")), Double.parseDouble(fields.getProperty("y")),
|
||||
Double.parseDouble(fields.getProperty("z")), Instant.parse(fields.getProperty("captured_at")),
|
||||
fields.getProperty("quality")) : null;
|
||||
var character = fields.containsKey("character") ? UUID.fromString(fields.getProperty("character")) : null;
|
||||
if (!fields.containsKey("player") && (character != null || fields.containsKey("session") || location != null))
|
||||
throw new IOException("Unbound event context");
|
||||
var context = fields.containsKey("player")
|
||||
? new EventInput.Context(UUID.fromString(fields.getProperty("player")),
|
||||
fields.getProperty("player_name"), character, fields.getProperty("character_name"),
|
||||
fields.containsKey("session") ? UUID.fromString(fields.getProperty("session")) : null, location)
|
||||
: null;
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
var nested = new LinkedHashMap<String, java.util.Map<String, Object>>();
|
||||
for (var key : fields.stringPropertyNames()) if (key.startsWith("data.")) {
|
||||
var value = fields.getProperty(key);
|
||||
if (value.isEmpty()) throw new IOException("Invalid character event value");
|
||||
var decoded = switch (value.charAt(0)) {
|
||||
case 's' -> value.substring(1);
|
||||
case 'b' -> {
|
||||
if (!value.equals("btrue") && !value.equals("bfalse")) throw new IOException("Invalid boolean");
|
||||
yield Boolean.valueOf(value.substring(1));
|
||||
}
|
||||
case 'i' -> Integer.valueOf(value.substring(1));
|
||||
case 'd' -> {
|
||||
var number = Double.valueOf(value.substring(1));
|
||||
if (!Double.isFinite(number)) throw new IOException("Non-finite event coordinate");
|
||||
yield number;
|
||||
}
|
||||
case 'l' -> {
|
||||
var encoded = value.substring(1);
|
||||
var list = encoded.isEmpty() ? java.util.List.<String>of() : java.util.List.of(encoded.split(","));
|
||||
if (!list.stream().allMatch(item -> item.equals("hotbar") || item.equals("armor")))
|
||||
throw new IOException("Invalid slot class");
|
||||
yield list;
|
||||
}
|
||||
default -> throw new IOException("Invalid feature event value type");
|
||||
};
|
||||
var name = key.substring(5);
|
||||
var separator = name.indexOf('.');
|
||||
if (separator < 0) data.put(name, decoded);
|
||||
else {
|
||||
if (separator == 0 || separator == name.length() - 1 || name.indexOf('.', separator + 1) >= 0)
|
||||
throw new IOException("Invalid nested feature intent key");
|
||||
nested.computeIfAbsent(name.substring(0, separator), ignored -> new LinkedHashMap<>())
|
||||
.put(name.substring(separator + 1), decoded);
|
||||
}
|
||||
}
|
||||
nested.forEach((key, value) -> {
|
||||
if (data.putIfAbsent(key, value) != null) throw new IllegalArgumentException("Conflicting feature intent key");
|
||||
});
|
||||
return new EventInput(id, action, Instant.parse(fields.getProperty("time")), type, context, data);
|
||||
} catch (RuntimeException failure) { throw new IOException("Invalid character event intent", failure); }
|
||||
}
|
||||
|
||||
private void publish(EventInput event) {
|
||||
try { sink.apply(event).whenComplete((delivery, failure) -> {
|
||||
if (failure != null) { report(failure); return; }
|
||||
if (delivery == LocalEventLog.Delivery.REJECTED) return;
|
||||
try { if (Files.deleteIfExists(path(event))) forceDirectory(); }
|
||||
catch (IOException error) { report(error); }
|
||||
}); } catch (RuntimeException failure) { report(failure); }
|
||||
}
|
||||
|
||||
private void forceDirectory() throws IOException {
|
||||
try (var channel = FileChannel.open(directory, StandardOpenOption.READ)) { channel.force(true); }
|
||||
}
|
||||
|
||||
private void report(Throwable error) {
|
||||
try { failures.accept(error); } catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
worker.shutdown();
|
||||
CompletableFuture.runAsync(() -> {
|
||||
try {
|
||||
if (!worker.awaitTermination(5, TimeUnit.SECONDS)) report(new IOException("Character event intents did not drain"));
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt(); report(interrupted);
|
||||
} finally { drained.complete(null); }
|
||||
});
|
||||
}
|
||||
|
||||
CompletionStage<Void> drained() { return drained; }
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/** Independent local CloudEvents JSONL stream with recoverable per-event pending files. */
|
||||
public final class LocalEventLog implements AutoCloseable {
|
||||
public static final String TYPE_PREFIX = "games.dmg.minecraft.fantasystick.";
|
||||
public enum Delivery { EXPORTED, DURABLE_PENDING, REJECTED }
|
||||
public record RuntimeInfo(String serverName, String platformVersion, String gameVersion, String pluginVersion) {
|
||||
public RuntimeInfo(String serverName,String gameVersion,String pluginVersion){this(serverName,gameVersion,gameVersion,pluginVersion);}
|
||||
public RuntimeInfo {
|
||||
Objects.requireNonNull(serverName);
|
||||
Objects.requireNonNull(platformVersion);
|
||||
Objects.requireNonNull(gameVersion);
|
||||
Objects.requireNonNull(pluginVersion);
|
||||
}
|
||||
}
|
||||
|
||||
private final Path root;
|
||||
private final Path pending;
|
||||
private final Path activeBoot;
|
||||
private final Clock clock;
|
||||
private final RuntimeInfo runtime;
|
||||
private final long maximumSegmentBytes;
|
||||
private final Consumer<String> diagnostic;
|
||||
private final UUID serverId;
|
||||
private final UUID bootId = UUID.randomUUID();
|
||||
private final ThreadPoolExecutor worker;
|
||||
private final CompletableFuture<Delivery> ready = new CompletableFuture<>();
|
||||
private final java.util.concurrent.atomic.AtomicBoolean started = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
private final UUID priorBoot;
|
||||
private final FileChannel lockChannel;
|
||||
private final java.nio.channels.FileLock writerLock;
|
||||
private long sequence;
|
||||
private Path segment;
|
||||
private LocalDate segmentDate;
|
||||
private long segmentBytes;
|
||||
private int segmentNumber;
|
||||
private volatile boolean closing;
|
||||
private final java.util.concurrent.atomic.AtomicLong unacceptedHandoff = new java.util.concurrent.atomic.AtomicLong();
|
||||
private final java.util.concurrent.atomic.AtomicLong lastDiagnostic = new java.util.concurrent.atomic.AtomicLong();
|
||||
|
||||
public static LocalEventLog open(Path dataFolder, Clock clock, RuntimeInfo runtime, int capacity,
|
||||
long maximumSegmentBytes, Consumer<String> diagnostic) throws IOException {
|
||||
return new LocalEventLog(dataFolder, clock, runtime, capacity, maximumSegmentBytes, diagnostic);
|
||||
}
|
||||
|
||||
private LocalEventLog(Path dataFolder, Clock clock, RuntimeInfo runtime, int capacity,
|
||||
long maximumSegmentBytes, Consumer<String> diagnostic) throws IOException {
|
||||
if (capacity < 1 || maximumSegmentBytes < 2) throw new IllegalArgumentException("Invalid event-log bound");
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.runtime = Objects.requireNonNull(runtime);
|
||||
this.maximumSegmentBytes = maximumSegmentBytes;
|
||||
this.diagnostic = Objects.requireNonNull(diagnostic);
|
||||
root = Objects.requireNonNull(dataFolder).resolve("events");
|
||||
if (Files.exists(root, LinkOption.NOFOLLOW_LINKS) && Files.isSymbolicLink(root)) {
|
||||
throw new IOException("Fantasy Stick events path is a symbolic link");
|
||||
}
|
||||
Files.createDirectories(root);
|
||||
pending = root.resolve("pending");
|
||||
if (Files.exists(pending, LinkOption.NOFOLLOW_LINKS) && Files.isSymbolicLink(pending)) {
|
||||
throw new IOException("Fantasy Stick pending-events path is a symbolic link");
|
||||
}
|
||||
Files.createDirectories(pending);
|
||||
activeBoot = root.resolve("active-boot");
|
||||
serverId = loadServerId();
|
||||
var lockPath=root.resolve("writer.lock");if(Files.exists(lockPath,LinkOption.NOFOLLOW_LINKS)&&Files.isSymbolicLink(lockPath))throw new IOException("Fantasy Stick writer lock is a symbolic link");
|
||||
lockChannel = FileChannel.open(lockPath, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
|
||||
java.nio.channels.FileLock acquired;
|
||||
try { acquired = lockChannel.tryLock(); }
|
||||
catch (java.nio.channels.OverlappingFileLockException conflict) { acquired = null; }
|
||||
if (acquired == null) { lockChannel.close(); throw new IOException("Fantasy Stick event writer is already active"); }
|
||||
writerLock = acquired;
|
||||
UUID previous;
|
||||
try {
|
||||
previous = loadPriorBoot();
|
||||
Files.writeString(activeBoot, bootId + "\n", StandardCharsets.UTF_8,
|
||||
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE);
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
writerLock.release(); lockChannel.close(); throw failure;
|
||||
}
|
||||
priorBoot = previous;
|
||||
worker = new ThreadPoolExecutor(1, 1, 0, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(capacity), task -> {
|
||||
var thread = new Thread(task, "fantasy-stick-events");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
}, new ThreadPoolExecutor.AbortPolicy());
|
||||
}
|
||||
|
||||
public CompletionStage<Delivery> ready() {
|
||||
if(started.compareAndSet(false,true))submitStartup(priorBoot).whenComplete((delivery,failure)->{if(failure!=null)ready.completeExceptionally(failure);else ready.complete(delivery);});
|
||||
return ready.minimalCompletionStage();
|
||||
}
|
||||
public UUID serverId() { return serverId; }
|
||||
public UUID bootId() { return bootId; }
|
||||
|
||||
public CompletionStage<Delivery> emit(EventInput input) {
|
||||
Objects.requireNonNull(input);
|
||||
if (closing) { rejectedHandoff(); return CompletableFuture.completedStage(Delivery.REJECTED); }
|
||||
return submitInternal(input, true);
|
||||
}
|
||||
|
||||
private CompletableFuture<Delivery> submitStartup(UUID priorBoot) {
|
||||
var result = new CompletableFuture<Delivery>();
|
||||
try {
|
||||
worker.execute(() -> {
|
||||
try {
|
||||
boolean interruptedTail = recoverInterruptedTails();
|
||||
int recoveredPending = recoverPending();
|
||||
if (priorBoot != null || interruptedTail || recoveredPending > 0) {
|
||||
var recovery = new LinkedHashMap<String, Object>();
|
||||
recovery.put("outcome", "incomplete_prior_lifetime");
|
||||
recovery.put("recovered_pending_count", Integer.toString(recoveredPending));
|
||||
recovery.put("interrupted_tail", interruptedTail);
|
||||
if (priorBoot != null) recovery.put("prior_boot_id", priorBoot.toString());
|
||||
store(new EventInput(UUID.randomUUID(), UUID.randomUUID(), clock.instant(),
|
||||
"game.recovery_detected", null, recovery));
|
||||
}
|
||||
result.complete(store(lifecycle("game.started", "ready")));
|
||||
} catch (Exception failure) {
|
||||
diagnostic.accept("Fantasy Stick event startup failed: " + failure.getClass().getSimpleName());
|
||||
result.complete(Delivery.REJECTED);
|
||||
}
|
||||
});
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
result.complete(Delivery.REJECTED);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private CompletableFuture<Delivery> submitInternal(EventInput input, boolean countRejection) {
|
||||
var result = new CompletableFuture<Delivery>();
|
||||
try {
|
||||
worker.execute(operation(input,result));
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
if (countRejection) rejectedHandoff();
|
||||
result.complete(Delivery.REJECTED);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private CompletableFuture<Delivery> submitClosing(EventInput input){var result=new CompletableFuture<Delivery>();try{if(!worker.getQueue().offer(operation(input,result),5,TimeUnit.SECONDS))result.complete(Delivery.REJECTED);}catch(InterruptedException interrupted){Thread.currentThread().interrupt();result.complete(Delivery.REJECTED);}return result;}
|
||||
private Runnable operation(EventInput input,CompletableFuture<Delivery> result){return ()->{try{result.complete(store(input));}catch(Exception failure){diagnostic.accept("Fantasy Stick event write failed: "+failure.getClass().getSimpleName());result.complete(Delivery.REJECTED);}};}
|
||||
|
||||
private Delivery store(EventInput input) throws IOException {
|
||||
recoverPending();
|
||||
var rejected = unacceptedHandoff.getAndSet(0);
|
||||
if (rejected > 0 && !"logging.gap".equals(input.type())) {
|
||||
degradedDiagnostic("Fantasy Stick event queue rejected observations before durable acceptance");
|
||||
storeOne(new EventInput(UUID.randomUUID(), UUID.randomUUID(), clock.instant(), "logging.gap", null,
|
||||
Map.of("unaccepted_count", Long.toString(rejected), "reason", "bounded_handoff_rejected",
|
||||
"outcome", "uncertain")));
|
||||
}
|
||||
return storeOne(input);
|
||||
}
|
||||
|
||||
private Delivery storeOne(EventInput input) throws IOException {
|
||||
var line = encode(input, ++sequence);var bytes=(line+"\n").getBytes(StandardCharsets.UTF_8);var target=pending.resolve(input.id()+".pending");writeCreateAtomic(target,bytes);try{append(bytes);Files.deleteIfExists(target);return Delivery.EXPORTED;}catch(IOException failure){degradedDiagnostic("Fantasy Stick event export failed; durable pending work was retained");return Delivery.DURABLE_PENDING;}
|
||||
}
|
||||
|
||||
private void rejectedHandoff(){unacceptedHandoff.incrementAndGet();}
|
||||
private void degradedDiagnostic(String message){long now=System.nanoTime(),previous=lastDiagnostic.get();if((previous==0||now-previous>=Duration.ofSeconds(30).toNanos())&&lastDiagnostic.compareAndSet(previous,now))diagnostic.accept(message);}
|
||||
|
||||
private UUID loadPriorBoot() throws IOException {
|
||||
if (!Files.exists(activeBoot, LinkOption.NOFOLLOW_LINKS)) return null;
|
||||
if (Files.isSymbolicLink(activeBoot)) throw new IOException("Fantasy Stick active writer marker is a symbolic link");
|
||||
try { return UUID.fromString(Files.readString(activeBoot, StandardCharsets.UTF_8).trim()); }
|
||||
catch (IllegalArgumentException invalid) { throw new IOException("Invalid Fantasy Stick active writer marker", invalid); }
|
||||
}
|
||||
|
||||
private boolean recoverInterruptedTails() throws IOException {
|
||||
List<Path> files;
|
||||
try (var stream = Files.walk(root)) {
|
||||
files = stream.filter(path -> path.getFileName().toString().endsWith(".jsonl"))
|
||||
.sorted().toList();
|
||||
}
|
||||
boolean recovered = false;
|
||||
for (var path : files) {
|
||||
if (Files.isSymbolicLink(path)) throw new IOException("Symbolic link in Fantasy Stick event segments");
|
||||
var bytes = Files.readAllBytes(path);
|
||||
int prefix=0,cursor=0;while(cursor<bytes.length){int end=cursor;while(end<bytes.length&&bytes[end]!='\n')end++;if(end==bytes.length)break;var line=java.util.Arrays.copyOfRange(bytes,cursor,end+1);if(!validJsonObjectLine(line))break;prefix=end+1;cursor=end+1;}if(prefix==bytes.length)continue;
|
||||
recovered = true;
|
||||
var quarantine = path.resolveSibling(path.getFileName() + ".corrupt-" + UUID.randomUUID());
|
||||
try (var channel = FileChannel.open(quarantine, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
|
||||
var tail = ByteBuffer.wrap(bytes, prefix, bytes.length - prefix);
|
||||
while (tail.hasRemaining()) channel.write(tail);
|
||||
channel.force(true);
|
||||
}
|
||||
try (var channel = FileChannel.open(path, StandardOpenOption.WRITE)) {
|
||||
channel.truncate(prefix);
|
||||
channel.force(true);
|
||||
}
|
||||
}
|
||||
return recovered;
|
||||
}
|
||||
|
||||
private int recoverPending() throws IOException {
|
||||
List<Path> files;
|
||||
try (var stream = Files.list(pending)) {
|
||||
files = stream.filter(path -> path.getFileName().toString().endsWith(".pending"))
|
||||
.sorted(Comparator.comparing(path -> path.getFileName().toString())).toList();
|
||||
}
|
||||
int recovered = 0;
|
||||
for (var path : files) {
|
||||
if (Files.isSymbolicLink(path)) throw new IOException("Symbolic link in Fantasy Stick pending events");
|
||||
var bytes = Files.readAllBytes(path);
|
||||
if (!validJsonObjectLine(bytes)) {
|
||||
Files.move(path,path.resolveSibling(path.getFileName()+".corrupt-"+UUID.randomUUID()));
|
||||
continue;
|
||||
}
|
||||
append(bytes);
|
||||
Files.delete(path);
|
||||
recovered++;
|
||||
}
|
||||
return recovered;
|
||||
}
|
||||
|
||||
private static boolean validJsonObjectLine(byte[] bytes){
|
||||
if(bytes.length<3||bytes[bytes.length-1]!='\n')return false;String text;try{var decoder=StandardCharsets.UTF_8.newDecoder();decoder.onMalformedInput(java.nio.charset.CodingErrorAction.REPORT);decoder.onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT);text=decoder.decode(ByteBuffer.wrap(bytes,0,bytes.length-1)).toString();}catch(java.nio.charset.CharacterCodingException invalid){return false;}if(!text.startsWith("{")||!text.endsWith("}")||!text.contains("\"specversion\":\"1.0\"")||!text.contains("\"id\":\"")||!text.contains("\"data\":"))return false;var stack=new java.util.ArrayDeque<Character>();boolean string=false,escape=false;for(int index=0;index<text.length();index++){char value=text.charAt(index);if(string){if(escape){if(value=='u'){if(index+4>=text.length())return false;for(int digit=1;digit<=4;digit++)if(Character.digit(text.charAt(index+digit),16)<0)return false;index+=4;}else if("\"\\/bfnrt".indexOf(value)<0)return false;escape=false;}else if(value=='\\')escape=true;else if(value=='\"')string=false;else if(value<0x20)return false;continue;}if(value=='\"'){string=true;continue;}if(value=='{'||value=='[')stack.push(value);else if(value=='}'||value==']'){if(stack.isEmpty())return false;char opened=stack.pop();if(value=='}'&&opened!='{'||value==']'&&opened!='[')return false;}else if(value<0x20&&!Character.isWhitespace(value))return false;}return !string&&!escape&&stack.isEmpty();
|
||||
}
|
||||
|
||||
private void append(byte[] line) throws IOException {
|
||||
var date = LocalDate.ofInstant(clock.instant(), ZoneOffset.UTC);
|
||||
if (segment == null || !date.equals(segmentDate)
|
||||
|| segmentBytes > 0 && segmentBytes + line.length > maximumSegmentBytes) {
|
||||
openSegment(date);
|
||||
}
|
||||
try (var channel = FileChannel.open(segment, StandardOpenOption.WRITE, StandardOpenOption.APPEND)) {
|
||||
var buffer = ByteBuffer.wrap(line);
|
||||
while (buffer.hasRemaining()) channel.write(buffer);
|
||||
channel.force(true);
|
||||
}
|
||||
segmentBytes += line.length;
|
||||
}
|
||||
|
||||
private void openSegment(LocalDate date) throws IOException {
|
||||
var directory = root.resolve(date.toString());
|
||||
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Unsafe Fantasy Stick event segment directory");
|
||||
}
|
||||
} else Files.createDirectory(directory);
|
||||
if (!directory.toRealPath().getParent().equals(root.toRealPath())) {
|
||||
throw new IOException("Fantasy Stick event segment escaped its root");
|
||||
}
|
||||
while (true) {
|
||||
var candidate = directory.resolve("events-" + bootId + "-" + segmentNumber++ + ".jsonl");
|
||||
try {
|
||||
Files.createFile(candidate);
|
||||
segment = candidate;
|
||||
segmentDate = date;
|
||||
segmentBytes = 0;
|
||||
return;
|
||||
} catch (java.nio.file.FileAlreadyExistsException collision) {
|
||||
// A boot identifier collision cannot overwrite an existing segment.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private UUID loadServerId() throws IOException {
|
||||
var path = root.resolve("server-id");
|
||||
if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (Files.isSymbolicLink(path)) throw new IOException("Fantasy Stick server identity is a symbolic link");
|
||||
try { return UUID.fromString(Files.readString(path, StandardCharsets.UTF_8).trim()); }
|
||||
catch (IllegalArgumentException invalid) { throw new IOException("Invalid Fantasy Stick server identity", invalid); }
|
||||
}
|
||||
var created = UUID.randomUUID();
|
||||
Files.writeString(path, created + "\n", StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
|
||||
return created;
|
||||
}
|
||||
|
||||
private void writeCreateAtomic(Path target, byte[] bytes) throws IOException {
|
||||
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) return;
|
||||
var temporary = pending.resolve(target.getFileName() + "." + UUID.randomUUID() + ".tmp");
|
||||
try (var channel = FileChannel.open(temporary, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
|
||||
var buffer = ByteBuffer.wrap(bytes);
|
||||
while (buffer.hasRemaining()) channel.write(buffer);
|
||||
channel.force(true);
|
||||
}
|
||||
try { Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE); }
|
||||
catch (java.nio.file.AtomicMoveNotSupportedException unsupported) { Files.move(temporary, target); }
|
||||
finally { Files.deleteIfExists(temporary); }
|
||||
}
|
||||
|
||||
private EventInput lifecycle(String type, String phase) {
|
||||
return new EventInput(UUID.randomUUID(), UUID.randomUUID(), clock.instant(), type, null,
|
||||
Map.of("phase", phase, "reason", "plugin_lifecycle"));
|
||||
}
|
||||
|
||||
private String encode(EventInput input, long acceptedSequence) {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.putAll(input.data());
|
||||
data.put("schema_version", 1);
|
||||
data.put("server_id", serverId.toString());
|
||||
data.put("server_name", runtime.serverName());
|
||||
data.put("game_version", runtime.gameVersion());
|
||||
data.put("platform_version", runtime.platformVersion());
|
||||
data.put("plugin_version", runtime.pluginVersion());
|
||||
data.put("boot_id", bootId.toString());
|
||||
data.put("sequence", Long.toString(acceptedSequence));
|
||||
data.put("action_id", input.actionId().toString());
|
||||
var context = input.context();
|
||||
if (context != null) {
|
||||
data.put("actor_kind", "player");
|
||||
data.put("player_id", context.playerId().toString());
|
||||
data.put("player_name", context.playerName());
|
||||
if (context.characterId() != null) {
|
||||
data.put("character_id", context.characterId().toString());
|
||||
data.put("character_name", context.characterName());
|
||||
data.put("session_id", context.sessionId().toString());
|
||||
}
|
||||
if (context.location() != null) data.put("location", location(context.location()));
|
||||
} 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());
|
||||
envelope.put("source", "urn:minecraft-server:" + serverId);
|
||||
envelope.put("type", TYPE_PREFIX + input.type());
|
||||
envelope.put("time", input.time().toString());
|
||||
envelope.put("datacontenttype", "application/json");
|
||||
envelope.put("serverid", serverId.toString());
|
||||
envelope.put("servername", runtime.serverName());
|
||||
envelope.put("gameversion", runtime.gameVersion());
|
||||
if (context != null && context.playerId() != null) envelope.put("playerid", context.playerId().toString());
|
||||
if (context != null && context.characterId() != null) envelope.put("characterid", context.characterId().toString());
|
||||
envelope.put("data", data);
|
||||
return Json.encode(envelope);
|
||||
}
|
||||
|
||||
private static Map<String, Object> location(EventInput.Location location) {
|
||||
var value = new LinkedHashMap<String, Object>();
|
||||
value.put("world_id", location.worldId().toString());
|
||||
value.put("world_name", location.worldName());
|
||||
value.put("x", location.x());
|
||||
value.put("y", location.y());
|
||||
value.put("z", location.z());
|
||||
value.put("captured_at", location.capturedAt().toString());
|
||||
value.put("quality", location.quality());
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closing) return;
|
||||
closing = true;
|
||||
try {
|
||||
if(!started.get()){worker.shutdown();if(worker.awaitTermination(5,TimeUnit.SECONDS)&&pendingEmpty())Files.deleteIfExists(activeBoot);return;}
|
||||
var stopped = submitClosing(lifecycle("game.stopped", "stopped"));
|
||||
worker.shutdown();
|
||||
var delivery = stopped.get(5, TimeUnit.SECONDS);
|
||||
if (!worker.awaitTermination(5, TimeUnit.SECONDS)) diagnostic.accept("Fantasy Stick event shutdown timed out");
|
||||
if(delivery!=Delivery.EXPORTED)degradedDiagnostic("Fantasy Stick stop event was not exported; incomplete lifetime evidence was retained");
|
||||
if (delivery == Delivery.EXPORTED && pendingEmpty()) Files.deleteIfExists(activeBoot);
|
||||
} catch (Exception failure) {
|
||||
diagnostic.accept("Fantasy Stick event shutdown failed: " + failure.getClass().getSimpleName());
|
||||
worker.shutdown();
|
||||
} finally {
|
||||
try { writerLock.release(); } catch (IOException ignored) { }
|
||||
try { lockChannel.close(); } catch (IOException ignored) { }
|
||||
}
|
||||
}
|
||||
|
||||
private boolean pendingEmpty() throws IOException {
|
||||
try (var files = Files.list(pending)) { return files.noneMatch(path -> path.getFileName().toString().endsWith(".pending")); }
|
||||
}
|
||||
|
||||
/** Small protected JSON encoder; values are immutable event snapshots, never arbitrary objects. */
|
||||
static final class Json {
|
||||
private Json() { }
|
||||
static String encode(Object value) {
|
||||
var output = new StringBuilder();
|
||||
append(output, value);
|
||||
return output.toString();
|
||||
}
|
||||
private static void append(StringBuilder output, Object value) {
|
||||
if (value == null) output.append("null");
|
||||
else if (value instanceof String text) string(output, text);
|
||||
else if (value instanceof UUID || value instanceof java.time.temporal.TemporalAccessor) string(output, value.toString());
|
||||
else if (value instanceof Boolean bool) output.append(bool);
|
||||
else if (value instanceof Number number) number(output, number);
|
||||
else if (value instanceof Map<?, ?> map) object(output, map);
|
||||
else if (value instanceof Collection<?> collection) array(output, collection);
|
||||
else throw new IllegalArgumentException("Unsupported event value " + value.getClass().getName());
|
||||
}
|
||||
private static void object(StringBuilder output, Map<?, ?> map) {
|
||||
output.append('{');
|
||||
boolean comma = false;
|
||||
for (var entry : map.entrySet()) {
|
||||
if (!(entry.getKey() instanceof String key)) throw new IllegalArgumentException("Non-string event key");
|
||||
if (comma) output.append(',');
|
||||
string(output, key); output.append(':'); append(output, entry.getValue()); comma = true;
|
||||
}
|
||||
output.append('}');
|
||||
}
|
||||
private static void array(StringBuilder output, Collection<?> values) {
|
||||
output.append('['); boolean comma = false;
|
||||
for (var value : values) { if (comma) output.append(','); append(output, value); comma = true; }
|
||||
output.append(']');
|
||||
}
|
||||
private static void number(StringBuilder output, Number number) {
|
||||
if (number instanceof Double doubleValue && !Double.isFinite(doubleValue)
|
||||
|| number instanceof Float floatValue && !Float.isFinite(floatValue)) {
|
||||
throw new IllegalArgumentException("Non-finite event number");
|
||||
}
|
||||
output.append(number);
|
||||
}
|
||||
private static void string(StringBuilder output, String text) {
|
||||
output.append('"');
|
||||
for (int index = 0; index < text.length(); index++) {
|
||||
char value = text.charAt(index);
|
||||
switch (value) {
|
||||
case '"' -> output.append("\\\"");
|
||||
case '\\' -> output.append("\\\\");
|
||||
case '\b' -> output.append("\\b");
|
||||
case '\f' -> output.append("\\f");
|
||||
case '\n' -> output.append("\\n");
|
||||
case '\r' -> output.append("\\r");
|
||||
case '\t' -> output.append("\\t");
|
||||
default -> {
|
||||
if (value < 0x20 || Character.isSurrogate(value)
|
||||
&& (Character.isHighSurrogate(value)
|
||||
? index + 1 >= text.length() || !Character.isLowSurrogate(text.charAt(index + 1))
|
||||
: index == 0 || !Character.isHighSurrogate(text.charAt(index - 1)))) {
|
||||
output.append(String.format("\\u%04x", (int) value));
|
||||
} else output.append(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
output.append('"');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,159 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.OrdinaryVitals;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Captures nutrition at the native boundary; success follows an acknowledged session receipt. */
|
||||
public final class NutritionEventBridge implements AutoCloseable {
|
||||
public record Capture(EventInput.Context context, UUID session, UUID character, Instant time,
|
||||
int food, float saturation, float exhaustion, String reason) { }
|
||||
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final FeatureEventOutbox outbox;
|
||||
private final Clock clock;
|
||||
|
||||
public NutritionEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Clock clock, Path data, Consumer<Throwable> failures) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
outbox = new FeatureEventOutbox(data, "nutrition-event-intents",
|
||||
Set.of("nutrition.suspended", "nutrition.restored"), sink, failures);
|
||||
}
|
||||
|
||||
public void replay() { outbox.replay(); }
|
||||
public CompletionStage<Void> drained() { return outbox.drained(); }
|
||||
@Override public void close() { outbox.close(); }
|
||||
|
||||
public Capture capture(Player player, UUID session, UUID character, String name, String reason) {
|
||||
try {
|
||||
var now = clock.instant();
|
||||
var where = location(player.getLocation(), now);
|
||||
return new Capture(new EventInput.Context(player.getUniqueId(), player.getName(),
|
||||
name == null ? null : character, name, name == null ? null : session, where),
|
||||
session, character, now, player.getFoodLevel(), player.getSaturation(), player.getExhaustion(), reason);
|
||||
} catch (RuntimeException ignored) { return null; }
|
||||
}
|
||||
|
||||
public void suspended(Player player, UUID session, CharacterRecord character, OrdinaryVitals ordinary,
|
||||
BigInteger receiptRevision) {
|
||||
if (ordinary == null || receiptRevision == null) return;
|
||||
var captured = capture(player, session, character.id(), character.name(), "entry");
|
||||
if (captured == null) return;
|
||||
var data = fields(captured, receiptRevision);
|
||||
data.put("ordinary_food", ordinary.food());
|
||||
data.put("ordinary_saturation", (double) ordinary.saturation());
|
||||
data.put("ordinary_exhaustion", (double) ordinary.exhaustion());
|
||||
data.put("fantasy_food", captured.food());
|
||||
data.put("fantasy_saturation", (double) captured.saturation());
|
||||
data.put("fantasy_exhaustion", (double) captured.exhaustion());
|
||||
data.put("outcome", "acknowledged");
|
||||
durable(captured, "nutrition.suspended", "suspended", data);
|
||||
}
|
||||
|
||||
public void restored(Capture before, Capture after, OrdinaryVitals ordinary, BigInteger receiptRevision) {
|
||||
if (before == null || after == null || ordinary == null || receiptRevision == null
|
||||
|| !before.session().equals(after.session())) return;
|
||||
var data = fields(before, receiptRevision);
|
||||
data.put("before_food", before.food());
|
||||
data.put("before_saturation", (double) before.saturation());
|
||||
data.put("before_exhaustion", (double) before.exhaustion());
|
||||
data.put("restored_food", after.food());
|
||||
data.put("restored_saturation", (double) after.saturation());
|
||||
data.put("restored_exhaustion", (double) after.exhaustion());
|
||||
data.put("outcome", "acknowledged");
|
||||
durable(after, "nutrition.restored", "restored", data);
|
||||
}
|
||||
|
||||
public void satiatedRefused(Player player, UUID session, UUID character, String name, double amount) {
|
||||
var captured = capture(player, session, character, name, "satiated_regeneration");
|
||||
if (captured == null) return;
|
||||
var action = UUID.randomUUID();
|
||||
for (var stage : new String[]{"observed", "resolved"}) {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("action_kind", "satiated_regeneration");
|
||||
data.put("session_id", session.toString());
|
||||
data.put("character_id", character.toString());
|
||||
data.put("requested_amount", amount);
|
||||
data.put("regain_source", "SATIATED");
|
||||
data.put("outcome", stage.equals("observed") ? "observed" : "cancelled");
|
||||
if (stage.equals("resolved")) data.put("reason", "nutrition_policy");
|
||||
try { sink.apply(new EventInput(id(action, stage), action, captured.time(),
|
||||
"action." + stage, captured.context(), data)); }
|
||||
catch (RuntimeException ignored) { /* Logging never changes a native cancellation. */ }
|
||||
}
|
||||
}
|
||||
|
||||
public void restorationRetained(Capture captured, String reason) {
|
||||
if (captured == null) return;
|
||||
var action = UUID.randomUUID();
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("action_kind", "nutrition_restore");
|
||||
data.put("session_id", captured.session().toString());
|
||||
data.put("character_id", captured.character().toString());
|
||||
data.put("outcome", "retained");
|
||||
data.put("reason", reason);
|
||||
try { sink.apply(new EventInput(id(action, "retained"), action, captured.time(),
|
||||
"action.resolved", captured.context(), data)); }
|
||||
catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
/** Native event alone is not success; a later observed health change proves an applied effect. */
|
||||
public void nativeHealed(Capture captured, double before, double after, String source) {
|
||||
if (captured == null || !Double.isFinite(before) || !Double.isFinite(after) || after <= before) return;
|
||||
var action = UUID.randomUUID();
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("session_id", captured.session().toString());
|
||||
data.put("character_id", captured.character().toString());
|
||||
data.put("regain_source", source);
|
||||
data.put("health_before", before);
|
||||
data.put("health_after", after);
|
||||
data.put("actual_amount", after - before);
|
||||
data.put("persistence_result", "pending_session_health_save");
|
||||
data.put("publication_result", "native_health_observed");
|
||||
data.put("outcome", "applied");
|
||||
try { sink.apply(new EventInput(id(action, "healed"), action, captured.time(),
|
||||
"combat.healed", captured.context(), data)); }
|
||||
catch (RuntimeException ignored) { /* An event never authorizes a health change. */ }
|
||||
}
|
||||
|
||||
private LinkedHashMap<String, Object> fields(Capture captured, BigInteger revision) {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("session_id", captured.session().toString());
|
||||
data.put("character_id", captured.character().toString());
|
||||
data.put("receipt_id", captured.session().toString());
|
||||
data.put("receipt_revision", revision.toString());
|
||||
data.put("reason", captured.reason());
|
||||
return data;
|
||||
}
|
||||
|
||||
private void durable(Capture captured, String type, String phase, LinkedHashMap<String, Object> data) {
|
||||
try { outbox.accepted(new EventInput(id(captured.session(), phase), captured.session(), captured.time(),
|
||||
type, captured.context(), data)); }
|
||||
catch (RuntimeException ignored) { /* Audit failure cannot change nutrition custody. */ }
|
||||
}
|
||||
|
||||
private EventInput.Location location(Location value, Instant now) {
|
||||
return value == null || value.getWorld() == null ? null
|
||||
: new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(), value.getX(), value.getY(),
|
||||
value.getZ(), now, "exact");
|
||||
}
|
||||
|
||||
private static UUID id(UUID session, String phase) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:nutrition:" + session + ':' + phase)
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
@@ -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,150 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.party.CombatSources;
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
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;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
|
||||
/** Captures native protection decisions without changing the native damage/event authority. */
|
||||
public final class PartyProtectionEventBridge {
|
||||
public record Action(UUID id, Instant observedAt, EventInput.Context actor, Map<String, Object> data) { }
|
||||
private final Consumer<EventInput> sink;
|
||||
private final Clock clock;
|
||||
|
||||
public PartyProtectionEventBridge(Consumer<EventInput> sink, Clock clock) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
}
|
||||
|
||||
public Action damage(EntityDamageEvent event, CombatSources.Origin origin,
|
||||
CombatSources.Origin recipient, Player target,
|
||||
PartyRoster.Party sourceParty, PartyRoster.Party targetParty, String reason) {
|
||||
try {
|
||||
var id = UUID.randomUUID();
|
||||
var time = clock.instant();
|
||||
Entity causing = event.getDamageSource().getCausingEntity();
|
||||
Entity direct = event.getDamageSource().getDirectEntity();
|
||||
var source = causing instanceof Player player && player.getUniqueId().equals(origin.player()) ? player : null;
|
||||
var context = source == null ? null : new EventInput.Context(source.getUniqueId(), source.getName(),
|
||||
null, null, null, location(source.getLocation()));
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("action_kind", "party_damage_protection");
|
||||
if (context == null) data.put("actor_kind", "system");
|
||||
data.put("damage_cause", event.getCause().name().toLowerCase(java.util.Locale.ROOT));
|
||||
data.put("source_kind", causing == null ? "provenance" : causing instanceof Player ? "player"
|
||||
: causing.getType() == null ? "unavailable" : causing.getType().name().toLowerCase(java.util.Locale.ROOT));
|
||||
data.put("source_player_id", origin.player().toString());
|
||||
if (causing != null && causing.getUniqueId() != null)
|
||||
data.put("causing_entity_id", causing.getUniqueId().toString());
|
||||
if (direct != null && direct.getUniqueId() != null)
|
||||
data.put("direct_entity_id", direct.getUniqueId().toString());
|
||||
if (origin.character() != null) data.put("source_character_id", origin.character().toString());
|
||||
if (origin.session() != null) data.put("source_session_id", origin.session().toString());
|
||||
data.put("target_player_id", target.getUniqueId().toString());
|
||||
if (recipient.character() != null) data.put("target_character_id", recipient.character().toString());
|
||||
if (recipient.session() != null) data.put("target_session_id", recipient.session().toString());
|
||||
if (sourceParty != null) data.put("source_party_id", sourceParty.id().toString());
|
||||
if (targetParty != null) data.put("target_party_id", targetParty.id().toString());
|
||||
data.put("provenance_quality", origin.session() == null ? "ordinary_or_unproved"
|
||||
: reason.equals("stale_source") ? "claimed_session_unverified" : "matching_session_id");
|
||||
data.put("provenance_age_status", "unavailable");
|
||||
data.put("requested_amount", event.getDamage());
|
||||
data.put("prior_cancelled", event.isCancelled());
|
||||
data.put("proposed_reason", reason);
|
||||
var sourceLocation = causing == null ? null : location(causing.getLocation());
|
||||
var targetLocation = location(target.getLocation());
|
||||
if (sourceLocation != null) data.put("source_location", coordinates(sourceLocation));
|
||||
else data.put("source_location_status", "unavailable");
|
||||
if (targetLocation != null) data.put("target_location", coordinates(targetLocation));
|
||||
else data.put("target_location_status", "unavailable");
|
||||
var action = new Action(id, time, context, Map.copyOf(data));
|
||||
var observed = new LinkedHashMap<String, Object>(data);
|
||||
observed.put("outcome", "observed");
|
||||
emit(action, "action.observed", "observed", observed);
|
||||
return action;
|
||||
} catch (RuntimeException ignored) { return null; }
|
||||
}
|
||||
|
||||
public Action nativeAttempt(String kind, Entity causing, Player target, CombatSources.Origin origin,
|
||||
CombatSources.Origin recipient, PartyRoster.Party sourceParty,
|
||||
PartyRoster.Party targetParty, String reason, String nativeKind) {
|
||||
try {
|
||||
var id = UUID.randomUUID();
|
||||
var now = clock.instant();
|
||||
var source = causing instanceof Player player && player.getUniqueId().equals(origin.player()) ? player : null;
|
||||
var context = source == null ? null : new EventInput.Context(source.getUniqueId(), source.getName(),
|
||||
null, null, null, location(source.getLocation()));
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("action_kind", kind);
|
||||
if (context == null) data.put("actor_kind", "system");
|
||||
data.put("native_kind", nativeKind);
|
||||
data.put("source_player_id", origin.player().toString());
|
||||
if (causing != null && causing.getUniqueId() != null)
|
||||
data.put("direct_entity_id", causing.getUniqueId().toString());
|
||||
if (origin.character() != null) data.put("source_character_id", origin.character().toString());
|
||||
if (origin.session() != null) data.put("source_session_id", origin.session().toString());
|
||||
if (recipient != null) {
|
||||
data.put("target_player_id", recipient.player().toString());
|
||||
if (recipient.character() != null) data.put("target_character_id", recipient.character().toString());
|
||||
if (recipient.session() != null) data.put("target_session_id", recipient.session().toString());
|
||||
} else data.put("target_status", "not_single_proved_player");
|
||||
if (sourceParty != null) data.put("source_party_id", sourceParty.id().toString());
|
||||
if (targetParty != null) data.put("target_party_id", targetParty.id().toString());
|
||||
data.put("provenance_quality", origin.session() == null ? "ordinary_or_unproved"
|
||||
: reason.equals("stale_source") ? "claimed_session_unverified" : "matching_session_id");
|
||||
data.put("provenance_age_status", "unavailable");
|
||||
data.put("requested_amount_status", "not_applicable");
|
||||
data.put("prior_cancelled", false);
|
||||
var sourceLocation = causing == null ? null : location(causing.getLocation());
|
||||
var targetLocation = target == null ? null : location(target.getLocation());
|
||||
if (sourceLocation != null) data.put("source_location", coordinates(sourceLocation));
|
||||
else data.put("source_location_status", "unavailable");
|
||||
if (targetLocation != null) data.put("target_location", coordinates(targetLocation));
|
||||
else data.put("target_location_status", "unavailable");
|
||||
var action = new Action(id, now, context, Map.copyOf(data));
|
||||
var observed = new LinkedHashMap<String, Object>(data);
|
||||
observed.put("outcome", "observed");
|
||||
emit(action, "action.observed", "observed", observed);
|
||||
return action;
|
||||
} catch (RuntimeException ignored) { return null; }
|
||||
}
|
||||
|
||||
public void resolved(Action action, String outcome, String reason, boolean cancelled, Double finalAmount) {
|
||||
if (action == null) return;
|
||||
var values = new LinkedHashMap<String, Object>(action.data());
|
||||
values.put("outcome", outcome);
|
||||
values.put("reason", reason);
|
||||
values.put("final_cancelled", cancelled);
|
||||
if (finalAmount != null) values.put("final_amount", finalAmount);
|
||||
else values.put("final_amount_status", "not_applicable");
|
||||
emit(action, "action.resolved", "resolved", values);
|
||||
}
|
||||
|
||||
private EventInput.Location location(Location value) {
|
||||
return value == null || value.getWorld() == null ? null
|
||||
: new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(), value.getX(),
|
||||
value.getY(), value.getZ(), clock.instant(), "exact");
|
||||
}
|
||||
|
||||
private Map<String, Object> coordinates(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());
|
||||
}
|
||||
|
||||
private void emit(Action action, String type, String stage, Map<String, Object> data) {
|
||||
try { sink.accept(new EventInput(UUID.nameUUIDFromBytes(("fantasystick:party-protection:" + action.id()
|
||||
+ ':' + stage).getBytes(StandardCharsets.UTF_8)), action.id(),
|
||||
stage.equals("observed") ? action.observedAt() : clock.instant(), type, action.actor(), 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,223 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Properties;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** An acknowledged native discovery has one recoverable, account-keyed event intent. */
|
||||
public final class RecipeDiscoveryEventBridge implements AutoCloseable {
|
||||
private static final String RECIPE = "fantasystick:character_sheet";
|
||||
private static final int MAX_BYTES = 16_384;
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final Clock clock;
|
||||
private final Path pending;
|
||||
private final Consumer<Throwable> failures;
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor(task ->
|
||||
new Thread(task, "fantasy-recipe-discovery-events"));
|
||||
|
||||
public RecipeDiscoveryEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Clock clock, Path dataFolder, Consumer<Throwable> failures) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.pending = Objects.requireNonNull(dataFolder).toAbsolutePath().resolve("recipe-event-intents");
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
public void discovered(Player player, String trigger, int notifications) {
|
||||
try {
|
||||
var record = capture(player, trigger, notifications > 0);
|
||||
worker.execute(() -> {
|
||||
try { publish(write(record)); }
|
||||
catch (IOException | RuntimeException failure) { report(failure); }
|
||||
});
|
||||
} catch (RuntimeException failure) { report(failure); }
|
||||
}
|
||||
|
||||
/** Called only after the shared writer's startup recovery is ready. */
|
||||
public void replay() {
|
||||
try {
|
||||
worker.execute(() -> {
|
||||
try {
|
||||
if (!Files.exists(pending, LinkOption.NOFOLLOW_LINKS)) return;
|
||||
if (!Files.isDirectory(pending, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe recipe event intents");
|
||||
try (var entries = Files.list(pending)) {
|
||||
for (var path : entries.filter(p -> p.getFileName().toString().endsWith(".pending")).sorted().toList()) {
|
||||
try { publish(read(path)); }
|
||||
catch (IOException | RuntimeException failure) { report(failure); }
|
||||
}
|
||||
}
|
||||
} catch (IOException | RuntimeException failure) { report(failure); }
|
||||
});
|
||||
} catch (RuntimeException failure) { report(failure); }
|
||||
}
|
||||
|
||||
public void failed(Player player, String trigger, String reason) {
|
||||
try {
|
||||
var action = UUID.randomUUID();
|
||||
var context = context(player);
|
||||
emit(new EventInput(UUID.nameUUIDFromBytes(("fantasystick:recipe:" + action + ":failed")
|
||||
.getBytes(StandardCharsets.UTF_8)), action, clock.instant(), "action.resolved", context,
|
||||
Map.of("action_kind", "recipe_discovery", "recipe_id", RECIPE, "trigger", trigger,
|
||||
"outcome", "failed", "reason", reason)));
|
||||
} catch (RuntimeException failure) { report(failure); }
|
||||
}
|
||||
|
||||
private record Record(UUID account, UUID action, Instant time, String name, String trigger,
|
||||
boolean notification, EventInput.Location location) { }
|
||||
|
||||
private Record capture(Player player, String trigger, boolean notification) {
|
||||
var context = context(player);
|
||||
return new Record(context.playerId(), id(context.playerId()), clock.instant(), context.playerName(),
|
||||
trigger, notification, context.location());
|
||||
}
|
||||
|
||||
private EventInput.Context context(Player player) {
|
||||
var location = player.getLocation();
|
||||
EventInput.Location where = location == null || location.getWorld() == null ? null
|
||||
: new EventInput.Location(location.getWorld().getUID(), location.getWorld().getName(),
|
||||
location.getX(), location.getY(), location.getZ(), clock.instant(), "exact");
|
||||
return new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null, where);
|
||||
}
|
||||
|
||||
private void publish(Record record) {
|
||||
var fields = new LinkedHashMap<String, Object>();
|
||||
fields.put("action_kind", "recipe_discovery");
|
||||
fields.put("recipe_id", RECIPE);
|
||||
fields.put("recipe_category", "misc");
|
||||
fields.put("trigger", record.trigger());
|
||||
fields.put("outcome", "acknowledged");
|
||||
fields.put("first_discovery_notification", record.notification());
|
||||
var input = new EventInput(UUID.nameUUIDFromBytes(("fantasystick:recipe:" + record.action()
|
||||
+ ":recipe.discovered").getBytes(StandardCharsets.UTF_8)), record.action(), record.time(),
|
||||
"recipe.discovered", new EventInput.Context(record.account(), record.name(), null, null, null,
|
||||
record.location()), fields);
|
||||
emit(input).whenComplete((delivery, failure) -> {
|
||||
if (failure != null) { report(failure); return; }
|
||||
if (delivery == LocalEventLog.Delivery.REJECTED) return;
|
||||
try {
|
||||
if (Files.deleteIfExists(file(record.account()))) forceDirectory();
|
||||
} catch (IOException deleteFailure) { report(deleteFailure); }
|
||||
});
|
||||
}
|
||||
|
||||
private CompletionStage<LocalEventLog.Delivery> emit(EventInput input) {
|
||||
try { return sink.apply(input); }
|
||||
catch (RuntimeException failure) {
|
||||
report(failure);
|
||||
return java.util.concurrent.CompletableFuture.completedStage(LocalEventLog.Delivery.REJECTED);
|
||||
}
|
||||
}
|
||||
|
||||
private Path file(UUID account) { return pending.resolve(account + ".pending"); }
|
||||
|
||||
private Record write(Record record) throws IOException {
|
||||
if (Files.exists(pending, LinkOption.NOFOLLOW_LINKS)
|
||||
&& !Files.isDirectory(pending, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe recipe event intents");
|
||||
Files.createDirectories(pending);
|
||||
Path target = file(record.account());
|
||||
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe recipe event intent");
|
||||
return read(target); // Preserve the first native acknowledgment and its original identity.
|
||||
}
|
||||
var values = new Properties();
|
||||
values.setProperty("account", record.account().toString());
|
||||
values.setProperty("action", record.action().toString());
|
||||
values.setProperty("time", record.time().toString());
|
||||
values.setProperty("name", record.name());
|
||||
values.setProperty("trigger", record.trigger());
|
||||
values.setProperty("notification", Boolean.toString(record.notification()));
|
||||
if (record.location() != null) {
|
||||
var location = record.location();
|
||||
values.setProperty("world", location.worldId().toString());
|
||||
values.setProperty("world_name", location.worldName());
|
||||
values.setProperty("x", Double.toString(location.x()));
|
||||
values.setProperty("y", Double.toString(location.y()));
|
||||
values.setProperty("z", Double.toString(location.z()));
|
||||
values.setProperty("location_time", location.capturedAt().toString());
|
||||
}
|
||||
var output = new ByteArrayOutputStream();
|
||||
values.store(output, "acknowledged native recipe discovery");
|
||||
var bytes = output.toByteArray();
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Recipe event intent too large");
|
||||
Path temporary = Files.createTempFile(pending, ".recipe-", ".tmp");
|
||||
try {
|
||||
try (var channel = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var buffer = ByteBuffer.wrap(bytes);
|
||||
while (buffer.hasRemaining()) channel.write(buffer);
|
||||
channel.force(true);
|
||||
}
|
||||
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
forceDirectory();
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
return record;
|
||||
}
|
||||
|
||||
private void forceDirectory() throws IOException {
|
||||
try (var channel = FileChannel.open(pending, StandardOpenOption.READ)) { channel.force(true); }
|
||||
}
|
||||
|
||||
private Record read(Path path) throws IOException {
|
||||
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe recipe event intent");
|
||||
var values = new Properties();
|
||||
try (var input = Files.newInputStream(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var bytes = input.readNBytes(MAX_BYTES + 1);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Recipe event intent too large");
|
||||
values.load(new java.io.ByteArrayInputStream(bytes));
|
||||
}
|
||||
try {
|
||||
var account = UUID.fromString(values.getProperty("account"));
|
||||
var action = UUID.fromString(values.getProperty("action"));
|
||||
if (!path.equals(file(account)) || !action.equals(id(account))) throw new IOException("Invalid recipe event identity");
|
||||
EventInput.Location location = values.containsKey("world")
|
||||
? new EventInput.Location(UUID.fromString(values.getProperty("world")), values.getProperty("world_name"),
|
||||
Double.parseDouble(values.getProperty("x")), Double.parseDouble(values.getProperty("y")),
|
||||
Double.parseDouble(values.getProperty("z")), Instant.parse(values.getProperty("location_time")), "exact")
|
||||
: null;
|
||||
var trigger = values.getProperty("trigger");
|
||||
if (!"join".equals(trigger) && !"enable_online".equals(trigger)) throw new IOException("Invalid recipe event trigger");
|
||||
var notification = values.getProperty("notification");
|
||||
if (!"true".equals(notification) && !"false".equals(notification)) throw new IOException("Invalid recipe notification");
|
||||
return new Record(account, action, Instant.parse(values.getProperty("time")),
|
||||
Objects.requireNonNull(values.getProperty("name")), trigger, Boolean.parseBoolean(notification), location);
|
||||
} catch (RuntimeException invalid) { throw new IOException("Invalid recipe event intent", invalid); }
|
||||
}
|
||||
|
||||
public static UUID id(UUID player) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:recipe:character_sheet:" + player)
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private void report(Throwable failure) {
|
||||
try { failures.accept(failure); } catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
worker.shutdown();
|
||||
try {
|
||||
if (!worker.awaitTermination(5, TimeUnit.SECONDS)) report(new IOException("Recipe event intents did not drain"));
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt(); report(interrupted);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(action.context().location()==null)data.put("location_status","unavailable");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){EventInput.Location captured;try{captured=location(player.getLocation());}catch(RuntimeException unavailable){captured=null;}return new EventInput.Context(player.getUniqueId(),player.getName(),active.character(),name,active.session(),captured);}
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.AdventureSettings;
|
||||
import games.dmg.fantasystick.persistence.EquipmentSessionStore;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Captured administrative/session recovery observations; never authorizes native recovery. */
|
||||
public final class SessionRecoveryEventBridge implements AutoCloseable {
|
||||
public record Action(UUID id, Instant observedAt, EventInput.Context context, String actorKind,
|
||||
String locationStatus, UUID target) { }
|
||||
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final FeatureEventOutbox outbox;
|
||||
private final Consumer<Throwable> failures;
|
||||
private final Clock clock;
|
||||
|
||||
public SessionRecoveryEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Clock clock, Path data, Consumer<Throwable> failures) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
outbox = new FeatureEventOutbox(data, "session-recovery-event-intents",
|
||||
Set.of("recovery.resolved", "config.changed"), sink, failures);
|
||||
}
|
||||
|
||||
public void replay() { outbox.replay(); }
|
||||
public CompletionStage<Void> drained() { return outbox.drained(); }
|
||||
@Override public void close() { outbox.close(); }
|
||||
|
||||
public Action capture(CommandSender sender, UUID target) {
|
||||
try {
|
||||
var now = clock.instant();
|
||||
if (sender instanceof Player player) {
|
||||
var location = player.getLocation();
|
||||
EventInput.Location origin = location == null || location.getWorld() == null ? null
|
||||
: new EventInput.Location(location.getWorld().getUID(), location.getWorld().getName(),
|
||||
location.getX(), location.getY(), location.getZ(), now, "exact");
|
||||
return new Action(UUID.randomUUID(), now,
|
||||
new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null, origin),
|
||||
"player", origin == null ? "unavailable" : null, target);
|
||||
}
|
||||
return new Action(UUID.randomUUID(), now, null,
|
||||
sender instanceof org.bukkit.command.BlockCommandSender ? "command_block" : "console",
|
||||
"not_applicable", target);
|
||||
} catch (RuntimeException ignored) { return null; }
|
||||
}
|
||||
|
||||
public void observed(Action action, String kind) {
|
||||
if (action == null) return;
|
||||
var data = fields(action);
|
||||
data.put("action_kind", kind);
|
||||
data.put("outcome", "observed");
|
||||
emit(action, "action.observed", "observed", data);
|
||||
}
|
||||
|
||||
public void resolved(Action action, String kind, String outcome, String reason) {
|
||||
if (action == null) return;
|
||||
var data = fields(action);
|
||||
if (outcome.equals("refused")) data.remove("target_player_id");
|
||||
data.put("action_kind", kind);
|
||||
data.put("outcome", outcome);
|
||||
data.put("reason", reason);
|
||||
emit(action, "action.resolved", "resolved:" + outcome + ':' + reason, data);
|
||||
}
|
||||
|
||||
public void inspected(Action action, Optional<EquipmentSessionStore.State> state,
|
||||
String nativePhase, boolean online) {
|
||||
if (action == null) return;
|
||||
var data = fields(action);
|
||||
data.put("action_kind", "equipment_inspect");
|
||||
data.put("outcome", "authorized");
|
||||
var saved = state.orElse(null);
|
||||
var receipt = saved == null ? null : saved.pending();
|
||||
data.put("receipt_phase", receipt != null ? "pending" : saved == null ? "absent" : "completed");
|
||||
if (saved != null) data.put("receipt_revision", saved.revision().toString());
|
||||
if (receipt != null) {
|
||||
data.put("session_id", receipt.sessionId().toString());
|
||||
data.put("character_id", receipt.characterId().toString());
|
||||
}
|
||||
data.put("native_phase", online ? nativePhase : "offline_unavailable");
|
||||
data.put("custody_quality", online ? "receipt_and_native_observation" : "receipt_only");
|
||||
emit(action, "recovery.inspected", "inspected", data);
|
||||
}
|
||||
|
||||
/** One result per proof-based reconciliation attempt, distinct from native equipment custody. */
|
||||
public void reconciled(Action action, UUID session, UUID character, String priorPhase,
|
||||
String currentPhase, String outcome, String quality, String reason,
|
||||
java.math.BigInteger priorRevision, java.math.BigInteger afterRevision,
|
||||
UUID equipmentOperation, EventInput.Location original, EventInput.Location current) {
|
||||
if (action == null) return;
|
||||
var data = fields(action);
|
||||
data.put("recovery_operation_id", action.id().toString());
|
||||
if (session != null) data.put("session_id", session.toString());
|
||||
if (character != null) data.put("character_id", character.toString());
|
||||
data.put("prior_phase", priorPhase);
|
||||
data.put("current_phase", currentPhase);
|
||||
data.put("custody_quality", quality);
|
||||
data.put("outcome", outcome);
|
||||
data.put("reason", reason);
|
||||
if (priorRevision != null) data.put("prior_receipt_revision", priorRevision.toString());
|
||||
if (afterRevision != null) data.put("receipt_revision", afterRevision.toString());
|
||||
if (equipmentOperation != null) data.put("equipment_recovery_operation_id", equipmentOperation.toString());
|
||||
nativeLocation(data, "original", original);
|
||||
nativeLocation(data, "current", current);
|
||||
data.put("changed_components", outcome.equals("restored") && quality.equals("verified_box_and_marker")
|
||||
? "verified_native_equipment" : quality.equals("native_restored_receipt_pending")
|
||||
? "native_equipment_unacknowledged" : "not_independently_verified");
|
||||
emit(action, "recovery.resolved", "reconciled", data);
|
||||
if (!action.actorKind().equals("system")) resolved(action, "equipment_recovery", outcome, reason);
|
||||
}
|
||||
|
||||
/** Must be called on the native thread before any asynchronous receipt acknowledgment. */
|
||||
public EventInput.Location captureLocation(Location value) {
|
||||
if (value == null || value.getWorld() == null) return null;
|
||||
return new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(),
|
||||
value.getX(), value.getY(), value.getZ(), clock.instant(), "exact");
|
||||
}
|
||||
|
||||
private void nativeLocation(LinkedHashMap<String, Object> data, String name, EventInput.Location location) {
|
||||
if (location == null) {
|
||||
data.put(name + "_location_status", "unavailable");
|
||||
return;
|
||||
}
|
||||
data.put(name + "_world_id", location.worldId().toString());
|
||||
data.put(name + "_world_name", location.worldName());
|
||||
data.put(name + "_x", location.x());
|
||||
data.put(name + "_y", location.y());
|
||||
data.put(name + "_z", location.z());
|
||||
data.put(name + "_captured_at", location.capturedAt().toString());
|
||||
data.put(name + "_location_status", name.equals("original") ? "captured_at_entry" : "captured_at_reconciliation");
|
||||
}
|
||||
|
||||
public Action system(Player player) {
|
||||
try {
|
||||
var now = clock.instant();
|
||||
var location = player.getLocation();
|
||||
var action = new Action(UUID.randomUUID(), now, null, "system",
|
||||
location == null || location.getWorld() == null ? "unavailable" : "captured_current_only",
|
||||
player.getUniqueId());
|
||||
return action;
|
||||
} catch (RuntimeException ignored) { return null; }
|
||||
}
|
||||
|
||||
public void configRead(Action action, boolean effective, Optional<String> revision) {
|
||||
if (action == null) return;
|
||||
var data = fields(action);
|
||||
data.put("key", "adventure-mode");
|
||||
data.put("effective_value", Boolean.toString(effective));
|
||||
if (revision.isPresent()) data.put("persistence_revision", revision.orElseThrow());
|
||||
else data.put("persistence_revision_status", "not_verified_at_read");
|
||||
data.put("outcome", "authorized");
|
||||
emit(action, "config.read", "read", data);
|
||||
}
|
||||
|
||||
public void configChanged(Action action, AdventureSettings.Saved saved) {
|
||||
if (action == null || saved == null) return;
|
||||
var data = fields(action);
|
||||
data.put("key", "adventure-mode");
|
||||
data.put("before_value", Boolean.toString(saved.beforeValue()));
|
||||
data.put("after_value", Boolean.toString(saved.value()));
|
||||
data.put("effective_value", Boolean.toString(saved.value()));
|
||||
data.put("persistence_revision", saved.revision());
|
||||
data.put("application", "future_sessions");
|
||||
data.put("outcome", "acknowledged");
|
||||
emit(action, "config.changed", "changed", data);
|
||||
}
|
||||
|
||||
private LinkedHashMap<String, Object> fields(Action action) {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("actor_kind", action.actorKind());
|
||||
if (action.locationStatus() != null) data.put("location_status", action.locationStatus());
|
||||
if (action.target() != null) data.put("target_player_id", action.target().toString());
|
||||
return data;
|
||||
}
|
||||
|
||||
private void emit(Action action, String type, String stage, LinkedHashMap<String, Object> data) {
|
||||
try {
|
||||
var event = new EventInput(UUID.nameUUIDFromBytes(("fantasystick:session-recovery:" + action.id()
|
||||
+ ':' + stage).getBytes(StandardCharsets.UTF_8)), action.id(),
|
||||
stage.equals("observed") ? action.observedAt() : clock.instant(), type, action.context(), data);
|
||||
if (type.equals("config.changed") || type.equals("recovery.resolved")) outbox.accepted(event);
|
||||
else sink.apply(event);
|
||||
}
|
||||
catch (RuntimeException failure) { try { failures.accept(failure); } catch (RuntimeException ignored) { } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.SwitchDelaySettings;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.command.BlockCommandSender;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Observes the registered administrator's acknowledged switch policy; never changes deadlines. */
|
||||
public final class SwitchConfigEventBridge implements AutoCloseable {
|
||||
public record Actor(String kind, EventInput.Context context) { }
|
||||
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final Clock clock;
|
||||
private final EquipmentSessions sessions;
|
||||
private final FeatureEventOutbox outbox;
|
||||
|
||||
public SwitchConfigEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Clock clock, Path data, EquipmentSessions sessions,
|
||||
Consumer<Throwable> failures) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
outbox = new FeatureEventOutbox(data, "switch-config-event-intents", java.util.Set.of("config.changed"),
|
||||
sink, failures);
|
||||
}
|
||||
|
||||
public Actor actor(CommandSender sender) {
|
||||
try {
|
||||
var player = sender instanceof Player value ? value : null;
|
||||
var kind = player != null ? "player" : sender instanceof BlockCommandSender ? "command_block" : "console";
|
||||
var active = player == null ? null : sessions.active(player.getUniqueId()).orElse(null);
|
||||
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
|
||||
return new Actor(kind, player == null ? null : new EventInput.Context(player.getUniqueId(),
|
||||
player.getName(), record == null ? null : record.id(),
|
||||
record == null ? null : record.name(), record == null ? null : active.session(),
|
||||
safeLocation(player)));
|
||||
} catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
public void read(Actor actor, String effective) {
|
||||
if (actor == null) return;
|
||||
var data = common(actor);
|
||||
data.put("effective", effective);
|
||||
data.put("outcome", "acknowledged_read");
|
||||
var action = UUID.randomUUID();
|
||||
try { sink.apply(new EventInput(id(action, "config.read"), action, clock.instant(),
|
||||
"config.read", actor.context(), data)); }
|
||||
catch (RuntimeException ignored) { /* Observation cannot change the policy. */ }
|
||||
}
|
||||
|
||||
public void changed(Actor actor, UUID operation, String before, String effective,
|
||||
SwitchDelaySettings.Update saved) {
|
||||
if (actor == null) return;
|
||||
try {
|
||||
var data = common(actor);
|
||||
data.put("operation_id", operation.toString());
|
||||
data.put("store_sequence", Long.toString(saved.sequence()));
|
||||
data.put("before", before);
|
||||
data.put("after", saved.value());
|
||||
data.put("effective", effective);
|
||||
data.put("zero_disabled", saved.delay().isZero());
|
||||
data.put("outcome", "acknowledged");
|
||||
outbox.accepted(new EventInput(id(operation, "config.changed"), operation, clock.instant(),
|
||||
"config.changed", actor.context(), data));
|
||||
} catch (RuntimeException ignored) { /* A failed audit cannot roll back an effective setting. */ }
|
||||
}
|
||||
|
||||
private static Map<String, Object> common(Actor actor) {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("key", SwitchDelaySettings.KEY);
|
||||
data.put("default", SwitchDelaySettings.DEFAULT);
|
||||
data.put("application_scope", "future_exit");
|
||||
data.put("existing_deadlines_unchanged", true);
|
||||
data.put("actor_kind", actor.kind());
|
||||
if (actor.context() == null) data.put("location_status", "not_applicable");
|
||||
else if (actor.context().location() == null) data.put("location_status", "unavailable");
|
||||
return data;
|
||||
}
|
||||
|
||||
private EventInput.Location safeLocation(Player player) {
|
||||
try {
|
||||
Location location = player.getLocation();
|
||||
if (location == null || location.getWorld() == null) return null;
|
||||
var world = location.getWorld();
|
||||
return new EventInput.Location(world.getUID(), world.getName(), location.getX(),
|
||||
location.getY(), location.getZ(), clock.instant(), "exact");
|
||||
} catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
private static UUID id(UUID action, String kind) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:switch-config:" + action + ":" + kind)
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
public void replay() { outbox.replay(); }
|
||||
public CompletionStage<Void> drained() { return outbox.drained(); }
|
||||
@Override public void close() { outbox.close(); }
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.EquipmentSessionStore;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Objects;
|
||||
import java.util.Properties;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Observes persisted switch deadlines and pre-pack admission without authorizing either. */
|
||||
public final class SwitchDelayEventBridge implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 16_384;
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final Clock clock;
|
||||
private final Path pending;
|
||||
private final Consumer<Throwable> failures;
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor(task ->
|
||||
new Thread(task, "fantasy-switch-delay-events"));
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public SwitchDelayEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink, Clock clock,
|
||||
Path dataFolder, Consumer<Throwable> failures) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.pending = Objects.requireNonNull(dataFolder).toAbsolutePath().resolve("switch-event-intents");
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
public record ExitContext(UUID player, String playerName, UUID character, String characterName,
|
||||
UUID session, EventInput.Location location, String reason) { }
|
||||
|
||||
public ExitContext exit(Player player, UUID session, CharacterRecord character, String reason) {
|
||||
try {
|
||||
return new ExitContext(player.getUniqueId(), player.getName(), character.id(), character.name(),
|
||||
session, location(player.getLocation()), reason);
|
||||
} catch (RuntimeException ignored) { return null; }
|
||||
}
|
||||
|
||||
private record SavedStart(ExitContext capture, EquipmentSessionStore.Exit exit,
|
||||
String revisionBefore, String revisionAfter, Instant time) { }
|
||||
|
||||
public void started(ExitContext captured, EquipmentSessionStore.State saved) {
|
||||
if (captured == null || saved == null || saved.pending() != null || saved.lastExit() == null
|
||||
|| !captured.session().equals(saved.lastExit().sessionId())) return;
|
||||
var start = new SavedStart(captured, saved.lastExit(),
|
||||
saved.revision().subtract(java.math.BigInteger.ONE).toString(), saved.revision().toString(),
|
||||
saved.lastExit().exitedAt());
|
||||
try { worker.execute(() -> {
|
||||
try { publish(write(start)); }
|
||||
catch (IOException | RuntimeException failure) { report(failure); }
|
||||
}); } catch (RuntimeException rejected) { report(rejected); }
|
||||
}
|
||||
|
||||
private void publish(SavedStart start) {
|
||||
var captured = start.capture();
|
||||
var exit = start.exit();
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
values.put("outcome", "acknowledged");
|
||||
values.put("exit_reason", captured.reason());
|
||||
values.put("exit_operation_id", captured.session().toString());
|
||||
values.put("character_id", exit.characterId().toString());
|
||||
values.put("session_id", exit.sessionId().toString());
|
||||
values.put("duration_seconds", Long.toString(Duration.between(exit.exitedAt(), exit.deadline()).getSeconds()));
|
||||
values.put("exited_at", exit.exitedAt().toString());
|
||||
values.put("deadline", exit.deadline().toString());
|
||||
values.put("revision_before", start.revisionBefore());
|
||||
values.put("revision_after", start.revisionAfter());
|
||||
values.put("zero_duration", exit.deadline().equals(exit.exitedAt()));
|
||||
var input = new EventInput(id(captured.session(), "started"), captured.session(), start.time(),
|
||||
"character.switch_delay_started", new EventInput.Context(captured.player(), captured.playerName(),
|
||||
captured.character(), captured.characterName(), captured.session(), captured.location()), values);
|
||||
try { sink.apply(input).whenComplete((delivery, failure) -> {
|
||||
if (failure != null) { report(failure); return; }
|
||||
if (delivery == LocalEventLog.Delivery.REJECTED) return;
|
||||
try { if (Files.deleteIfExists(file(captured.session()))) forceDirectory(); }
|
||||
catch (IOException error) { report(error); }
|
||||
}); } catch (RuntimeException failure) { report(failure); }
|
||||
}
|
||||
|
||||
/** Replay only persisted, previously acknowledged exits, before any new native session observations. */
|
||||
public void replay() {
|
||||
if (worker.isShutdown()) return;
|
||||
try { worker.execute(() -> {
|
||||
try {
|
||||
if (!Files.exists(pending, LinkOption.NOFOLLOW_LINKS)) return;
|
||||
if (!Files.isDirectory(pending, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe switch event intents");
|
||||
try (var entries = Files.list(pending)) {
|
||||
for (var path : entries.filter(p -> p.getFileName().toString().endsWith(".pending")).sorted().toList()) {
|
||||
try { publish(read(path)); }
|
||||
catch (IOException | RuntimeException failure) { report(failure); }
|
||||
}
|
||||
}
|
||||
} catch (IOException | RuntimeException failure) { report(failure); }
|
||||
}); } catch (RuntimeException rejected) { report(rejected); }
|
||||
}
|
||||
|
||||
private Path file(UUID session) { return pending.resolve(session + ".pending"); }
|
||||
|
||||
private SavedStart write(SavedStart value) throws IOException {
|
||||
if (Files.exists(pending, LinkOption.NOFOLLOW_LINKS)
|
||||
&& !Files.isDirectory(pending, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe switch event intents");
|
||||
Files.createDirectories(pending);
|
||||
Path target = file(value.capture().session());
|
||||
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) return read(target);
|
||||
var fields = new Properties();
|
||||
var capture = value.capture(); var exit = value.exit();
|
||||
fields.setProperty("player", capture.player().toString());
|
||||
fields.setProperty("player_name", capture.playerName());
|
||||
fields.setProperty("character", capture.character().toString());
|
||||
fields.setProperty("character_name", capture.characterName());
|
||||
fields.setProperty("session", capture.session().toString());
|
||||
fields.setProperty("reason", capture.reason());
|
||||
fields.setProperty("exited_at", exit.exitedAt().toString());
|
||||
fields.setProperty("deadline", exit.deadline().toString());
|
||||
fields.setProperty("revision_before", value.revisionBefore());
|
||||
fields.setProperty("revision_after", value.revisionAfter());
|
||||
fields.setProperty("time", value.time().toString());
|
||||
if (capture.location() != null) {
|
||||
var location = capture.location();
|
||||
fields.setProperty("world", location.worldId().toString());
|
||||
fields.setProperty("world_name", location.worldName());
|
||||
fields.setProperty("x", Double.toString(location.x()));
|
||||
fields.setProperty("y", Double.toString(location.y()));
|
||||
fields.setProperty("z", Double.toString(location.z()));
|
||||
fields.setProperty("location_time", location.capturedAt().toString());
|
||||
}
|
||||
var output = new ByteArrayOutputStream();
|
||||
fields.store(output, "acknowledged switch deadline");
|
||||
byte[] bytes = output.toByteArray();
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Switch event intent too large");
|
||||
Path temporary = Files.createTempFile(pending, ".switch-", ".tmp");
|
||||
try {
|
||||
try (var channel = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var buffer = ByteBuffer.wrap(bytes);
|
||||
while (buffer.hasRemaining()) channel.write(buffer);
|
||||
channel.force(true);
|
||||
}
|
||||
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
forceDirectory();
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
return value;
|
||||
}
|
||||
|
||||
private void forceDirectory() throws IOException {
|
||||
try (var channel = FileChannel.open(pending, StandardOpenOption.READ)) { channel.force(true); }
|
||||
}
|
||||
|
||||
private SavedStart read(Path path) throws IOException {
|
||||
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe switch event intent");
|
||||
var fields = new Properties();
|
||||
try (var input = Files.newInputStream(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var bytes = input.readNBytes(MAX_BYTES + 1);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Switch event intent too large");
|
||||
fields.load(new java.io.ByteArrayInputStream(bytes));
|
||||
}
|
||||
try {
|
||||
var player = UUID.fromString(fields.getProperty("player"));
|
||||
var character = UUID.fromString(fields.getProperty("character"));
|
||||
var session = UUID.fromString(fields.getProperty("session"));
|
||||
if (!path.equals(file(session))) throw new IOException("Switch event identity mismatch");
|
||||
var reason = fields.getProperty("reason");
|
||||
if (!java.util.Set.of("voluntary", "logout", "defeat", "shutdown").contains(reason))
|
||||
throw new IOException("Invalid switch exit reason");
|
||||
EventInput.Location location = fields.containsKey("world")
|
||||
? new EventInput.Location(UUID.fromString(fields.getProperty("world")), fields.getProperty("world_name"),
|
||||
Double.parseDouble(fields.getProperty("x")), Double.parseDouble(fields.getProperty("y")),
|
||||
Double.parseDouble(fields.getProperty("z")), Instant.parse(fields.getProperty("location_time")), "exact")
|
||||
: null;
|
||||
var before = fields.getProperty("revision_before"); var after = fields.getProperty("revision_after");
|
||||
if (!before.matches("0|[1-9][0-9]*") || !after.matches("[1-9][0-9]*"))
|
||||
throw new IOException("Invalid switch revision");
|
||||
var exit = new EquipmentSessionStore.Exit(session, character,
|
||||
Instant.parse(fields.getProperty("exited_at")), Instant.parse(fields.getProperty("deadline")));
|
||||
return new SavedStart(new ExitContext(player, Objects.requireNonNull(fields.getProperty("player_name")),
|
||||
character, Objects.requireNonNull(fields.getProperty("character_name")), session, location, reason),
|
||||
exit, before, after, Instant.parse(fields.getProperty("time")));
|
||||
} catch (RuntimeException invalid) { throw new IOException("Invalid switch event intent", invalid); }
|
||||
}
|
||||
|
||||
private void report(Throwable failure) {
|
||||
try { failures.accept(failure); } catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
/** Shutdown does not wait for file I/O on the Minecraft thread. */
|
||||
@Override public void close() {
|
||||
worker.shutdown();
|
||||
CompletableFuture.runAsync(() -> {
|
||||
try {
|
||||
if (!worker.awaitTermination(5, TimeUnit.SECONDS))
|
||||
report(new IOException("Switch event intents did not drain"));
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt(); report(interrupted);
|
||||
} finally { drained.complete(null); }
|
||||
});
|
||||
}
|
||||
|
||||
public CompletionStage<Void> drained() { return drained; }
|
||||
|
||||
public void evaluated(Player player, UUID requested, EquipmentSessionStore.State state,
|
||||
Duration configured, Instant now, boolean allowed) {
|
||||
try {
|
||||
var action = UUID.randomUUID();
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
values.put("action_kind", "character_switch_preflight");
|
||||
values.put("requested_character_id", requested.toString());
|
||||
values.put("configured_duration_seconds", Long.toString(configured.getSeconds()));
|
||||
values.put("preflight_order", "before_equipment_pack");
|
||||
values.put("outcome", "observed");
|
||||
var exit = state == null ? null : state.lastExit();
|
||||
boolean same = exit != null && requested.equals(exit.characterId());
|
||||
values.put("same_character", same);
|
||||
values.put("last_exited_character_id", exit == null ? "none" : exit.characterId().toString());
|
||||
if (exit != null) {
|
||||
values.put("deadline", exit.deadline().toString());
|
||||
var remaining = Duration.between(now, exit.deadline());
|
||||
values.put("remaining_seconds", Long.toString(Math.max(0,
|
||||
remaining.getSeconds() + (remaining.getNano() > 0 ? 1 : 0))));
|
||||
values.put("receipt_revision", state.revision().toString());
|
||||
}
|
||||
var context = new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null,
|
||||
location(player.getLocation()));
|
||||
emit(new EventInput(id(action, "observed"), action, clock.instant(), "action.observed", context, values));
|
||||
values.put("outcome", allowed ? "allowed" : "refused");
|
||||
values.put("reason", allowed ? (same ? "same_character" : "prepack_allowed") : "cooldown");
|
||||
emit(new EventInput(id(action, "resolved"), action, clock.instant(), "action.resolved", context, values));
|
||||
} catch (RuntimeException ignored) { /* Logging cannot change entry admission. */ }
|
||||
}
|
||||
|
||||
private EventInput.Location location(Location value) {
|
||||
return value == null || value.getWorld() == null ? null
|
||||
: new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(), value.getX(), value.getY(),
|
||||
value.getZ(), clock.instant(), "exact");
|
||||
}
|
||||
|
||||
private void emit(EventInput event) {
|
||||
try { sink.apply(event); } catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
private static UUID id(UUID action, String kind) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:switch-delay:" + action + ':' + kind)
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,41 @@ public final class EquipmentStorage {
|
||||
return new PackPlan(PackResult.PACKED, item, available);
|
||||
}
|
||||
|
||||
/** Bounded custody/slot-class proof; never exposes the box's items or metadata to event payloads. */
|
||||
public record Snapshot(String boxSource, int boxSlot, int hotbarSlots, int armorSlots, boolean displacedHead) {
|
||||
public int packedSlots() { return hotbarSlots + armorSlots; }
|
||||
public java.util.List<String> slotClasses() {
|
||||
var result = new java.util.ArrayList<String>(2);
|
||||
if (hotbarSlots > 0) result.add("hotbar");
|
||||
if (armorSlots > 0) result.add("armor");
|
||||
return java.util.List.copyOf(result);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read-only native proof at the packing/restoration boundary, including moved or cursor custody. */
|
||||
public java.util.Optional<Snapshot> snapshot(PlayerInventory inventory, UUID owner, UUID session, ItemStack cursor) {
|
||||
Snapshot found = null;
|
||||
for (int slot = 9; slot <= 41; slot++) {
|
||||
if (slot >= 36 && slot < 40) continue;
|
||||
var item = slot == 41 ? cursor : inventory.getItem(slot);
|
||||
if (!matches(item, owner, session)) continue;
|
||||
if (found != null || item.getAmount() != 1 || !(item.getItemMeta() instanceof BlockStateMeta meta)
|
||||
|| !(meta.getBlockState() instanceof ShulkerBox box)) return java.util.Optional.empty();
|
||||
var fields = meta.getPersistentDataContainer();
|
||||
if (!fields.has(VERSION, PersistentDataType.INTEGER) || fields.get(VERSION, PersistentDataType.INTEGER) != 1
|
||||
|| !Arrays.equals(EQUIPMENT_SLOTS, fields.get(SLOTS, PersistentDataType.INTEGER_ARRAY)))
|
||||
return java.util.Optional.empty();
|
||||
var contents = box.getInventory();
|
||||
int hotbar = 0, armor = 0;
|
||||
for (int index = 0; index < EQUIPMENT_SLOTS.length; index++) {
|
||||
if (!empty(contents.getItem(index))) { if (index < 9) hotbar++; else armor++; }
|
||||
}
|
||||
found = new Snapshot(slot == 41 ? "cursor" : slot == 40 ? "offhand" : "main",
|
||||
slot, hotbar, armor, !empty(inventory.getItem(39)));
|
||||
}
|
||||
return java.util.Optional.ofNullable(found);
|
||||
}
|
||||
|
||||
public enum RestoreResult { RESTORED, NOT_FOUND, AMBIGUOUS, INVALID_BOX, OCCUPIED_SLOT }
|
||||
|
||||
/** Caller removes validated fantasy controls first. Unexpected slot occupants are never overwritten. */
|
||||
|
||||
@@ -12,12 +12,14 @@ import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.Color;
|
||||
@@ -77,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 {
|
||||
@@ -86,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,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");
|
||||
@@ -193,8 +199,25 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
defeat.level(), defeat.active().character(), defeat.coins());
|
||||
}
|
||||
|
||||
/** Consumed synchronously by the fantasy defeat transition before it persists the Tail state. */
|
||||
public boolean consumePvpDefeat(UUID player) { return pvpDefeats.remove(player); }
|
||||
/** Captures short-lived contributors before the downed wait; no reward is published yet. */
|
||||
public EquipmentSessions.DefeatCapture captureDownedDefeat(UUID player){
|
||||
var entry=defeats.entrySet().stream().filter(value->value.getValue().active().player().equals(player)).findFirst().orElse(null);
|
||||
if(entry==null)return new EquipmentSessions.DefeatCapture(pvpDefeats.remove(player),List.of(),0,BigInteger.ZERO);
|
||||
defeats.remove(entry.getKey());var defeat=entry.getValue();var direct=new ArrayList<>(contributions.consumeDirect(player,clock.instant()));direct.remove(defeat.active().character());boolean pvp=pvpDefeats.remove(player)||!direct.isEmpty();return new EquipmentSessions.DefeatCapture(pvp,List.copyOf(direct),defeat.level(),defeat.coins());
|
||||
}
|
||||
|
||||
/** Backward-compatible test adapter; production captures the complete durable context. */
|
||||
public boolean consumePvpDefeat(UUID player){return captureDownedDefeat(player).playerDefeat();}
|
||||
|
||||
/** 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 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 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)
|
||||
public void mobDeath(EntityDeathEvent event) {
|
||||
@@ -221,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());
|
||||
@@ -231,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)));
|
||||
@@ -285,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;
|
||||
@@ -295,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;
|
||||
@@ -325,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.");
|
||||
@@ -354,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);
|
||||
|
||||
@@ -5,6 +5,7 @@ import java.util.Objects;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import java.util.WeakHashMap;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
@@ -17,12 +18,24 @@ public final class PartyCombat implements Listener, AutoCloseable {
|
||||
private boolean closed;
|
||||
private final PartyRoster parties;
|
||||
private final CombatSources sources;
|
||||
private games.dmg.fantasystick.events.PartyProtectionEventBridge protectionEvents;
|
||||
private final WeakHashMap<EntityDamageEvent, DamageAudit> damageAudits = new WeakHashMap<>();
|
||||
private final WeakHashMap<org.bukkit.event.Event, NativeAudit> nativeAudits = new WeakHashMap<>();
|
||||
private record NativeAudit(games.dmg.fantasystick.events.PartyProtectionEventBridge.Action action,
|
||||
String reason) { }
|
||||
private record DamageAudit(games.dmg.fantasystick.events.PartyProtectionEventBridge.Action action,
|
||||
String decision) { }
|
||||
|
||||
public PartyCombat(PartyRoster parties, EquipmentSessions sessions) {
|
||||
this.parties = Objects.requireNonNull(parties);
|
||||
this.sources = new CombatSources(sessions);
|
||||
}
|
||||
|
||||
public void attachProtectionEvents(games.dmg.fantasystick.events.PartyProtectionEventBridge events) {
|
||||
if (protectionEvents != null || closed) throw new IllegalStateException("Party protection events already attached");
|
||||
protectionEvents = Objects.requireNonNull(events);
|
||||
}
|
||||
|
||||
public void install(org.bukkit.plugin.Plugin plugin) {
|
||||
cleanup = plugin.getServer().getScheduler().runTaskTimer(plugin, () -> {
|
||||
if (!closed) for (var player : plugin.getServer().getOnlinePlayers()) OngoingDamage.sweep(player);
|
||||
@@ -31,6 +44,8 @@ public final class PartyCombat implements Listener, AutoCloseable {
|
||||
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
damageAudits.clear();
|
||||
nativeAudits.clear();
|
||||
if (cleanup != null) cleanup.cancel();
|
||||
}
|
||||
|
||||
@@ -40,8 +55,21 @@ public final class PartyCombat implements Listener, AutoCloseable {
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void impact(org.bukkit.event.entity.ProjectileHitEvent event) {
|
||||
var origin = sources.read(event.getEntity());
|
||||
if (origin != null && (!sources.current(origin) || !(event.getEntity() instanceof org.bukkit.entity.ThrownPotion)
|
||||
&& event.getHitEntity() instanceof Player target && blocked(origin, target))) event.setCancelled(true);
|
||||
if (origin == null) return;
|
||||
var target = event.getHitEntity() instanceof Player player ? player : null;
|
||||
var reason = !sources.current(origin) ? "stale_source"
|
||||
: target != null && !(event.getEntity() instanceof org.bukkit.entity.ThrownPotion)
|
||||
? protectionReason(origin, target) : null;
|
||||
if (reason != null) {
|
||||
captureNative(event, "party_projectile_impact_protection", event.getEntity(), target, origin,
|
||||
reason, "projectile_impact");
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void resolvedImpact(org.bukkit.event.entity.ProjectileHitEvent event) {
|
||||
resolveNative(event, event);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
@@ -50,13 +78,33 @@ public final class PartyCombat implements Listener, AutoCloseable {
|
||||
// Revoke stale continuation here. Valid healing and unrelated recipients are untouched;
|
||||
// actual harmful applications and instantaneous damage have their own guards below.
|
||||
var origin = sources.read(event.getEntity());
|
||||
if (origin != null && !sources.current(origin)) event.setCancelled(true);
|
||||
if (origin != null && !sources.current(origin)) {
|
||||
var recipients = event.getAffectedEntities().stream().filter(Player.class::isInstance)
|
||||
.map(Player.class::cast).toList();
|
||||
captureNative(event, "party_splash_protection", event.getEntity(),
|
||||
recipients.size() == 1 ? recipients.getFirst() : null, origin, "stale_source", "potion_splash");
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void resolvedSplash(org.bukkit.event.entity.PotionSplashEvent event) {
|
||||
resolveNative(event, event);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void staleLingering(LingeringPotionSplashEvent event) {
|
||||
var origin = sources.read(event.getEntity());
|
||||
if (origin != null && !sources.current(origin)) event.setCancelled(true);
|
||||
if (origin != null && !sources.current(origin)) {
|
||||
captureNative(event, "party_lingering_protection", event.getEntity(), null,
|
||||
origin, "stale_source", "lingering_splash");
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void resolvedLingering(LingeringPotionSplashEvent event) {
|
||||
resolveNative(event, event);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
@@ -64,12 +112,49 @@ public final class PartyCombat implements Listener, AutoCloseable {
|
||||
sources.store(event.getAreaEffectCloud(), sources.read(event.getEntity()));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOWEST)
|
||||
public void observeDamage(EntityDamageEvent event) {
|
||||
if (protectionEvents == null || !(event.getEntity() instanceof Player target)) return;
|
||||
// OngoingDamage.read reconciles native PDC. Only the original HIGHEST guard may do so;
|
||||
// the LOWEST audit must not change its timing or mutate an earlier protection's state.
|
||||
if (event.getCause() == EntityDamageEvent.DamageCause.POISON
|
||||
|| event.getCause() == EntityDamageEvent.DamageCause.WITHER
|
||||
|| event.getCause() == EntityDamageEvent.DamageCause.FIRE_TICK) return;
|
||||
var origin = sources.source(event);
|
||||
if (origin == null) return;
|
||||
var ongoing = OngoingDamage.read(target, event.getCause());
|
||||
String reason = ongoing != null && !sources.current(ongoing.target()) ? "stale_target"
|
||||
: protectionReason(origin, target);
|
||||
if (reason == null) return;
|
||||
captureDamage(event, target, origin, reason);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void resolvedDamage(EntityDamageEvent event) {
|
||||
if (protectionEvents == null) return;
|
||||
var audit = damageAudits.remove(event);
|
||||
if (audit == null) return;
|
||||
boolean prior = Boolean.TRUE.equals(audit.action().data().get("prior_cancelled"));
|
||||
String reason = prior && event.isCancelled() ? "prior_cancelled"
|
||||
: !event.isCancelled() && audit.decision() != null ? "protection_overridden"
|
||||
: event.isCancelled() && audit.decision() != null ? audit.decision()
|
||||
: event.isCancelled() ? "external_cancelled" : "protection_not_applied";
|
||||
String outcome = !event.isCancelled() ? "accepted" : prior || audit.decision() == null
|
||||
? "cancelled" : "refused";
|
||||
protectionEvents.resolved(audit.action(), outcome, reason, event.isCancelled(),
|
||||
event.isCancelled() ? 0 : event.getFinalDamage());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void damage(EntityDamageEvent event) {
|
||||
if (games.dmg.fantasystick.ability.GuardianBond.redirectedDamage()) return;
|
||||
if (event.getEntity() instanceof Player target) {
|
||||
var ongoing = OngoingDamage.read(target, event.getCause());
|
||||
if (ongoing != null && (!sources.current(ongoing.target()) || blocked(ongoing.source(), target))) {
|
||||
var reason = !sources.current(ongoing.target()) ? "stale_target"
|
||||
: protectionReason(ongoing.source(), target);
|
||||
captureDamage(event, target, ongoing.source(), reason);
|
||||
markDamageDecision(event, reason);
|
||||
event.setCancelled(true); return;
|
||||
}
|
||||
}
|
||||
@@ -77,7 +162,14 @@ public final class PartyCombat implements Listener, AutoCloseable {
|
||||
if (origin == null && event instanceof EntityDamageByEntityEvent entity) origin = sources.read(entity.getDamager());
|
||||
if (origin == null) origin = sources.read(event.getDamageSource().getCausingEntity());
|
||||
if (origin != null && (!sources.current(origin)
|
||||
|| event.getEntity() instanceof Player target && blocked(origin, target))) event.setCancelled(true);
|
||||
|| event.getEntity() instanceof Player target && blocked(origin, target))) {
|
||||
if (event.getEntity() instanceof Player target) {
|
||||
var reason = protectionReason(origin, target);
|
||||
captureDamage(event, target, origin, reason);
|
||||
markDamageDecision(event, reason);
|
||||
}
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
@@ -85,7 +177,16 @@ public final class PartyCombat implements Listener, AutoCloseable {
|
||||
if (!(event.getEntity() instanceof Player target) || event.getNewEffect() == null
|
||||
|| OngoingDamage.cause(event.getModifiedType()) == null) return;
|
||||
var origin = sources.read(event.getSource());
|
||||
if (origin != null && blocked(origin, target)) event.setCancelled(true);
|
||||
if (origin != null && blocked(origin, target)) {
|
||||
captureNative(event, "party_effect_application_protection", event.getSource(), target,
|
||||
origin, protectionReason(origin, target), event.getModifiedType().getKey().getKey());
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void resolvedPotion(org.bukkit.event.entity.EntityPotionEffectEvent event) {
|
||||
resolveNative(event, event);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
@@ -105,7 +206,16 @@ public final class PartyCombat implements Listener, AutoCloseable {
|
||||
public void combust(org.bukkit.event.entity.EntityCombustByEntityEvent event) {
|
||||
if (!(event.getEntity() instanceof Player target)) return;
|
||||
var origin = sources.read(event.getCombuster());
|
||||
if (origin != null && blocked(origin, target)) event.setCancelled(true);
|
||||
if (origin != null && blocked(origin, target)) {
|
||||
captureNative(event, "party_ignite_protection", event.getCombuster(), target,
|
||||
origin, protectionReason(origin, target), "combust");
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void resolvedCombust(org.bukkit.event.entity.EntityCombustByEntityEvent event) {
|
||||
resolveNative(event, event);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
@@ -118,11 +228,49 @@ public final class PartyCombat implements Listener, AutoCloseable {
|
||||
: new OngoingDamage.Application(origin, sources.player(target.getUniqueId())));
|
||||
}
|
||||
|
||||
private boolean blocked(CombatSources.Origin origin, Player target) {
|
||||
private void captureNative(org.bukkit.event.Event event, String kind, org.bukkit.entity.Entity causing,
|
||||
Player target, CombatSources.Origin origin, String reason, String nativeKind) {
|
||||
if (protectionEvents == null || reason == null) return;
|
||||
var recipient = target == null ? null : sources.player(target.getUniqueId());
|
||||
var action = protectionEvents.nativeAttempt(kind, causing, target, origin, recipient,
|
||||
parties.partyOf(origin.player()).orElse(null),
|
||||
recipient == null ? null : parties.partyOf(recipient.player()).orElse(null),
|
||||
reason, nativeKind);
|
||||
if (action != null) nativeAudits.put(event, new NativeAudit(action, reason));
|
||||
}
|
||||
|
||||
private void resolveNative(org.bukkit.event.Event event, org.bukkit.event.Cancellable cancellable) {
|
||||
if (protectionEvents == null) return;
|
||||
var audit = nativeAudits.remove(event);
|
||||
if (audit != null) protectionEvents.resolved(audit.action(), cancellable.isCancelled() ? "refused" : "accepted",
|
||||
cancellable.isCancelled() ? audit.reason() : "external_uncancelled", cancellable.isCancelled(), null);
|
||||
}
|
||||
|
||||
private void captureDamage(EntityDamageEvent event, Player target, CombatSources.Origin origin, String reason) {
|
||||
if (protectionEvents == null || damageAudits.containsKey(event) || reason == null) return;
|
||||
var recipient = sources.player(target.getUniqueId());
|
||||
var action = protectionEvents.damage(event, origin, recipient, target,
|
||||
parties.partyOf(origin.player()).orElse(null), parties.partyOf(recipient.player()).orElse(null), reason);
|
||||
if (action != null) damageAudits.put(event, new DamageAudit(action, null));
|
||||
}
|
||||
|
||||
private void markDamageDecision(EntityDamageEvent event, String reason) {
|
||||
var audit = damageAudits.get(event);
|
||||
if (audit != null && reason != null) damageAudits.put(event, new DamageAudit(audit.action(), reason));
|
||||
}
|
||||
|
||||
private String protectionReason(CombatSources.Origin origin, Player target) {
|
||||
if (!sources.current(origin)) return "stale_source";
|
||||
var recipient = sources.player(target.getUniqueId());
|
||||
boolean fantasy = origin.session() != null;
|
||||
return !sources.current(origin) || fantasy != (recipient.session() != null) || fantasy
|
||||
&& !origin.player().equals(recipient.player())
|
||||
&& parties.partyOf(origin.player()).filter(group -> group.members().contains(recipient.player())).isPresent();
|
||||
if (fantasy != (recipient.session() != null)) return "mixed_mode";
|
||||
if (fantasy && !origin.player().equals(recipient.player())
|
||||
&& parties.partyOf(origin.player()).filter(group -> group.members().contains(recipient.player())).isPresent())
|
||||
return "same_party";
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean blocked(CombatSources.Origin origin, Player target) {
|
||||
return protectionReason(origin, target) != null;
|
||||
}
|
||||
}
|
||||
@@ -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);}
|
||||
|
||||
@@ -30,6 +30,9 @@ public final class AdventureSettings implements AutoCloseable {
|
||||
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-configuration-persistence"));
|
||||
private volatile boolean enabled;
|
||||
private volatile String revision;
|
||||
public java.util.Optional<String> revision() { return java.util.Optional.ofNullable(revision); }
|
||||
public record Saved(boolean beforeValue, boolean value, String revision) { }
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
@@ -53,13 +56,32 @@ public final class AdventureSettings implements AutoCloseable {
|
||||
|
||||
public boolean enabled() { return enabled; }
|
||||
|
||||
public synchronized CompletionStage<Boolean> set(boolean value) {
|
||||
public CompletionStage<Boolean> set(boolean value) {
|
||||
return setRecorded(value).thenApply(Saved::value);
|
||||
}
|
||||
|
||||
/** The digest identifies the exact acknowledged config bytes, not a cross-file transaction. */
|
||||
public CompletionStage<Saved> setRecorded(boolean value) {
|
||||
return setRecorded(value, ignored -> { });
|
||||
}
|
||||
|
||||
/** Capture an acknowledged audit intent before the configuration worker finishes draining. */
|
||||
public synchronized CompletionStage<Saved> setRecorded(boolean value, java.util.function.Consumer<Saved> onSaved) {
|
||||
Objects.requireNonNull(onSaved);
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
pending++;
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try { save(value); enabled = value; return value; }
|
||||
catch (IOException | RuntimeException failure) {
|
||||
try {
|
||||
boolean before = enabled;
|
||||
var revision = save(value);
|
||||
enabled = value;
|
||||
this.revision = revision;
|
||||
var saved = new Saved(before, value, revision);
|
||||
try { onSaved.accept(saved); }
|
||||
catch (RuntimeException ignored) { /* An audit observer cannot roll back an acknowledged policy. */ }
|
||||
return saved;
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
// No parser contents, filesystem paths or configuration values in diagnostics.
|
||||
throw new java.util.concurrent.CompletionException(new IOException(
|
||||
"Adventure configuration save refused or failed; effective setting unchanged"));
|
||||
@@ -71,7 +93,7 @@ public final class AdventureSettings implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
private void save(boolean value) throws IOException {
|
||||
private String save(boolean value) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
|
||||
var file = directory.resolve("config.yml");
|
||||
@@ -111,7 +133,15 @@ public final class AdventureSettings implements AutoCloseable {
|
||||
while (buffer.hasRemaining()) output.write(buffer);
|
||||
output.force(true);
|
||||
}
|
||||
String revision;
|
||||
try {
|
||||
revision = "sha256:" + java.util.HexFormat.of().formatHex(
|
||||
java.security.MessageDigest.getInstance("SHA-256").digest(bytes));
|
||||
} catch (java.security.NoSuchAlgorithmException missing) {
|
||||
throw new IllegalStateException("SHA-256 unavailable", missing);
|
||||
}
|
||||
replacement.move(temporary, file);
|
||||
return revision;
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -364,20 +370,29 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
|
||||
public CompletableFuture<Optional<CharacterRecord>> renameCharacter(
|
||||
UUID characterId, String name, BigInteger expectedSheetGeneration) {
|
||||
return renameCharacterObserved(characterId, name, expectedSheetGeneration).thenApply(RenameOutcome::record);
|
||||
}
|
||||
|
||||
/** Capture the authoritative before/after pair in the same serialized save, not from stale item text. */
|
||||
public record RenameOutcome(Optional<CharacterRecord> record, Optional<CharacterRecord> before, String status) { }
|
||||
|
||||
public CompletableFuture<RenameOutcome> renameCharacterObserved(
|
||||
UUID characterId, String name, BigInteger expectedSheetGeneration) {
|
||||
Objects.requireNonNull(characterId);
|
||||
Objects.requireNonNull(name);
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) return Optional.empty();
|
||||
if (found.isEmpty() || found.orElseThrow().dead())
|
||||
return new RenameOutcome(Optional.empty(), Optional.empty(), "unavailable");
|
||||
var old = found.orElseThrow();
|
||||
if (expectedSheetGeneration != null && !old.sheetGeneration().equals(expectedSheetGeneration))
|
||||
return Optional.empty();
|
||||
if (old.name().equals(name)) return Optional.of(old);
|
||||
return new RenameOutcome(Optional.empty(), Optional.of(old), "stale_generation");
|
||||
if (old.name().equals(name)) return new RenameOutcome(Optional.of(old), Optional.of(old), "unchanged");
|
||||
var renamed = new CharacterRecord(old.id(), 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, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
|
||||
repository.replace(renamed, old.revision());
|
||||
return Optional.of(renamed);
|
||||
return new RenameOutcome(Optional.of(renamed), Optional.of(old), "renamed");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -467,6 +482,21 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
/** One character-file transaction owns both the exact coin debit and strict gear replacement. */
|
||||
public CompletableFuture<GearPurchase> purchaseGear(UUID characterId,BigInteger expectedRevision,UUID operationId,GearSlot slot,GearTier target,BigInteger price){Objects.requireNonNull(characterId);Objects.requireNonNull(expectedRevision);Objects.requireNonNull(operationId);Objects.requireNonNull(slot);Objects.requireNonNull(target);Objects.requireNonNull(price);if(target==GearTier.NONE||price.signum()<0)throw new IllegalArgumentException("Invalid gear purchase");return submit(()->{var found=repository.load(characterId);if(found.isEmpty()||found.orElseThrow().dead())return new GearPurchase(GearPurchaseStatus.UNAVAILABLE,Optional.empty());var old=found.orElseThrow();if(old.rewardReceipts().contains(operationId))return new GearPurchase(GearPurchaseStatus.DUPLICATE,Optional.of(old));if(!old.revision().equals(expectedRevision))return new GearPurchase(GearPurchaseStatus.STALE,Optional.of(old));if(!target.higherThan(old.gear().tier(slot)))return new GearPurchase(GearPurchaseStatus.NOT_UPGRADE,Optional.of(old));if(old.coins().compareTo(price)<0)return new GearPurchase(GearPurchaseStatus.INSUFFICIENT,Optional.of(old));var receipts=new java.util.HashSet<>(old.rewardReceipts());if(receipts.size()>=4096)receipts.remove(receipts.stream().min(UUID::compareTo).orElseThrow());receipts.add(operationId);var saved=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(),false,old.deathId(),old.diedAt(),old.coins().subtract(price),true,receipts,old.pvpCoinEscrow(),old.gear().with(slot,target),old.sheetGeneration());repository.replace(saved,old.revision());return new GearPurchase(GearPurchaseStatus.APPLIED,Optional.of(saved));});}
|
||||
|
||||
public enum RarePurchaseStatus { APPLIED,DUPLICATE,INSUFFICIENT,STALE,UNAVAILABLE }
|
||||
public record RarePurchase(RarePurchaseStatus status,Optional<CharacterRecord> record){}
|
||||
/** One character-file transaction owns a rare purchase's exact idempotent coin debit. */
|
||||
public CompletableFuture<RarePurchase> purchaseRare(UUID characterId,BigInteger expectedRevision,UUID operationId,BigInteger price){Objects.requireNonNull(characterId);Objects.requireNonNull(expectedRevision);Objects.requireNonNull(operationId);Objects.requireNonNull(price);if(price.signum()<=0)throw new IllegalArgumentException("Invalid rare purchase");return submit(()->{var found=repository.load(characterId);if(found.isEmpty()||found.orElseThrow().dead())return new RarePurchase(RarePurchaseStatus.UNAVAILABLE,Optional.empty());var old=found.orElseThrow();if(old.rewardReceipts().contains(operationId))return new RarePurchase(RarePurchaseStatus.DUPLICATE,Optional.of(old));if(!old.revision().equals(expectedRevision))return new RarePurchase(RarePurchaseStatus.STALE,Optional.of(old));if(old.coins().compareTo(price)<0)return new RarePurchase(RarePurchaseStatus.INSUFFICIENT,Optional.of(old));var receipts=new java.util.HashSet<>(old.rewardReceipts());if(receipts.size()>=4096)receipts.remove(receipts.stream().min(UUID::compareTo).orElseThrow());receipts.add(operationId);var saved=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(),false,old.deathId(),old.diedAt(),old.coins().subtract(price),true,receipts,old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());repository.replace(saved,old.revision());return new RarePurchase(RarePurchaseStatus.APPLIED,Optional.of(saved));});}
|
||||
|
||||
public enum MagicCoreStatus { APPLIED,DUPLICATE,STALE,UNAVAILABLE }
|
||||
public record MagicCore(MagicCoreStatus status,Optional<CharacterRecord> record){}
|
||||
/** Credit one ordinary arbitrary-precision point with stable consume proof. */
|
||||
public CompletableFuture<MagicCore> consumeMagicCore(UUID characterId,BigInteger expectedRevision,UUID operationId){Objects.requireNonNull(characterId);Objects.requireNonNull(expectedRevision);Objects.requireNonNull(operationId);return submit(()->{var found=repository.load(characterId);if(found.isEmpty()||found.orElseThrow().dead())return new MagicCore(MagicCoreStatus.UNAVAILABLE,Optional.empty());var old=found.orElseThrow();if(old.rewardReceipts().contains(operationId))return new MagicCore(MagicCoreStatus.DUPLICATE,Optional.of(old));if(!old.revision().equals(expectedRevision))return new MagicCore(MagicCoreStatus.STALE,Optional.of(old));var receipts=new java.util.HashSet<>(old.rewardReceipts());if(receipts.size()>=4096)receipts.remove(receipts.stream().min(UUID::compareTo).orElseThrow());receipts.add(operationId);var saved=new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints().add(BigInteger.ONE),old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),false,old.deathId(),old.diedAt(),old.coins(),old.coinsInitialized(),receipts,old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());repository.replace(saved,old.revision());return new MagicCore(MagicCoreStatus.APPLIED,Optional.of(saved));});}
|
||||
|
||||
public enum ReskillStatus { APPLIED,DUPLICATE,NOTHING_TO_RESET,STALE,UNAVAILABLE }
|
||||
public record Reskill(ReskillStatus status,Optional<CharacterRecord> record){}
|
||||
/** Reset every one-point character investment and preserve identity/progress/deadlines in one revision. */
|
||||
public CompletableFuture<Reskill> reskill(UUID characterId,BigInteger expectedRevision,UUID operationId,java.math.BigDecimal nativeHealthLimit){Objects.requireNonNull(characterId);Objects.requireNonNull(expectedRevision);Objects.requireNonNull(operationId);Objects.requireNonNull(nativeHealthLimit);if(nativeHealthLimit.signum()<=0)throw new IllegalArgumentException("Invalid native health limit");return submit(()->{var found=repository.load(characterId);if(found.isEmpty()||found.orElseThrow().dead())return new Reskill(ReskillStatus.UNAVAILABLE,Optional.empty());var old=found.orElseThrow();if(old.rewardReceipts().contains(operationId))return new Reskill(ReskillStatus.DUPLICATE,Optional.of(old));if(!old.revision().equals(expectedRevision))return new Reskill(ReskillStatus.STALE,Optional.of(old));BigInteger refund=old.stats().values().stream().reduce(BigInteger.ZERO,BigInteger::add).add(BigInteger.valueOf(old.learned().size())).add(BigInteger.valueOf(old.passives().stream().filter(id->!id.isEmpty()).distinct().count()));if(refund.signum()==0)return new Reskill(ReskillStatus.NOTHING_TO_RESET,Optional.of(old));var stats=new java.util.EnumMap<Stat,BigInteger>(Stat.class);for(var stat:Stat.values())stats.put(stat,BigInteger.ZERO);var receipts=new java.util.HashSet<>(old.rewardReceipts());if(receipts.size()>=4096)receipts.remove(receipts.stream().min(UUID::compareTo).orElseThrow());receipts.add(operationId);var health=old.health().min(java.math.BigDecimal.TEN.min(nativeHealthLimit));var saved=new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),stats,old.level(),old.experience(),old.unspentPoints().add(refund),health,Set.of(),List.of("","","","","","",""),List.of("","",""),old.cooldowns(),old.restrictedKills(),false,old.deathId(),old.diedAt(),old.coins(),old.coinsInitialized(),receipts,old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());repository.replace(saved,old.revision());return new Reskill(ReskillStatus.APPLIED,Optional.of(saved));});}
|
||||
|
||||
public enum CombatAwardStatus { APPLIED, SPACING_BLOCKED, AT_CAP, UNCHANGED, DUPLICATE, STALE, UNAVAILABLE }
|
||||
public record CombatAward(CombatAwardStatus status, Optional<CharacterRecord> record, int levels) { }
|
||||
|
||||
@@ -604,7 +634,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
java.util.Map<Stat, BigInteger> requirements, BigInteger cost) {
|
||||
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision); Objects.requireNonNull(ability);
|
||||
Objects.requireNonNull(requirements); Objects.requireNonNull(cost);
|
||||
if (ability.isBlank() || cost.signum() <= 0 || requirements.entrySet().stream().anyMatch(entry -> entry.getValue().compareTo(BigInteger.TEN) < 0)) {
|
||||
if (ability.isBlank() || !cost.equals(BigInteger.ONE) || requirements.entrySet().stream().anyMatch(entry -> entry.getValue().compareTo(BigInteger.TEN) < 0)) {
|
||||
throw new IllegalArgumentException("Invalid ability unlock");
|
||||
}
|
||||
return submit(() -> {
|
||||
@@ -785,6 +815,9 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
return death(characterId, null, loseCoins);
|
||||
}
|
||||
|
||||
/** 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());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);
|
||||
Instant diedAt = clock.instant();
|
||||
@@ -858,6 +891,19 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
/** Observe accepted save callbacks without waiting for file I/O on the native thread. */
|
||||
public java.util.concurrent.CompletionStage<Void> drained() {
|
||||
return java.util.concurrent.CompletableFuture.runAsync(() -> {
|
||||
try {
|
||||
if (!worker.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS))
|
||||
throw new java.util.concurrent.CompletionException(new java.io.IOException("Character storage did not drain"));
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new java.util.concurrent.CompletionException(interrupted);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Stop accepting work without blocking shutdown; already queued file writes may finish. */
|
||||
@Override
|
||||
public void close() {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Save-before-publication downed countdown and Revive protection policy. */
|
||||
public final class DownedSettings implements AutoCloseable{
|
||||
public static final String TIMEOUT="downed-timeout-seconds",PROTECTION="revive-downed-invulnerability-seconds";private static final Map<String,String> DEFAULTS=Map.of(TIMEOUT,"300",PROTECTION,"5");
|
||||
public record Update(String key,String value,Map<String,String> values,long sequence){public Update{values=Map.copyOf(values);}}
|
||||
private final Path directory;private final ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-downed-configuration"));private volatile Map<String,String> effective;private Map<String,String> persisted;private long saved,published;private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
public DownedSettings(Path directory,Map<String,String> initial){this.directory=directory.toAbsolutePath();var values=new LinkedHashMap<String,String>();for(String key:keys())values.put(key,initial.getOrDefault(key,DEFAULTS.get(key)));validate(values);effective=Map.copyOf(values);persisted=effective;}
|
||||
public static List<String> keys(){return List.of(TIMEOUT,PROTECTION);}public static String defaultValue(String key){return DEFAULTS.get(key);}public String value(String key){return effective.get(key);}public Duration timeout(){return Duration.ofSeconds(Long.parseLong(effective.get(TIMEOUT)));}public Duration protection(){return Duration.ofSeconds(Long.parseLong(effective.get(PROTECTION)));}
|
||||
public CompletionStage<Update> set(String key,String raw){if(!DEFAULTS.containsKey(key))throw new IllegalArgumentException("Unknown downed setting");String value=Long.toString(seconds(raw,key.equals(TIMEOUT)));var preview=new LinkedHashMap<>(effective);preview.put(key,value);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,value);save(key,value);persisted=Map.copyOf(next);return new Update(key,value,persisted,++saved);}catch(IOException failure){throw new CompletionException(failure);}finally{finished();}},worker).minimalCompletionStage();}
|
||||
public synchronized boolean publish(Update update){if(update.sequence()<=published)return false;validate(update.values());effective=update.values();published=update.sequence();return true;}
|
||||
private static void validate(Map<String,String> values){seconds(values.get(TIMEOUT),true);seconds(values.get(PROTECTION),false);}private static long seconds(String raw,boolean positive){var value=new java.math.BigDecimal(Objects.requireNonNull(raw));long seconds=value.longValueExact();if(seconds<(positive?1:0)||seconds>Duration.ofDays(365).toSeconds())throw new IllegalArgumentException("Invalid duration");return seconds;}
|
||||
private void save(String key,String value)throws IOException{Files.createDirectories(directory);Path file=directory.resolve("config.yml"),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");Map<String,Object> document=new LinkedHashMap<>();if(Files.exists(file,LinkOption.NOFOLLOW_LINKS)){if(!Files.isRegularFile(file,LinkOption.NOFOLLOW_LINKS)||Files.isSymbolicLink(file)||Files.size(file)>1_048_576)throw new IOException("Unsafe configuration");Object loaded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));if(loaded instanceof Map<?,?> map)for(var entry:map.entrySet()){if(!(entry.getKey() instanceof String name))throw new IOException("Invalid key");document.put(name,entry.getValue());}}document.put(key,Long.parseLong(value));Path temporary=Files.createTempFile(directory,".config-downed-",".tmp");try{byte[] bytes=yaml().dump(document).getBytes(StandardCharsets.UTF_8);try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE)){var buffer=ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);try(var parent=FileChannel.open(directory,StandardOpenOption.READ)){parent.force(true);}}finally{Files.deleteIfExists(temporary);}}catch(OverlappingFileLockException busy){throw new IOException("Configuration busy",busy);}}
|
||||
private static Yaml yaml(){var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(20);options.setCodePointLimit(1_048_576);return new Yaml(new SafeConstructor(options));}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);}
|
||||
}
|
||||
@@ -146,6 +146,9 @@ public final class EquipmentSessionStore implements AutoCloseable {
|
||||
}));
|
||||
}
|
||||
|
||||
/** Reopen only the exact completed session retained by durable downed custody. */
|
||||
public CompletionStage<State> resume(UUID player,UUID session,UUID character,String previousGameMode,OrdinaryVitals ordinaryVitals){Objects.requireNonNull(player);Objects.requireNonNull(session);Objects.requireNonNull(character);var receipt=new Receipt(session,character,clock.instant(),previousGameMode,ordinaryVitals);return submit(()->withLock(()->{var old=load(player).orElseThrow(()->new IOException("Completed equipment session not found"));if(old.pending()!=null||old.lastExit()==null||!session.equals(old.lastExit().sessionId())||!character.equals(old.lastExit().characterId()))throw new IOException("Completed equipment session does not match downed custody");return write(new State(player,old.revision().add(BigInteger.ONE),receipt,old.lastExit()));}));}
|
||||
|
||||
/** Capture the actual exit request time, never queue completion time. The receipt remains pending. */
|
||||
public CompletionStage<State> recordExit(UUID player, UUID session, Duration delay) {
|
||||
Objects.requireNonNull(player);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -76,11 +76,12 @@ public final class FrostShardSettings implements AutoCloseable {
|
||||
public FrostShard.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.GreatSword;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Serialized Great Sword configuration; the caller publishes acknowledged changes on the server thread. */
|
||||
public final class GreatSwordSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String BASE_DAMAGE = "great-sword-base-damage";
|
||||
public static final String RECOVERY = "great-sword-recovery-seconds";
|
||||
private static final List<String> KEYS = List.of(BASE_DAMAGE, RECOVERY);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "7", RECOVERY, "0.75");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, GreatSword.Settings settings, Map<String, String> values, long sequence) {
|
||||
public Update { values = Map.copyOf(values); }
|
||||
}
|
||||
|
||||
private final Path directory;
|
||||
private final Replacement replacement;
|
||||
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-great-sword-configuration"));
|
||||
private volatile Map<String, String> effectiveValues;
|
||||
private Map<String, String> persistedValues;
|
||||
private long persistedSequence;
|
||||
private long publishedSequence;
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public GreatSwordSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
GreatSwordSettings(Path directory, Map<String, String> initial, Replacement replacement) {
|
||||
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
|
||||
this.replacement = Objects.requireNonNull(replacement);
|
||||
var copy = new LinkedHashMap<String, String>();
|
||||
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
|
||||
parse(copy); effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
|
||||
}
|
||||
|
||||
public static List<String> keys() { return KEYS; }
|
||||
public static String defaultValue(String key) { return DEFAULTS.get(key); }
|
||||
public String value(String key) { return effectiveValues.get(key); }
|
||||
public GreatSword.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
public synchronized boolean publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
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) {
|
||||
Objects.requireNonNull(key); Objects.requireNonNull(value);
|
||||
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
|
||||
String accepted = canonical(key, value);
|
||||
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted); parse(preview);
|
||||
synchronized (this) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
pending++;
|
||||
}
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
var next = new LinkedHashMap<>(persistedValues); next.put(key, accepted);
|
||||
var parsed = parse(next); save(key, accepted); persistedValues = Map.copyOf(next);
|
||||
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
|
||||
? failure : new IOException("Great Sword configuration save refused or failed; effective settings unchanged"));
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
}
|
||||
}
|
||||
|
||||
private static String canonical(String key, String raw) {
|
||||
try {
|
||||
if (key.equals(BASE_DAMAGE)) return new java.math.BigInteger(raw).toString();
|
||||
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
|
||||
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
|
||||
}
|
||||
|
||||
public static GreatSword.Settings parse(Map<String, String> values) {
|
||||
try {
|
||||
var seconds = new java.math.BigDecimal(values.get(RECOVERY));
|
||||
Duration recovery = Duration.ofNanos(seconds.movePointRight(9).toBigIntegerExact().longValueExact());
|
||||
return new GreatSword.Settings(new java.math.BigInteger(values.get(BASE_DAMAGE)), recovery);
|
||||
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Great Sword settings must use supported numeric values", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private void save(String key, String value) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
|
||||
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
|
||||
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS))
|
||||
throw new IOException("Unsafe 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(MAX_BYTES);
|
||||
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(MAX_BYTES + 1); }
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
|
||||
if (loaded != null) {
|
||||
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
|
||||
for (var entry : map.entrySet()) {
|
||||
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
|
||||
document.put(savedKey, entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
document.put(key, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Path temporary = Files.createTempFile(directory, ".config-", ".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);
|
||||
}
|
||||
replacement.move(temporary, file);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration 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); }
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -72,11 +72,12 @@ public final class HexSettings implements AutoCloseable {
|
||||
public Hex.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
|
||||
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,11 +72,12 @@ public final class InspireSettings implements AutoCloseable {
|
||||
public Inspire.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
|
||||
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) {
|
||||
|
||||
@@ -77,11 +77,12 @@ public final class LongbowSettings implements AutoCloseable {
|
||||
public Longbow.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
|
||||
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,11 +71,12 @@ public final class QuickstepSettings implements AutoCloseable {
|
||||
public Quickstep.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.shop.RareCatalog;
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Durable adapter boundary for the separate shared rare catalog. */
|
||||
public interface RareCatalogRepository {
|
||||
Optional<RareCatalog> load() throws IOException;
|
||||
void save(RareCatalog catalog) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.shop.RareProduct;
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Durable recovery plans spanning one catalog reservation and character gear purchase. */
|
||||
public final class RarePurchaseOperationStore implements AutoCloseable {
|
||||
public record Plan(UUID id, UUID character, BigInteger expectedCharacterRevision, UUID catalogRevision,
|
||||
RareProduct product, BigInteger price) {
|
||||
public Plan {
|
||||
Objects.requireNonNull(id); Objects.requireNonNull(character); Objects.requireNonNull(expectedCharacterRevision);
|
||||
Objects.requireNonNull(catalogRevision); Objects.requireNonNull(product); Objects.requireNonNull(price);
|
||||
if(expectedCharacterRevision.signum()<0||price.signum()<=0)throw new IllegalArgumentException("Invalid Rare purchase plan");
|
||||
}
|
||||
}
|
||||
|
||||
private final Path directory;
|
||||
private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-rare-operation-persistence"));
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
|
||||
public RarePurchaseOperationStore(Path data){directory=data.toAbsolutePath().resolve("rare-purchase-operations");}
|
||||
|
||||
public CompletionStage<Plan> prepare(Plan plan){return submit(()->{
|
||||
Files.createDirectories(directory);Path target=file(plan.id());
|
||||
if(Files.exists(target)) {
|
||||
var existing=decode(Files.readString(target,StandardCharsets.UTF_8));
|
||||
if(!existing.equals(plan))throw new IOException("rare operation identity collision");
|
||||
return existing;
|
||||
}
|
||||
write(target,encode(plan));return plan;
|
||||
});}
|
||||
public CompletionStage<Void> complete(UUID id){return submit(()->{if(Files.deleteIfExists(file(id)))DurableFiles.forceDirectory(directory);return null;});}
|
||||
public CompletionStage<List<Plan>> load(){return submit(this::loadBlocking);}
|
||||
|
||||
private List<Plan> loadBlocking()throws IOException {
|
||||
if(Files.notExists(directory))return List.of();
|
||||
var result=new ArrayList<Plan>();
|
||||
try(var files=Files.list(directory)) {
|
||||
for(var path:files.filter(value->value.getFileName().toString().endsWith(".yml")).sorted().toList()) {
|
||||
if(Files.size(path)>65_536)throw new IOException("rare operation too large");
|
||||
result.add(decode(Files.readString(path,StandardCharsets.UTF_8)));
|
||||
}
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
private Path file(UUID id){return directory.resolve(id+".yml");}
|
||||
private static String encode(Plan plan){Map<String,Object> row=new LinkedHashMap<>();row.put("schema-version",1);
|
||||
row.put("operation-id",plan.id().toString());row.put("character",plan.character().toString());
|
||||
row.put("expected-character-revision",plan.expectedCharacterRevision().toString());
|
||||
row.put("catalog-revision",plan.catalogRevision().toString());row.put("product",plan.product().id());
|
||||
row.put("price",plan.price().toString());return yaml().dump(row);}
|
||||
private static Plan decode(String input){Object decoded=yaml().load(input);if(!(decoded instanceof Map<?,?> row)||
|
||||
!"1".equals(String.valueOf(row.get("schema-version"))))throw new IllegalArgumentException("Invalid rare operation schema");
|
||||
return new Plan(UUID.fromString(text(row,"operation-id")),UUID.fromString(text(row,"character")),
|
||||
new BigInteger(text(row,"expected-character-revision")),UUID.fromString(text(row,"catalog-revision")),
|
||||
RareProduct.find(text(row,"product")).orElseThrow(),new BigInteger(text(row,"price")));}
|
||||
private static void write(Path target,String content)throws IOException {Path temporary=Files.createTempFile(target.getParent(),"rare-operation-",".tmp");
|
||||
try{byte[] bytes=content.getBytes(StandardCharsets.UTF_8);try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE)){
|
||||
var buffer=ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}
|
||||
try{Files.move(temporary,target,StandardCopyOption.ATOMIC_MOVE);}catch(AtomicMoveNotSupportedException unsupported){
|
||||
throw new IOException("Atomic rare-operation persistence unavailable",unsupported);}DurableFiles.forceDirectory(target.getParent());}
|
||||
finally{Files.deleteIfExists(temporary);}}
|
||||
private static Yaml yaml(){var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(0);
|
||||
options.setCodePointLimit(65_536);return new Yaml(new SafeConstructor(options));}
|
||||
private static String text(Map<?,?> row,String key){Object value=row.get(key);if(value instanceof String text)return text;throw new IllegalArgumentException("Invalid "+key);}
|
||||
private <T>CompletionStage<T> submit(Callable<T> action){synchronized(this){if(closed)return CompletableFuture.failedStage(
|
||||
new IOException("rare operation store closed"));pending++;}return CompletableFuture.supplyAsync(()->{try{return action.call();}
|
||||
catch(Exception failure){throw new CompletionException(failure);}finally{finished();}},worker).minimalCompletionStage();}
|
||||
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);}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.shop.*;
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
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 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()));}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) {
|
||||
|
||||
@@ -70,11 +70,12 @@ public final class SecondWindSettings implements AutoCloseable {
|
||||
public SecondWind.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
|
||||
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) {
|
||||
|
||||
@@ -74,11 +74,12 @@ public final class ShieldBashSettings implements AutoCloseable {
|
||||
public ShieldBash.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
|
||||
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,11 +71,12 @@ public final class StoneSkinSettings implements AutoCloseable {
|
||||
public StoneSkin.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.shop.RareCatalog;
|
||||
import games.dmg.fantasystick.shop.RareProduct;
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Forced atomic persistence for the one shared rare catalog. */
|
||||
public final class YamlRareCatalogRepository implements RareCatalogRepository {
|
||||
private final Path file;
|
||||
public YamlRareCatalogRepository(Path data){file=data.toAbsolutePath().resolve("rare-catalog.yml");}
|
||||
|
||||
@Override public Optional<RareCatalog> load() throws IOException {
|
||||
if(Files.notExists(file))return Optional.empty();
|
||||
try {
|
||||
if(Files.size(file)>1_048_576)throw new IllegalArgumentException("Catalog too large");
|
||||
Object decoded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));
|
||||
if(!(decoded instanceof Map<?,?> root)||Integer.parseInt(String.valueOf(root.get("schema-version")))!=1)
|
||||
throw new IllegalArgumentException("Invalid schema");
|
||||
var products=new LinkedHashMap<RareProduct,RareCatalog.Offer>();
|
||||
for(Object value:list(root.get("products"))) {
|
||||
var row=map(value);
|
||||
var product=parseProduct(text(row,"product"));
|
||||
if(products.put(product,new RareCatalog.Offer(new BigInteger(text(row,"price")),
|
||||
Integer.parseInt(text(row,"remaining"))))!=null)throw new IllegalArgumentException("Duplicate product");
|
||||
}
|
||||
var transactions=new LinkedHashMap<UUID,RareCatalog.Transaction>();
|
||||
Object savedTransactions=root.get("transactions");
|
||||
if(savedTransactions!=null)for(Object value:list(savedTransactions)) {
|
||||
var row=map(value);var id=UUID.fromString(text(row,"operation-id"));
|
||||
var transaction=new RareCatalog.Transaction(parseProduct(text(row,"product")),
|
||||
RareCatalog.TransactionState.valueOf(text(row,"state")));
|
||||
if(transactions.put(id,transaction)!=null)throw new IllegalArgumentException("Duplicate operation");
|
||||
}
|
||||
return Optional.of(new RareCatalog(LocalDate.parse(text(root,"catalog-day")),
|
||||
UUID.fromString(text(root,"revision")),Instant.parse(text(root,"refreshed-at")),
|
||||
RareCatalog.RefreshReason.valueOf(text(root,"refresh-reason")),products,transactions));
|
||||
} catch(RuntimeException invalid) { throw new IOException("Invalid rare catalog",invalid); }
|
||||
}
|
||||
|
||||
@Override public void save(RareCatalog catalog) throws IOException {
|
||||
Objects.requireNonNull(catalog);
|
||||
Map<String,Object> root=new LinkedHashMap<>();
|
||||
root.put("schema-version",1);
|
||||
root.put("catalog-day",catalog.day().toString());
|
||||
root.put("revision",catalog.revision().toString());
|
||||
root.put("refreshed-at",catalog.refreshedAt().toString());
|
||||
root.put("refresh-reason",catalog.reason().name());
|
||||
root.put("products",catalog.products().entrySet().stream().sorted(Map.Entry.comparingByKey(
|
||||
Comparator.comparing(RareProduct::id)))
|
||||
.map(entry->{Map<String,Object> row=new LinkedHashMap<>();row.put("product",product(entry.getKey()));
|
||||
row.put("price",entry.getValue().price().toString());row.put("remaining",Integer.toString(entry.getValue().remaining()));return row;}).toList());
|
||||
root.put("transactions",catalog.transactions().entrySet().stream().sorted(Map.Entry.comparingByKey()).map(entry->{
|
||||
Map<String,Object> row=new LinkedHashMap<>();row.put("operation-id",entry.getKey().toString());
|
||||
row.put("product",product(entry.getValue().product()));row.put("state",entry.getValue().state().name());return row;}).toList());
|
||||
write(yaml().dump(root));
|
||||
}
|
||||
|
||||
private void write(String content)throws IOException {
|
||||
Files.createDirectories(file.getParent());
|
||||
Path temporary=Files.createTempFile(file.getParent(),"rare-catalog-",".tmp");
|
||||
try {
|
||||
byte[] bytes=content.getBytes(StandardCharsets.UTF_8);
|
||||
try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE)){
|
||||
var buffer=ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);
|
||||
}
|
||||
try{Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);}
|
||||
catch(AtomicMoveNotSupportedException unsupported){throw new IOException("Atomic rare catalog persistence unavailable",unsupported);}
|
||||
DurableFiles.forceDirectory(file.getParent());
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
}
|
||||
|
||||
private static String product(RareProduct product){return product.id();}
|
||||
private static RareProduct parseProduct(String value){return RareProduct.find(value).orElseThrow();}
|
||||
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){if(value instanceof List<?> result)return result;throw new IllegalArgumentException();}
|
||||
private static Map<?,?> map(Object value){if(value instanceof Map<?,?> result)return result;throw new IllegalArgumentException();}
|
||||
private static String text(Map<?,?> map,String key){Object value=map.get(key);if(value instanceof String text)return text;throw new IllegalArgumentException("Invalid "+key);}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.shop.RareInventoryState;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Forced-atomic adapter for persistent typed rare goods and use proof. */
|
||||
public final class YamlRareInventoryRepository {
|
||||
private final Path file;
|
||||
public YamlRareInventoryRepository(Path data){file=data.toAbsolutePath().resolve("rare-inventory.yml");}
|
||||
public RareInventoryState load()throws IOException{if(Files.notExists(file))return new RareInventoryState();try{if(Files.size(file)>1_048_576)throw new IllegalArgumentException();Object decoded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));if(!(decoded instanceof Map<?,?> root)||(Integer.parseInt(String.valueOf(root.get("schema-version")))<1||Integer.parseInt(String.valueOf(root.get("schema-version")))>2))throw new IllegalArgumentException();var items=new LinkedHashMap<UUID,Map<String,BigInteger>>();for(Object value:list(root.get("characters"))){var row=map(value);var goods=new LinkedHashMap<String,BigInteger>();for(Object itemValue:list(row.get("items"))){var item=map(itemValue);goods.put(text(item,"product"),new BigInteger(text(item,"quantity")));}items.put(UUID.fromString(text(row,"character")),goods);}var receipts=new LinkedHashMap<UUID,RareInventoryState.PurchaseReceipt>();for(Object value:list(root.get("purchase-receipts"))){var row=map(value);receipts.put(UUID.fromString(text(row,"operation")),new RareInventoryState.PurchaseReceipt(UUID.fromString(text(row,"character")),text(row,"product")));}var uses=new LinkedHashMap<UUID,RareInventoryState.Use>();for(Object value:list(root.get("uses"))){var row=map(value);UUID operation=UUID.fromString(text(row,"operation"));uses.put(operation,new RareInventoryState.Use(operation,UUID.fromString(text(row,"character")),text(row,"product"),new BigInteger(text(row,"expected-quantity")),decimal(row,"health-before"),decimal(row,"maximum"),row.get("percent")==null?0:Integer.parseInt(text(row,"percent")),RareInventoryState.UsePhase.valueOf(text(row,"phase"))));}return new RareInventoryState(items,receipts,uses);}catch(RuntimeException invalid){throw new IOException("Invalid rare inventory",invalid);}}
|
||||
public void save(RareInventoryState state)throws IOException{Map<String,Object> root=new LinkedHashMap<>();root.put("schema-version",2);root.put("characters",state.items().entrySet().stream().sorted(Map.Entry.comparingByKey()).map(entry->{Map<String,Object> row=new LinkedHashMap<>();row.put("character",entry.getKey().toString());row.put("items",entry.getValue().entrySet().stream().sorted(Map.Entry.comparingByKey()).map(item->{Map<String,Object> value=new LinkedHashMap<>();value.put("product",item.getKey());value.put("quantity",item.getValue().toString());return value;}).toList());return row;}).toList());root.put("purchase-receipts",state.purchaseReceipts().entrySet().stream().sorted(Map.Entry.comparingByKey()).map(entry->{Map<String,Object> row=new LinkedHashMap<>();row.put("operation",entry.getKey().toString());row.put("character",entry.getValue().character().toString());row.put("product",entry.getValue().product());return row;}).toList());root.put("uses",state.uses().values().stream().sorted(Comparator.comparing(RareInventoryState.Use::operation)).map(use->{Map<String,Object> row=new LinkedHashMap<>();row.put("operation",use.operation().toString());row.put("character",use.character().toString());row.put("product",use.product());row.put("expected-quantity",use.expectedQuantity().toString());if(use.healthBefore()!=null){row.put("health-before",use.healthBefore().toPlainString());row.put("maximum",use.maximum().toPlainString());row.put("percent",Integer.toString(use.percent()));}row.put("phase",use.phase().name());return row;}).toList());write(yaml().dump(root));}
|
||||
private void write(String content)throws IOException{Files.createDirectories(file.getParent());Path temporary=Files.createTempFile(file.getParent(),"rare-inventory-",".tmp");try{byte[] bytes=content.getBytes(StandardCharsets.UTF_8);try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE)){var buffer=ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}try{Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);}catch(AtomicMoveNotSupportedException unavailable){throw new IOException("Atomic rare inventory persistence unavailable",unavailable);}DurableFiles.forceDirectory(file.getParent());}finally{Files.deleteIfExists(temporary);}}
|
||||
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){if(value==null)return List.of();if(value instanceof List<?> values)return values;throw new IllegalArgumentException();}
|
||||
private static Map<?,?> map(Object value){if(value instanceof Map<?,?> values)return values;throw new IllegalArgumentException();}
|
||||
private static String text(Map<?,?> map,String key){Object value=map.get(key);if(value instanceof String text)return text;throw new IllegalArgumentException("Invalid "+key);}
|
||||
private static BigDecimal decimal(Map<?,?> map,String key){return map.get(key)==null?null:new BigDecimal(text(map,key));}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
import games.dmg.fantasystick.shop.RareMerchant;import java.io.*;import java.nio.charset.StandardCharsets;import java.nio.file.*;import java.util.*;import org.yaml.snakeyaml.*;import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
/** Forced atomic desired-state adapter for Fantasy Rare merchant registrations. */
|
||||
public final class YamlRareMerchantRepository{
|
||||
private final Path file;public YamlRareMerchantRepository(Path data){file=data.toAbsolutePath().resolve("rare-merchants.yml");}
|
||||
public List<RareMerchant> load()throws IOException{if(Files.notExists(file))return List.of();try{Object decoded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));if(!(decoded instanceof Map<?,?> root)||Integer.parseInt(root.get("schema-version").toString())!=1)throw new IllegalArgumentException();var result=new ArrayList<RareMerchant>();for(Object value:list(root.get("shops"))){var row=map(value);result.add(new RareMerchant(UUID.fromString(text(row,"shop-id")),text(row,"name"),UUID.fromString(text(row,"world")),Double.parseDouble(text(row,"x")),Double.parseDouble(text(row,"y")),Double.parseDouble(text(row,"z")),Float.parseFloat(text(row,"yaw")),Float.parseFloat(text(row,"pitch"))));}validate(result);return List.copyOf(result);}catch(RuntimeException invalid){throw new IOException("Invalid Fantasy Rare merchant file",invalid);}}
|
||||
public void save(List<RareMerchant> values)throws IOException{validate(values);Map<String,Object> root=new LinkedHashMap<>();root.put("schema-version",1);root.put("shops",values.stream().sorted(Comparator.comparing(RareMerchant::id)).map(value->{Map<String,Object> row=new LinkedHashMap<>();row.put("shop-id",value.id().toString());row.put("name",value.name());row.put("world",value.world().toString());row.put("x",Double.toString(value.x()));row.put("y",Double.toString(value.y()));row.put("z",Double.toString(value.z()));row.put("yaw",Float.toString(value.yaw()));row.put("pitch",Float.toString(value.pitch()));return row;}).toList());Files.createDirectories(file.getParent());Path temporary=Files.createTempFile(file.getParent(),"rare-merchants-",".tmp");try{byte[] bytes=yaml().dump(root).getBytes(StandardCharsets.UTF_8);try(var output=java.nio.channels.FileChannel.open(temporary,StandardOpenOption.WRITE)){var buffer=java.nio.ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}try{Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);}catch(AtomicMoveNotSupportedException unsupported){throw new IOException("Atomic Rare merchant persistence unavailable",unsupported);}DurableFiles.forceDirectory(file.getParent());}finally{Files.deleteIfExists(temporary);}}
|
||||
private static void validate(List<RareMerchant> values){var ids=new HashSet<UUID>();if(values.stream().anyMatch(value->!ids.add(value.id())))throw new IllegalArgumentException("Duplicate Rare merchant identity");}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){if(value instanceof List<?> result)return result;throw new IllegalArgumentException();}private static Map<?,?> map(Object value){if(value instanceof Map<?,?> result)return result;throw new IllegalArgumentException();}private static String text(Map<?,?> map,String key){Object value=map.get(key);if(value instanceof String text)return text;throw new IllegalArgumentException();}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Persists one fresh character before handing its authentic sheet to a still-valid native inventory slot. */
|
||||
@@ -22,6 +23,11 @@ public final class AdminCharacterIssuer implements AutoCloseable {
|
||||
private final Consumer<Failure> failures;
|
||||
private final ConcurrentHashMap<UUID, Attempt> pending = new ConcurrentHashMap<>();
|
||||
private volatile boolean closed;
|
||||
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityEvents;
|
||||
|
||||
public void attachIdentityEvents(games.dmg.fantasystick.events.CharacterIdentityEventBridge events) {
|
||||
identityEvents = Objects.requireNonNull(events);
|
||||
}
|
||||
|
||||
public AdminCharacterIssuer(CharacterStorage storage, Supplier<CharacterRecord> allocate,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> failures) {
|
||||
@@ -32,16 +38,17 @@ public final class AdminCharacterIssuer implements AutoCloseable {
|
||||
}
|
||||
|
||||
/** Must be entered on the server thread. Completion is delivered on that thread. */
|
||||
public void issue(Player player, Consumer<Outcome> response) {
|
||||
Objects.requireNonNull(player); Objects.requireNonNull(response);
|
||||
public void issue(CommandSender sender, Player player, Consumer<Outcome> response) {
|
||||
Objects.requireNonNull(sender); Objects.requireNonNull(player); Objects.requireNonNull(response);
|
||||
if (closed || !player.isOnline()) { response.accept(Outcome.STALE); return; }
|
||||
int slot = player.getInventory().firstEmpty();
|
||||
if (slot < 0) { response.accept(Outcome.FULL); return; }
|
||||
var attempt = new Attempt(player, slot, response);
|
||||
var audit = identityEvents;
|
||||
var attempt = new Attempt(player, slot, response, audit == null ? null : audit.issue(sender, player));
|
||||
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);
|
||||
@@ -74,9 +81,15 @@ public final class AdminCharacterIssuer implements AutoCloseable {
|
||||
|| !claimed.orElseThrow().getFirst().id().equals(attempt.record.id())) {
|
||||
abandon(attempt); respond(attempt, Outcome.RETAINED); return;
|
||||
}
|
||||
var savedRecord = claimed.orElseThrow().getFirst();
|
||||
try {
|
||||
var sheet = new CharacterSheets().create(attempt.record);
|
||||
var sheets = new CharacterSheets();
|
||||
var sheet = sheets.create(savedRecord);
|
||||
attempt.player.getInventory().setItem(attempt.slot, sheet);
|
||||
if (!sheets.identify(attempt.player.getInventory().getItem(attempt.slot))
|
||||
.filter(savedRecord.id()::equals).isPresent()) {
|
||||
respond(attempt, Outcome.RETAINED); return;
|
||||
}
|
||||
attempt.delivered = true;
|
||||
} catch (RuntimeException uncertain) {
|
||||
report(attempt, "delivery", uncertain);
|
||||
@@ -85,6 +98,8 @@ public final class AdminCharacterIssuer implements AutoCloseable {
|
||||
}
|
||||
try { attempt.player.updateInventory(); }
|
||||
catch (RuntimeException syncFailure) { report(attempt, "sync", syncFailure); }
|
||||
var events = identityEvents;
|
||||
if (events != null) events.issued(attempt.issue, attempt.player, savedRecord, attempt.slot);
|
||||
respond(attempt, Outcome.DELIVERED);
|
||||
}
|
||||
|
||||
@@ -114,8 +129,12 @@ public final class AdminCharacterIssuer implements AutoCloseable {
|
||||
|
||||
private static final class Attempt {
|
||||
final Player player; final UUID playerId; final int slot; final Consumer<Outcome> response;
|
||||
final games.dmg.fantasystick.events.CharacterIdentityEventBridge.Issue issue;
|
||||
final java.util.concurrent.atomic.AtomicBoolean abandoned = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
CharacterRecord record; CharacterStorage.CraftPreparation preparation; volatile boolean delivered;
|
||||
Attempt(Player player, int slot, Consumer<Outcome> response) { this.player=player; this.playerId=player.getUniqueId(); this.slot=slot; this.response=response; }
|
||||
Attempt(Player player, int slot, Consumer<Outcome> response,
|
||||
games.dmg.fantasystick.events.CharacterIdentityEventBridge.Issue issue) {
|
||||
this.player=player; this.playerId=player.getUniqueId(); this.slot=slot; this.response=response; this.issue=issue;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 208 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user