Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bdb43b2bb | ||
|
|
ae741496b3 | ||
|
|
fa917e57ba | ||
|
|
689877403f | ||
|
|
936e221c81 | ||
|
|
819a966160 | ||
|
|
1926dbb9f7 | ||
|
|
c776dca248 | ||
|
|
bce5391b70 | ||
|
|
f183d7daaa | ||
|
|
c960dbcf7b | ||
|
|
fa87b44cc8 | ||
|
|
70ac5f0801 | ||
|
|
d8efc41fb6 | ||
|
|
185de89c85 | ||
|
|
618a6a2776 | ||
|
|
e308f2f884 | ||
|
|
cf2e013f74 | ||
|
|
720fdc1687 | ||
|
|
4d7ce211f5 | ||
|
|
bca3033fd9 | ||
|
|
1e3c7f28e2 | ||
|
|
dc68811a56 | ||
|
|
afc596e727 | ||
|
|
421e44d86f | ||
|
|
871258070b | ||
|
|
cbd0f70a8b | ||
|
|
7b588e08d2 | ||
|
|
86f248d80c |
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.
|
||||
@@ -136,10 +144,12 @@ Optional `character-swap-cooldown-seconds` defaults to `900`; zero bypasses the
|
||||
|
||||
Administration (permission `fantasystick.admin`, default operators):
|
||||
|
||||
- `/fantasystick character view <online-player>` — opens the exact online account's current active character as a complete read-only virtual book for an in-game administrator.
|
||||
- `/fantasystick inspect <player-uuid>` — asynchronous receipt/native-session inspection.
|
||||
- `/fantasystick recover <player-uuid>` — validated online equipment/game-mode restoration only; no forced overwrite, progression grant or ambiguous replay.
|
||||
- `/fantasystick config adventure-mode [true|false]` — show or persist the policy for new sessions; no change to active sessions.
|
||||
- `/fstickadmin character find <name...>` and `character show <character-uuid>` — asynchronously locate authoritative saved characters and inspect one exact UUID read-only.
|
||||
- `/fstickadmin character view <online-player>` — opens the exact online account's current active character as a complete read-only virtual book for an in-game administrator.
|
||||
- `/fstickadmin character recover <character-uuid> <online-player>` — previews and explicitly confirms a generation-revoking reissue of the same saved character; dead characters use a distinct no-Tail revival action, and unresolved native delivery remains fail-closed.
|
||||
- `/fstickadmin inspect <player-uuid>` — asynchronous receipt/native-session inspection.
|
||||
- `/fstickadmin recover <player-uuid>` — validated online equipment/game-mode restoration only; no forced overwrite, progression grant or ambiguous replay.
|
||||
- `/fstickadmin config adventure-mode [true|false]` — show or persist the policy for new sessions; no change to active sessions.
|
||||
|
||||
Tests use actual native items, inventories, serialization, Paper event dispatch and menu-close behavior, with explicit scheduler/player/world ports. Drag continuation ordering includes a model based on pinned bytecode. The packaged lifecycle fixture isolates the entrypoint but delegates helpers parent-first. Live clients, production PluginClassLoader execution and arbitrary-plugin/crash atomicity are not claimed verified. No deployment or existing-data migration is authorized by story completion.
|
||||
|
||||
@@ -167,17 +177,19 @@ A same-process retry can acknowledge gear already verified restored for that exa
|
||||
|
||||
## Native fantasy health, exit and defeat
|
||||
|
||||
Offhand entry applies actual native maximum health `min(10 + CON, native health limit)`, resumes saved remaining character health, and supplies the recurring blue active-character aura to native viewers who opt in with `/fantasyparticles players on`. The account-owned aura preference defaults to off; `/fantasyparticles players` reports it and `/fantasyparticles players off` hides only this recurring aura. The independent, default-on `/fantasyparticles all [on|off]` preference durably controls every explicit Fantasy Stick particle for that account without changing nearby players' delivery, sounds or gameplay. Turning `all` off temporarily takes precedence without overwriting the saved `players` choice. A fresh character's saved health starts full. CON has no gameplay cap; high values do not reject entry. Native damage/healing rules remain in effect, with the existing party/mixed-mode protection. The book reports current native health rather than waiting for an exit save.
|
||||
Offhand entry applies actual native maximum health `min(10 + CON, native health limit)`, resumes saved remaining character health, and supplies the recurring blue active-character aura to native viewers who opt in with `/fstick particles players on`. The account-owned aura preference defaults to off; `/fstick particles players` reports it and `/fstick particles players off` hides only this recurring aura. The independent, default-on `/fstick particles all [on|off]` preference durably controls every explicit Fantasy Stick particle for that account without changing nearby players' delivery, sounds or gameplay. Turning `all` off temporarily takes precedence without overwriting the saved `players` choice. A fresh character's saved health starts full. CON has no gameplay cap; high values do not reject entry. Native damage/healing rules remain in effect, with the existing party/mixed-mode protection. The book reports current native health rather than waiting for an exit save.
|
||||
|
||||
`NativeMaximumHealth` owns only the effective-value cache/dirty callback of that player's existing native maximum-health instance. Base values, modifiers and native potion expiry remain owned by their original sources; nothing is copied back from an old modifier snapshot. The native player health methods and client attribute snapshot are tested. This adapter uses **pinned Purpur 2618 internals**, including private fields; a detached startup probe exercises installation, recalculation and removal before gameplay is registered. Runtime upgrades require renewed native compatibility verification, not a guessed fallback or global attribute-limit changes. Matching native health scaling displays the real fantasy maximum, not a separate virtual health pool; a later unrelated display-scale change is preserved on exit.
|
||||
|
||||
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,8 +51,21 @@ 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;
|
||||
private games.dmg.fantasystick.persistence.PlayerFeedbackPreferences feedbackPreferences;
|
||||
private games.dmg.fantasystick.session.FantasyFeedbackCommand feedbackCommand;
|
||||
private games.dmg.fantasystick.session.FantasyParticlesCommand particlesCommand;
|
||||
@@ -126,7 +139,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.adventure.AdventureQuestDirectory adventureQuests;
|
||||
private games.dmg.fantasystick.adventure.AdventureQuestController adventureQuestController;
|
||||
private games.dmg.fantasystick.adventure.AdventureQuestCommands adventureQuestCommands;
|
||||
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;
|
||||
@@ -144,6 +162,11 @@ 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.EventCaptureListener eventCapture;
|
||||
private games.dmg.fantasystick.events.DownedEventBridge downedEvents;
|
||||
private games.dmg.fantasystick.events.RareEventBridge rareEvents;
|
||||
private games.dmg.fantasystick.events.BookEventBridge bookEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
|
||||
|
||||
@Override public void onEnable() {
|
||||
@@ -175,6 +198,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,
|
||||
@@ -249,6 +274,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());
|
||||
@@ -263,6 +290,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)),
|
||||
@@ -318,13 +347,18 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
new games.dmg.fantasystick.persistence.YamlPartyRegistrarRepository(getDataFolder().toPath()));
|
||||
trainingDummies = new games.dmg.fantasystick.training.TrainingDummyDirectory(
|
||||
new games.dmg.fantasystick.persistence.YamlTrainingDummyRepository(getDataFolder().toPath()));
|
||||
var adventureRepository=new games.dmg.fantasystick.persistence.YamlAdventureQuestRepository(getDataFolder().toPath());
|
||||
adventureQuests = new games.dmg.fantasystick.adventure.AdventureQuestDirectory(
|
||||
new games.dmg.fantasystick.persistence.YamlAdventureQuestRepository(getDataFolder().toPath()),
|
||||
Clock.systemUTC(), adventureQuestSettings::rules, java.util.random.RandomGenerator.getDefault());
|
||||
adventureRepository, Clock.systemUTC(), adventureQuestSettings::rules, java.util.random.RandomGenerator.getDefault());
|
||||
fishing = new games.dmg.fantasystick.adventure.FishingDirectory(
|
||||
new games.dmg.fantasystick.persistence.YamlFishingRepository(getDataFolder().toPath()));
|
||||
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);
|
||||
}
|
||||
@@ -335,6 +369,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);
|
||||
@@ -364,7 +402,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);
|
||||
@@ -444,6 +484,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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());
|
||||
@@ -554,26 +595,44 @@ 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,event->getServer().getPluginManager().callEvent(event),nextTick,failure->getLogger().log(Level.WARNING,"Rare potion use failed",failure));
|
||||
rareReskills=new games.dmg.fantasystick.shop.RareReskillService(rareInventory,characters,sessions,nextTick,record->{for(String ability:record.learned())abilityEngine.remove(record.id(),ability);speedI.sweep();lowLightVision.sweep();},failure->getLogger().log(Level.WARNING,"Rare Reskill use failed",failure));
|
||||
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);
|
||||
adventureQuestCommands = new games.dmg.fantasystick.adventure.AdventureQuestCommands(
|
||||
this, adventureQuests, adventureQuestController, nextTick);
|
||||
fishing.registerLabels(value->adventureQuests.findSite(value.site()).map(games.dmg.fantasystick.adventure.AdventureQuestState.Site::fishing).filter(java.util.Objects::nonNull).filter(definition->definition.itemId().equals(value.item())).map(games.dmg.fantasystick.adventure.AdventureQuestState.FishingDefinition::itemName).orElse(value.name()));
|
||||
fishingController=new games.dmg.fantasystick.adventure.FishingController(this,adventureQuests,fishing,
|
||||
adventureQuestSettings,sessions,parties,characters,rules,nextTick,java.util.random.RandomGenerator.getDefault(),
|
||||
failure->getLogger().log(Level.WARNING,"Fishing "+failure.phase()+" failed for character "+failure.character()+"; operation "+failure.operation(),failure.cause()));
|
||||
adventureQuestController.registerFishing(fishingController);adventureQuestController.registerResetParticipant(fishingController);books.attachFishingInventory(fishing);
|
||||
sessionEvents.attachNativeUseControl(fishingController::nativeUse);
|
||||
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);
|
||||
var custodyDirectory=dungeons;
|
||||
sessions.attachUnavailableCharacter(character->custodyDirectory.custody(character).isPresent());
|
||||
characters.attachUnavailableCharacter(character->custodyDirectory.custody(character).isPresent());
|
||||
characterParties.attachUnavailableCharacter(character->custodyDirectory.custody(character).isPresent());
|
||||
dungeonController.registerFishing(fishingController);
|
||||
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())).toList());
|
||||
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());
|
||||
adventureQuests.registerDungeonAvailability(site->{var definition=dungeons.find(site.id());if(definition.isEmpty()||!definition.orElseThrow().enabled())return false;var catalog=adventureQuests.snapshot().catalog();return catalog==null||dungeons.runView(site.id(),catalog.revision()).map(run->!run.defeated()).orElse(true);});
|
||||
adventureQuestController.registerResetParticipant(dungeonController);
|
||||
adventureQuestController.registerDungeonRoomPresence((dungeon,location)->location.getWorld()!=null&&dungeons.find(dungeon).filter(value->value.world().equals(location.getWorld().getUID())).isPresent()&&dungeons.roomAt(dungeon,location.getBlockX(),location.getBlockY(),location.getBlockZ()).isPresent());
|
||||
combatExperience.attachExcluded(entity->adventureQuestController.managed(entity)||dungeonController.managed(entity));
|
||||
combatExperience.attachManagedOrigins(entity->adventureQuestController.encounterOrigin(entity).or(()->dungeonController.encounterOrigin(entity)));
|
||||
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,
|
||||
@@ -605,6 +664,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);
|
||||
@@ -618,22 +679,47 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
() -> factory.create(rules), nextTick, problem -> getLogger().log(Level.WARNING,
|
||||
"Admin character issue " + problem.operation() + " for player " + problem.player()
|
||||
+ "; character " + problem.character(), problem.cause())));
|
||||
admin.attachCharacterBooks(books);
|
||||
var command = java.util.Objects.requireNonNull(getCommand("fantasystick"), "Missing packaged fantasystick command");
|
||||
command.setExecutor(admin);
|
||||
command.setTabCompleter(admin);
|
||||
var playerFeedbackCommand = java.util.Objects.requireNonNull(getCommand("fantasyfeedback"),
|
||||
"Missing packaged fantasyfeedback command");
|
||||
playerFeedbackCommand.setExecutor(feedbackCommand);
|
||||
playerFeedbackCommand.setTabCompleter(feedbackCommand);
|
||||
var playerParticlesCommand = java.util.Objects.requireNonNull(getCommand("fantasyparticles"),
|
||||
"Missing packaged fantasyparticles command");
|
||||
playerParticlesCommand.setExecutor(particlesCommand);
|
||||
playerParticlesCommand.setTabCompleter(particlesCommand);
|
||||
admin.attachCharacterBooks(books, characters);
|
||||
characterRecovery = new games.dmg.fantasystick.session.AdminCharacterRecovery(
|
||||
characters, receipts, revivals, sessions, getServer(), nextTick,
|
||||
problem -> getLogger().log(Level.WARNING,
|
||||
"Saved character recovery " + problem.operation() + " for character "
|
||||
+ problem.character() + " and recipient " + problem.recipient(), problem.cause()));
|
||||
admin.attachCharacterRecovery(characterRecovery);
|
||||
var playerRoot = new games.dmg.fantasystick.session.FantasyPlayerCommand(
|
||||
admin, feedbackCommand, particlesCommand);
|
||||
var playerCommand = java.util.Objects.requireNonNull(getCommand("fstick"),
|
||||
"Missing packaged fstick command");
|
||||
playerCommand.setExecutor(playerRoot);
|
||||
playerCommand.setTabCompleter(playerRoot);
|
||||
var adminCommand = java.util.Objects.requireNonNull(getCommand("fstickadmin"),
|
||||
"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());
|
||||
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);
|
||||
sessions.attachDownedEvents(downedEvents);
|
||||
downedController.attachEvents(downedEvents);
|
||||
revive.attachDownedEvents(downedEvents);
|
||||
admin.attachDownedEvents(downedEvents);
|
||||
getServer().getPluginManager().registerEvents(eventCapture, this);
|
||||
} catch (java.io.IOException failure) {
|
||||
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = 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);
|
||||
getServer().getPluginManager().registerEvents(partyCombat, this);
|
||||
@@ -642,18 +728,26 @@ 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();
|
||||
var localArmorMarket=armorMarket;
|
||||
armorMarketPulse=getServer().getScheduler().runTaskTimer(this,()->{if(lifetime!=generation)return;
|
||||
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);
|
||||
@@ -697,6 +791,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
recipeDiscovery.discoverAll(onlinePlayers);
|
||||
for (var player : onlinePlayers) { sessions.arrive(player); revive.reconcile(player); }
|
||||
sheetResourcePack.deliverAll(onlinePlayers);
|
||||
if(eventLog!=null)eventLog.ready();
|
||||
} catch (RuntimeException | LinkageError failure) {
|
||||
try { onDisable(); }
|
||||
catch (RuntimeException | LinkageError cleanupFailure) { failure.addSuppressed(cleanupFailure); }
|
||||
@@ -711,12 +806,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingProtocol = protocolPresentation; protocolPresentation = null;
|
||||
var closingChat = chatNames; chatNames = null;
|
||||
var closingAdmin = admin; admin = null;
|
||||
var closingCharacterRecovery = characterRecovery; characterRecovery = null;
|
||||
var closingFeedbackCommand = feedbackCommand; feedbackCommand = null;
|
||||
var closingParticlesCommand = particlesCommand; particlesCommand = null;
|
||||
var closingFeedbackPreferences = feedbackPreferences; feedbackPreferences = null;
|
||||
particleDelivery = null;
|
||||
combatFeedback = null;
|
||||
var closingEvents = sessionEvents; sessionEvents = null;
|
||||
var closingEventLog = eventLog; eventLog = null;
|
||||
var closingEventCapture = eventCapture; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null;
|
||||
var closingBooks = books; books = null;
|
||||
var closingPartyEvents = partyEvents; partyEvents = null;
|
||||
var closingPartyCombat = partyCombat; partyCombat = null;
|
||||
@@ -729,13 +827,18 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingAdventureQuests = adventureQuests; adventureQuests = null;
|
||||
var closingAdventureQuestController = adventureQuestController; adventureQuestController = null;
|
||||
adventureQuestCommands = null;
|
||||
var closingDungeons=dungeons;dungeons=null;var closingDungeonController=dungeonController;dungeonController=null;dungeonCommands=null;
|
||||
var closingFishing=fishing;fishing=null;var closingFishingController=fishingController;fishingController=null;
|
||||
var closingDungeons=dungeons;dungeons=null;var closingDowned=downed;downed=null;var closingDownedController=downedController;downedController=null;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;
|
||||
@@ -857,13 +960,19 @@ 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);
|
||||
if (closingAdventureQuests != null) cleanup("adventure quest persistence", closingAdventureQuests::close);
|
||||
if (closingAdventureQuestController != null) cleanup("adventure quests", closingAdventureQuestController::close);
|
||||
if(closingFishingController!=null)cleanup("fishing",closingFishingController::close);
|
||||
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);
|
||||
@@ -884,6 +993,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)
|
||||
@@ -892,8 +1006,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
: closingAdventureQuestSettings.drained().toCompletableFuture(),
|
||||
closingAdventureQuests == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingAdventureQuests.drained().toCompletableFuture(),
|
||||
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)
|
||||
@@ -989,21 +1106,33 @@ 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 (closingEventLog != null) cleanup("local events", closingEventLog::close);
|
||||
if (closingVitals != null) cleanup("native health hooks", closingVitals::close);
|
||||
unregister(closingModes);
|
||||
unregister(closingChat);
|
||||
unregister(closingEventCapture);
|
||||
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);
|
||||
@@ -1017,6 +1146,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
unregister(closingGreatSword);
|
||||
unregister(closingRevive);
|
||||
unregister(closingAdminRevival);
|
||||
unregister(closingCharacterRecovery);
|
||||
unregister(closingBasicAttack);
|
||||
unregister(closingNutrition);
|
||||
unregister(closingOutOfCombatRecovery);
|
||||
|
||||
@@ -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()))) {
|
||||
|
||||
@@ -50,6 +50,9 @@ 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 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;
|
||||
@@ -80,6 +83,9 @@ 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 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,6 +106,7 @@ 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 captured = candidates(target);
|
||||
if (captured.isEmpty()) {
|
||||
execution.input().player().sendMessage("That player has no direct-inventory Tail eligible for Revive.");
|
||||
@@ -126,6 +133,8 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
}));
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -270,7 +279,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
try {
|
||||
var provisional = new CharacterRecord(dead.id(), dead.name(), dead.revision(), dead.stats(), dead.level(), dead.experience(),
|
||||
dead.unspentPoints(), dead.maximumHealth(), dead.learned(), dead.loadout(), dead.passives(), dead.cooldowns(), dead.restrictedKills(),
|
||||
false, dead.deathId(), dead.diedAt());
|
||||
false, dead.deathId(), dead.diedAt(), dead.sheetGeneration());
|
||||
ItemStack sheet = sheets.create(provisional);
|
||||
ItemStack source = view.target.getInventory().getItem(reservation.sourceSlot());
|
||||
if (reservation.sourceAmount() == 1) view.target.getInventory().setItem(reservation.sourceSlot(), sheet);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package games.dmg.fantasystick.adventure;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.*;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.command.CommandSender;
|
||||
@@ -11,22 +12,33 @@ public final class AdventureQuestCommands {
|
||||
private static final String PERMISSION="fantasystick.admin";
|
||||
private final Plugin plugin;private final AdventureQuestDirectory directory;private final AdventureQuestController controller;private final Consumer<Runnable> nextTick;private final AdventureLocationPresentation locations;private final AdventureAdminTeleport teleports;
|
||||
public AdventureQuestCommands(Plugin plugin,AdventureQuestDirectory directory,AdventureQuestController controller,Consumer<Runnable> nextTick){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.controller=Objects.requireNonNull(controller);this.nextTick=Objects.requireNonNull(nextTick);locations=new AdventureLocationPresentation(plugin.getServer());teleports=new AdventureAdminTeleport(plugin,nextTick);}
|
||||
public void adventure(CommandSender sender,String[] args){if(args.length==1){if(sender instanceof Player player)controller.inspect(player);else sender.sendMessage("Usage: /fantasystick adventure");return;}if(args.length==2&&args[1].equals("tracking")){if(sender instanceof Player player)controller.tracking(player,null);else sender.sendMessage("Quest tracking requires a player.");return;}if(args.length==3&&args[1].equals("tracking")&&(args[2].equals("on")||args[2].equals("off"))){if(sender instanceof Player player)controller.tracking(player,args[2].equals("on"));else sender.sendMessage("Quest tracking requires a player.");return;}if(args.length==2&&args[1].equals("reroll")){if(!authorized(sender))return;sender.sendMessage("Reroll expires every current adventure quest, encounter and reward claim. Run /fantasystick adventure reroll confirm to reset the day.");return;}if(args.length!=3||!args[1].equals("reroll")||!args[2].equals("confirm")){sender.sendMessage("Usage: /fantasystick adventure [tracking [on|off]|reroll confirm]");return;}if(!authorized(sender))return;var current=directory.snapshot().catalog();UUID expected=current==null?null:current.revision();directory.rerollToday(expected).whenComplete((result,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Adventure reset failed; the current generation is unchanged.");return;}switch(result.status()){case STALE->sender.sendMessage("Adventure reset refused: the generation changed or is still being published; inspect it and try again.");case APPLIED->{var catalog=result.catalog().orElseThrow();controller.publishPendingReset().whenComplete((published,publishFailure)->respond(sender,()->{if(publishFailure!=null||!published){sender.sendMessage("Adventure reset is saved but pending recovery; the new generation is not yet available.");return;}sender.sendMessage("Reset daily adventures: generation="+catalog.revision()+", offers="+catalog.sites().size()+", expires="+catalog.expiresAt()+".");}));}}}));}
|
||||
public void site(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usageSite(sender);return;}switch(args[1]){case"list"->{if(args.length!=2){usageSite(sender);return;}if(directory.snapshot().sites().stream().noneMatch(v->v.type()!=AdventureQuestState.SiteType.DUNGEON))sender.sendMessage("No adventure sites registered.");else directory.snapshot().sites().stream().filter(v->v.type()!=AdventureQuestState.SiteType.DUNGEON).sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.type().name().toLowerCase(Locale.ROOT)+" "+v.displayName()+" at "+locations.format(v.world(),v.x(),v.y(),v.z())+"; "+controller.arena(v).display()));}case"place"->{if(args.length<3||!(args[2].equals("camp")||args[2].equals("champion"))||!(sender instanceof Player player)){usageSite(sender);return;}var placement=placement(args);if(placement==null){usageSite(sender);return;}var location=player.getLocation();var type=args[2].equals("camp")?AdventureQuestState.SiteType.CAMP:AdventureQuestState.SiteType.CHAMPION;directory.placeSite(type,placement.name(),Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),placement.arena()).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Placed "+value.type().name().toLowerCase(Locale.ROOT)+" "+value.id()+" ("+value.displayName()+") at "+locations.format(value.world(),value.x(),value.y(),value.z())+"; "+controller.arena(value).display()+".":"Adventure site save failed; desired state is unchanged.")));}case"remove"->{if(args.length!=3){usageSite(sender);return;}UUID id=parse(args[2]);if(id==null){usageSite(sender);return;}directory.removeSite(id).whenComplete((removed,failure)->respond(sender,()->{if(failure!=null)sender.sendMessage("Adventure site removal failed; desired state is unchanged.");else if(removed.isEmpty())sender.sendMessage("Adventure site not found.");else{controller.removeSiteLoaded(id);sender.sendMessage("Removed adventure site "+id+"; its quests expired.");}}));}case"teleport"->{if(args.length!=3){usageSite(sender);return;}if(!(sender instanceof Player)){sender.sendMessage("Adventure-site teleport requires a player.");return;}UUID id=parse(args[2]);if(id==null){usageSite(sender);return;}var site=site(id).orElse(null);if(site==null){sender.sendMessage("Adventure site not found.");return;}teleports.teleport(sender,"Adventure-site",target(site),()->site(id).map(AdventureQuestCommands::target));}default->usageSite(sender);}}
|
||||
public void adventure(CommandSender sender,String[] args){if(args.length==1){if(sender instanceof Player player)controller.inspect(player);else sender.sendMessage("Usage: /fstick adventure");return;}if(args.length==2&&args[1].equals("tracking")){if(sender instanceof Player player)controller.tracking(player,null);else sender.sendMessage("Quest tracking requires a player.");return;}if(args.length==3&&args[1].equals("tracking")&&(args[2].equals("on")||args[2].equals("off"))){if(sender instanceof Player player)controller.tracking(player,args[2].equals("on"));else sender.sendMessage("Quest tracking requires a player.");return;}if(args.length==2&&args[1].equals("reroll")){if(!authorized(sender))return;sender.sendMessage("Reroll expires every current adventure quest, encounter and reward claim. Run /fstickadmin adventure reroll confirm to reset the day.");return;}if(args.length!=3||!args[1].equals("reroll")||!args[2].equals("confirm")){sender.sendMessage("Usage: /fstick adventure [tracking [on|off]] or /fstickadmin adventure reroll [confirm]");return;}if(!authorized(sender))return;var current=directory.snapshot().catalog();UUID expected=current==null?null:current.revision();directory.rerollToday(expected).whenComplete((result,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Adventure reset failed; the current generation is unchanged.");return;}switch(result.status()){case STALE->sender.sendMessage("Adventure reset refused: the generation changed or is still being published; inspect it and try again.");case APPLIED->{var catalog=result.catalog().orElseThrow();controller.publishPendingReset().whenComplete((published,publishFailure)->respond(sender,()->{if(publishFailure!=null||!published){sender.sendMessage("Adventure reset is saved but pending recovery; the new generation is not yet available.");return;}sender.sendMessage("Reset daily adventures: generation="+catalog.revision()+", offers="+catalog.sites().size()+", expires="+catalog.expiresAt()+".");}));}}}));}
|
||||
public void site(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usageSite(sender);return;}switch(args[1]){case"list"->{if(args.length!=2){usageSite(sender);return;}if(directory.snapshot().sites().stream().noneMatch(v->v.type()!=AdventureQuestState.SiteType.DUNGEON))sender.sendMessage("No adventure sites registered.");else directory.snapshot().sites().stream().filter(v->v.type()!=AdventureQuestState.SiteType.DUNGEON).sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.type().name().toLowerCase(Locale.ROOT)+" "+v.displayName()+" at "+locations.format(v.world(),v.x(),v.y(),v.z())+"; "+controller.arena(v).display()+"; mode="+v.participationMode().value()+siteDetails(v)));}case"place"->{if(args.length<3||!(sender instanceof Player player)){usageSite(sender);return;}if(args[2].equals("fishing")){placeFishing(sender,player,args);return;}if(!(args[2].equals("camp")||args[2].equals("champion"))){usageSite(sender);return;}int start=3;var mode=AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;if(args.length>3&&args[3].equals("--mode")){if(args.length<5){usageSite(sender);return;}try{mode=AdventureQuestState.ParticipationMode.parse(args[4]);}catch(IllegalArgumentException invalid){usageSite(sender);return;}start=5;}var placement=placement(args,start);if(placement==null){usageSite(sender);return;}var location=player.getLocation();var type=args[2].equals("camp")?AdventureQuestState.SiteType.CAMP:AdventureQuestState.SiteType.CHAMPION;directory.placeSite(type,placement.name(),Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),placement.arena(),mode).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Placed "+value.type().name().toLowerCase(Locale.ROOT)+" "+value.id()+" ("+value.displayName()+") at "+locations.format(value.world(),value.x(),value.y(),value.z())+"; "+controller.arena(value).display()+"; mode="+value.participationMode().value()+".":"Adventure site save failed; desired state is unchanged.")));}case"fishing"->{fishing(sender,args);}case"mode"->{if(args.length<3||args.length>4){usageSite(sender);return;}UUID id=parse(args[2]);var current=id==null?null:site(id).orElse(null);if(current==null){sender.sendMessage("Adventure site not found.");return;}if(args.length==3){sender.sendMessage("Adventure site "+id+" mode="+current.participationMode().value()+".");return;}AdventureQuestState.ParticipationMode requested;try{requested=AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){usageSite(sender);return;}directory.setSiteMode(id,current.participationMode(),requested).whenComplete((result,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Adventure site mode save failed; the acknowledged mode is unchanged.");return;}sender.sendMessage(switch(result.status()){case APPLIED->"Updated adventure site "+id+" mode="+result.site().orElseThrow().participationMode().value()+" for future acceptances.";case UNCHANGED->"Adventure site "+id+" mode is already "+requested.value()+".";case STALE->"Adventure site mode update refused: the acknowledged mode changed; inspect and retry.";case NOT_FOUND->"Adventure site not found.";});}));}case"remove"->{if(args.length!=3){usageSite(sender);return;}UUID id=parse(args[2]);if(id==null){usageSite(sender);return;}directory.removeSite(id).whenComplete((removed,failure)->respond(sender,()->{if(failure!=null)sender.sendMessage("Adventure site removal failed; desired state is unchanged.");else if(removed.isEmpty())sender.sendMessage("Adventure site not found.");else{controller.removeSiteLoaded(id);sender.sendMessage("Removed adventure site "+id+"; its quests expired.");}}));}case"teleport"->{if(args.length!=3){usageSite(sender);return;}if(!(sender instanceof Player)){sender.sendMessage("Adventure-site teleport requires a player.");return;}UUID id=parse(args[2]);if(id==null){usageSite(sender);return;}var site=site(id).orElse(null);if(site==null){sender.sendMessage("Adventure site not found.");return;}teleports.teleport(sender,"Adventure-site",target(site),()->site(id).map(AdventureQuestCommands::target));}default->usageSite(sender);}}
|
||||
public void giver(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usageGiver(sender);return;}switch(args[1]){case"list"->{if(args.length!=2){usageGiver(sender);return;}if(directory.snapshot().givers().isEmpty())sender.sendMessage("No Quest Givers registered.");else directory.snapshot().givers().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.displayName()+" at "+locations.format(v.world(),v.x(),v.y(),v.z())));}case"place"->{if(!(sender instanceof Player player)){usageGiver(sender);return;}String name=join(args,2);var location=player.getLocation();directory.placeGiver(name,Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Quest Giver save failed; desired state is unchanged.");return;}var world=plugin.getServer().getWorld(value.world());if(world!=null&&world.isChunkLoaded(section(value.x()),section(value.z())))controller.reconcile(world.getChunkAt(section(value.x()),section(value.z())));sender.sendMessage("Placed Quest Giver "+value.id()+" ("+value.displayName()+") at "+locations.format(value.world(),value.x(),value.y(),value.z())+".");}));}case"remove"->{if(args.length!=3){usageGiver(sender);return;}UUID id=parse(args[2]);if(id==null){usageGiver(sender);return;}directory.removeGiver(id).whenComplete((removed,failure)->respond(sender,()->{if(failure!=null)sender.sendMessage("Quest Giver removal failed; desired state is unchanged.");else if(removed.isEmpty())sender.sendMessage("Quest Giver not found.");else{controller.removeGiverLoaded(id);sender.sendMessage("Removed Quest Giver "+id+".");}}));}default->usageGiver(sender);}}
|
||||
public List<String> complete(CommandSender sender,String[] args){if(args.length==1){var values=new ArrayList<String>();values.add("adventure");if(sender.hasPermission(PERMISSION)){values.add("adventure-site");values.add("quest-giver");}return matching(values,args[0]);}if(args.length==2&&args[0].equals("adventure")){var values=new ArrayList<String>();if(sender instanceof Player player&&controller.trackingAvailable(player))values.add("tracking");if(sender.hasPermission(PERMISSION))values.add("reroll");return matching(values,args[1]);}if(args.length==3&&args[0].equals("adventure")&&args[1].equals("tracking")&&sender instanceof Player player&&controller.trackingAvailable(player))return matching(List.of("on","off"),args[2]);if(!sender.hasPermission(PERMISSION))return List.of();if(args.length==3&&args[0].equals("adventure")&&args[1].equals("reroll"))return matching(List.of("confirm"),args[2]);if(args.length==2&&args[0].equals("adventure-site"))return matching(sender instanceof Player?List.of("list","place","remove","teleport"):List.of("list","remove"),args[1]);if(args.length==3&&args[0].equals("adventure-site")&&args[1].equals("place"))return matching(List.of("camp","champion"),args[2]);if(args.length==4&&args[0].equals("adventure-site")&&args[1].equals("place")&&(args[2].equals("camp")||args[2].equals("champion")))return matching(List.of("4","16","64"),args[3]);if(args.length==5&&args[0].equals("adventure-site")&&args[1].equals("place")&&whole(args[3])!=null)return matching(List.of("8","16","128"),args[4]);if(args.length==3&&args[0].equals("adventure-site")&&Set.of("remove","teleport").contains(args[1]))return matching(directory.snapshot().sites().stream().filter(v->v.type()!=AdventureQuestState.SiteType.DUNGEON).map(v->v.id().toString()).toList(),args[2]);if(args.length==2&&args[0].equals("quest-giver"))return matching(sender instanceof Player?List.of("list","place","remove"):List.of("list","remove"),args[1]);if(args.length==3&&args[0].equals("quest-giver")&&args[1].equals("remove"))return matching(directory.snapshot().givers().stream().map(v->v.id().toString()).toList(),args[2]);return List.of();}
|
||||
public List<String> complete(CommandSender sender,String[] args){if(args.length==1){var values=new ArrayList<String>();values.add("adventure");if(sender.hasPermission(PERMISSION)){values.add("adventure-site");values.add("quest-giver");}return matching(values,args[0]);}if(args.length==2&&args[0].equals("adventure")){var values=new ArrayList<String>();if(sender instanceof Player player&&controller.trackingAvailable(player))values.add("tracking");if(sender.hasPermission(PERMISSION))values.add("reroll");return matching(values,args[1]);}if(args.length==3&&args[0].equals("adventure")&&args[1].equals("tracking")&&sender instanceof Player player&&controller.trackingAvailable(player))return matching(List.of("on","off"),args[2]);if(!sender.hasPermission(PERMISSION))return List.of();if(args.length==3&&args[0].equals("adventure")&&args[1].equals("reroll"))return matching(List.of("confirm"),args[2]);if(args.length==2&&args[0].equals("adventure-site"))return matching(sender instanceof Player?List.of("fishing","list","mode","place","remove","teleport"):List.of("fishing","list","mode","remove"),args[1]);if(args.length==3&&args[0].equals("adventure-site")&&args[1].equals("place"))return matching(List.of("camp","champion","fishing"),args[2]);if(args.length>=4&&args[0].equals("adventure-site")&&args[1].equals("place")&&args[2].equals("fishing"))return fishingOptionCompletion(args);if(args.length==4&&args[0].equals("adventure-site")&&args[1].equals("place")&&(args[2].equals("camp")||args[2].equals("champion"))){var values=new ArrayList<>(List.of("--mode","4","16","64"));return matching(values,args[3]);}if(args.length==5&&args[0].equals("adventure-site")&&args[1].equals("place")&&args[3].equals("--mode"))return matching(modeValues(),args[4]);if(args.length==6&&args[0].equals("adventure-site")&&args[1].equals("place")&&args[3].equals("--mode"))return matching(List.of("4","16","64"),args[5]);if(args.length==5&&args[0].equals("adventure-site")&&args[1].equals("place")&&whole(args[3])!=null)return matching(List.of("8","16","128"),args[4]);if(args.length==7&&args[0].equals("adventure-site")&&args[1].equals("place")&&args[3].equals("--mode")&&whole(args[5])!=null)return matching(List.of("8","16","128"),args[6]);if(args.length==3&&args[0].equals("adventure-site")&&Set.of("fishing","mode","remove","teleport").contains(args[1]))return matching(directory.snapshot().sites().stream().filter(v->v.type()!=AdventureQuestState.SiteType.DUNGEON).map(v->v.id().toString()).toList(),args[2]);if(args.length==4&&args[0].equals("adventure-site")&&args[1].equals("mode"))return matching(modeValues(),args[3]);if(args.length>=4&&args[0].equals("adventure-site")&&args[1].equals("fishing"))return fishingOptionCompletion(args);if(args.length==2&&args[0].equals("quest-giver"))return matching(sender instanceof Player?List.of("list","place","remove"):List.of("list","remove"),args[1]);if(args.length==3&&args[0].equals("quest-giver")&&args[1].equals("remove"))return matching(directory.snapshot().givers().stream().map(v->v.id().toString()).toList(),args[2]);return List.of();}
|
||||
private void placeFishing(CommandSender sender,Player player,String[] args){var options=options(args,3,Set.of("name","item","required","chance","xp","coins","radius","mode"));if(options==null||!options.keySet().containsAll(Set.of("name","item","required","chance","xp","coins"))){usageSite(sender);return;}try{int radius=options.containsKey("radius")?bounded(options.get("radius"),4,64):directory.defaultRadius();var mode=options.containsKey("mode")?AdventureQuestState.ParticipationMode.parse(options.get("mode")):AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;var location=player.getLocation();directory.placeFishingSite(options.get("name"),options.get("item"),Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),radius,percent(options.get("chance")),positive(options.get("required")),nonnegative(options.get("xp")),nonnegative(options.get("coins")),mode).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Placed fishing site "+value.id()+" ("+value.displayName()+"); "+fishingDetails(value)+".":"Fishing site save failed; desired state is unchanged.")));}catch(IllegalArgumentException invalid){usageSite(sender);}}
|
||||
private void fishing(CommandSender sender,String[] args){if(args.length<3){usageSite(sender);return;}UUID id=parse(args[2]);var current=id==null?null:directory.findSite(id).filter(value->value.type()==AdventureQuestState.SiteType.FISHING).orElse(null);if(current==null){sender.sendMessage("Fishing site not found.");return;}if(args.length==3){sender.sendMessage(current.id()+" "+current.displayName()+"; "+fishingDetails(current)+".");return;}var options=options(args,3,Set.of("name","item","required","chance","xp","coins","radius","mode"));if(options==null||options.isEmpty()){usageSite(sender);return;}try{var before=current.fishing();var definition=new AdventureQuestState.FishingDefinition(before.itemId(),options.getOrDefault("item",before.itemName()),options.containsKey("required")?positive(options.get("required")):before.required(),options.containsKey("chance")?percent(options.get("chance")):before.chancePercent(),options.containsKey("xp")?nonnegative(options.get("xp")):before.rewardXp(),options.containsKey("coins")?nonnegative(options.get("coins")):before.rewardCoins(),options.containsKey("radius")?bounded(options.get("radius"),4,64):before.radius());var mode=options.containsKey("mode")?AdventureQuestState.ParticipationMode.parse(options.get("mode")):current.participationMode();directory.updateFishingSite(id,current,options.getOrDefault("name",current.name()),definition,mode).whenComplete((result,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Fishing site update failed; the acknowledged definition is unchanged.");return;}sender.sendMessage(switch(result.status()){case APPLIED->"Updated fishing site "+id+"; "+fishingDetails(result.site().orElseThrow())+".";case UNCHANGED->"Fishing site "+id+" is unchanged.";case STALE->"Fishing site update refused: the acknowledged definition changed; inspect and retry.";case NOT_FOUND->"Fishing site not found.";});}));}catch(IllegalArgumentException invalid){usageSite(sender);}}
|
||||
private static String siteDetails(AdventureQuestState.Site site){return site.fishing()==null?"":"; "+fishingDetails(site);}
|
||||
private static String fishingDetails(AdventureQuestState.Site site){var value=site.fishing();return "item=\""+value.itemName()+"\", required="+value.required()+", chance="+value.chancePercent().toPlainString()+"%, reward="+value.rewardXp()+" XP/"+value.rewardCoins()+" coins, radius="+value.radius()+", mode="+site.participationMode().value();}
|
||||
private static Map<String,String> options(String[] args,int start,Set<String> allowed){var result=new LinkedHashMap<String,String>();for(int index=start;index<args.length;index++){String raw=args[index];if(!raw.startsWith("--")||!allowed.contains(raw.substring(2))||result.containsKey(raw.substring(2))||index+1>=args.length)return null;String key=raw.substring(2),value=args[++index];if(value.startsWith("\"")){var joined=new StringBuilder(value);while(!joined.toString().endsWith("\"")&&index+1<args.length)joined.append(' ').append(args[++index]);value=joined.toString();if(value.length()<2||!value.endsWith("\""))return null;value=value.substring(1,value.length()-1);}if(value.isBlank()||value.startsWith("--"))return null;result.put(key,value);}return result;}
|
||||
private static java.math.BigDecimal percent(String value){if(!value.matches("(?:0|[1-9][0-9]*)(?:\\.[0-9]{1,6})?"))throw new IllegalArgumentException();var result=new java.math.BigDecimal(value);if(result.compareTo(java.math.BigDecimal.valueOf(100))>0)throw new IllegalArgumentException();return result;}
|
||||
private static BigInteger positive(String value){var result=nonnegative(value);if(result.signum()==0)throw new IllegalArgumentException();return result;}
|
||||
private static BigInteger nonnegative(String value){if(!value.matches("(?:0|[1-9][0-9]*)"))throw new IllegalArgumentException();var result=new BigInteger(value);if(result.compareTo(BigInteger.valueOf(Integer.MAX_VALUE))>0)throw new IllegalArgumentException();return result;}
|
||||
private static int bounded(String value,int minimum,int maximum){var parsed=nonnegative(value);if(parsed.compareTo(BigInteger.valueOf(minimum))<0||parsed.compareTo(BigInteger.valueOf(maximum))>0)throw new IllegalArgumentException();return parsed.intValueExact();}
|
||||
private static List<String> fishingOptionCompletion(String[] args){String current=args[args.length-1];if(args.length>=2){String previous=args[args.length-2];if(previous.equals("--mode"))return matching(modeValues(),current);if(previous.equals("--radius"))return matching(List.of("4","16","64"),current);if(previous.equals("--chance"))return matching(List.of("0","20","100"),current);if(previous.equals("--required"))return matching(List.of("1","5","2147483647"),current);if(previous.equals("--xp")||previous.equals("--coins"))return matching(List.of("0","10","100","2147483647"),current);}var used=Arrays.stream(args).filter(value->value.startsWith("--")).collect(java.util.stream.Collectors.toSet());return matching(List.of("--chance","--coins","--item","--mode","--name","--radius","--required","--xp").stream().filter(value->!used.contains(value)).toList(),current);}
|
||||
private Optional<AdventureQuestState.Site> site(UUID id){return directory.findSite(id).filter(value->value.type()!=AdventureQuestState.SiteType.DUNGEON);}
|
||||
private static AdventureAdminTeleport.Target target(AdventureQuestState.Site site){return new AdventureAdminTeleport.Target(site.id(),site.displayName(),site.type()==AdventureQuestState.SiteType.CAMP?"monster camp":"champion",site.world(),site.x(),site.y(),site.z(),0,0,true);}
|
||||
private static AdventureAdminTeleport.Target target(AdventureQuestState.Site site){return new AdventureAdminTeleport.Target(site.id(),site.displayName(),switch(site.type()){case CAMP->"monster camp";case CHAMPION->"champion";case FISHING->"fishing site";case DUNGEON->"dungeon entrance";},site.world(),site.x(),site.y(),site.z(),0,0,true);}
|
||||
private boolean authorized(CommandSender sender){if(sender.hasPermission(PERMISSION))return true;sender.sendMessage("Permission denied: you need fantasystick.admin to administer Fantasy Stick.");return false;}
|
||||
private void respond(CommandSender sender,Runnable response){try{nextTick.accept(()->{if(sender.hasPermission(PERMISSION)&&(!(sender instanceof Player player)||player.isOnline()))response.run();});}catch(RuntimeException ignored){}}
|
||||
private record Placement(String name,AdventureQuestState.Arena arena){}
|
||||
private static Placement placement(String[] args){if(args.length==3)return new Placement("",null);Integer first=whole(args[3]);if(first==null)return looksNumeric(args[3])?null:new Placement(join(args,3),null);if(args.length>4){Integer second=whole(args[4]);if(second!=null)return first>=8&&first<=128&&second>=8&&second<=128?new Placement(join(args,5),AdventureQuestState.Arena.rectangle(first,second)):null;if(looksNumeric(args[4]))return null;}return first>=4&&first<=64?new Placement(join(args,4),AdventureQuestState.Arena.circle(first)):null;}
|
||||
private static Placement placement(String[] args,int start){if(args.length==start)return new Placement("",null);if(Arrays.stream(args,start,args.length).anyMatch(value->value.startsWith("--")))return null;Integer first=whole(args[start]);if(first==null)return looksNumeric(args[start])||args[start].startsWith("--")?null:new Placement(join(args,start),null);if(args.length>start+1){Integer second=whole(args[start+1]);if(second!=null)return first>=8&&first<=128&&second>=8&&second<=128?new Placement(join(args,start+2),AdventureQuestState.Arena.rectangle(first,second)):null;if(looksNumeric(args[start+1]))return null;}return first>=4&&first<=64?new Placement(join(args,start+1),AdventureQuestState.Arena.circle(first)):null;}
|
||||
private static List<String> modeValues(){return Arrays.stream(AdventureQuestState.ParticipationMode.values()).map(AdventureQuestState.ParticipationMode::value).toList();}
|
||||
private static Integer whole(String value){try{return Integer.valueOf(value);}catch(NumberFormatException invalid){return null;}}
|
||||
private static boolean looksNumeric(String value){return value.matches("[+-]?(?:\\d.*|\\.\\d+)");}
|
||||
private static String join(String[] args,int start){return start>=args.length?"":String.join(" ",Arrays.copyOfRange(args,start,args.length));}
|
||||
private static UUID parse(String value){try{var id=UUID.fromString(value);return id.toString().equals(value)?id:null;}catch(IllegalArgumentException invalid){return null;}}
|
||||
private static int section(double coordinate){return((int)Math.floor(coordinate))>>4;}
|
||||
private static List<String> matching(Collection<String> values,String prefix){return values.stream().filter(v->v.startsWith(prefix)).distinct().sorted().toList();}
|
||||
private static void usageSite(CommandSender sender){sender.sendMessage("Usage: /fantasystick adventure-site <place <camp|champion> [<radius>|<x-blocks> <z-blocks>] [name...]|remove <site-uuid>|teleport <site-uuid>|list>");}
|
||||
private static void usageGiver(CommandSender sender){sender.sendMessage("Usage: /fantasystick quest-giver <place [name...]|remove <giver-uuid>|list>");}
|
||||
private static void usageSite(CommandSender sender){sender.sendMessage("Usage: /fstickadmin adventure-site <place <camp|champion> [--mode <mode>] [<radius>|<x-blocks> <z-blocks>] [name...]|place fishing --name <name> --item <item> --required <quantity> --chance <percent> --xp <xp> --coins <coins> [--radius <blocks>] [--mode <mode>]|fishing <site-uuid> [options]|mode <site-uuid> [<mode>]|remove <site-uuid>|teleport <site-uuid>|list>");}
|
||||
private static void usageGiver(CommandSender sender){sender.sendMessage("Usage: /fstickadmin quest-giver <place [name...]|remove <giver-uuid>|list>");}
|
||||
}
|
||||
@@ -34,7 +34,8 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
|
||||
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
|
||||
private final AdventureQuestNavigation navigation;private final EquipmentSessions sessions;private final PartyRoster parties;private final CharacterStorage characters;private final ProgressionRules progression;
|
||||
private final Consumer<Runnable> nextTick;private final Clock clock;private final java.util.random.RandomGenerator random;
|
||||
private final NamespacedKey typeKey,idKey,siteKey,catalogKey,menuSiteKey,menuCatalogKey,menuPageKey;
|
||||
private final NamespacedKey typeKey,idKey,siteKey,catalogKey,attemptKey,encounterKindKey,menuSiteKey,menuCatalogKey,menuPageKey,menuFishingKey;
|
||||
private FishingController fishing;
|
||||
private final Set<Entity> relocating=Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
private final Map<UUID,Attempt> attempts=new HashMap<>();private final Map<UUID,Instant> readyAt=new HashMap<>();private final Set<UUID> starting=new HashSet<>();
|
||||
private final List<AdventureResetParticipant> resetParticipants=new ArrayList<>();private CompletableFuture<Boolean> resetWork;
|
||||
@@ -43,34 +44,37 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
|
||||
private static final class MenuHolder implements InventoryHolder {final UUID catalog;final int page;MenuHolder(UUID catalog,int page){this.catalog=catalog;this.page=page;}@Override public Inventory getInventory(){return null;}}
|
||||
private static final class Attempt {final UUID id=UUID.randomUUID(),site,catalog;final AdventureQuestState.SiteType type;final Set<UUID> entities=new HashSet<>(),represented=new HashSet<>();final AdventureQuestState.Arena arena;final int resetSeconds;BossBar bossBar;LivingEntity champion;Instant absentSince;Attempt(UUID site,UUID catalog,AdventureQuestState.SiteType type,AdventureQuestState.Arena arena,int resetSeconds){this.site=site;this.catalog=catalog;this.type=type;this.arena=arena;this.resetSeconds=resetSeconds;}}
|
||||
public AdventureQuestController(Plugin plugin,AdventureQuestDirectory directory,AdventureQuestSettings settings,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,Clock clock,java.util.random.RandomGenerator random){this(plugin,directory,settings,sessions,parties,characters,progression,nextTick,clock,random,games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());}
|
||||
public AdventureQuestController(Plugin plugin,AdventureQuestDirectory directory,AdventureQuestSettings settings,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,Clock clock,java.util.random.RandomGenerator random,games.dmg.fantasystick.presentation.ParticleDelivery particles){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.settings=Objects.requireNonNull(settings);this.particles=Objects.requireNonNull(particles);navigation=new AdventureQuestNavigation(plugin.getServer());this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.characters=Objects.requireNonNull(characters);this.progression=Objects.requireNonNull(progression);this.nextTick=Objects.requireNonNull(nextTick);this.clock=Objects.requireNonNull(clock);this.random=Objects.requireNonNull(random);typeKey=new NamespacedKey(plugin,"managed_npc_type");idKey=new NamespacedKey(plugin,"quest_giver_id");siteKey=new NamespacedKey(plugin,"adventure_site_id");catalogKey=new NamespacedKey(plugin,"adventure_catalog_id");menuSiteKey=new NamespacedKey(plugin,"quest_offer_site");menuCatalogKey=new NamespacedKey(plugin,"quest_offer_catalog");menuPageKey=new NamespacedKey(plugin,"quest_offer_page");}
|
||||
public AdventureQuestController(Plugin plugin,AdventureQuestDirectory directory,AdventureQuestSettings settings,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,Clock clock,java.util.random.RandomGenerator random,games.dmg.fantasystick.presentation.ParticleDelivery particles){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.settings=Objects.requireNonNull(settings);this.particles=Objects.requireNonNull(particles);navigation=new AdventureQuestNavigation(plugin.getServer());this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.characters=Objects.requireNonNull(characters);this.progression=Objects.requireNonNull(progression);this.nextTick=Objects.requireNonNull(nextTick);this.clock=Objects.requireNonNull(clock);this.random=Objects.requireNonNull(random);typeKey=new NamespacedKey(plugin,"managed_npc_type");idKey=new NamespacedKey(plugin,"quest_giver_id");siteKey=new NamespacedKey(plugin,"adventure_site_id");catalogKey=new NamespacedKey(plugin,"adventure_catalog_id");attemptKey=new NamespacedKey(plugin,"adventure_attempt_id");encounterKindKey=new NamespacedKey(plugin,"adventure_encounter_kind");menuSiteKey=new NamespacedKey(plugin,"quest_offer_site");menuCatalogKey=new NamespacedKey(plugin,"quest_offer_catalog");menuPageKey=new NamespacedKey(plugin,"quest_offer_page");menuFishingKey=new NamespacedKey(plugin,"quest_fishing_turn_in");}
|
||||
public void install(){for(var world:plugin.getServer().getWorlds()){var chunks=world.getLoadedChunks();if(chunks!=null)for(var chunk:chunks)reconcile(chunk);}pulseTask=plugin.getServer().getScheduler().runTaskTimer(plugin,this::pulse,20,20);publishPendingReset();}
|
||||
public boolean giver(Entity entity){var data=entity.getPersistentDataContainer();return data!=null&&GIVER.equals(data.get(typeKey,PersistentDataType.STRING));}
|
||||
public boolean enemy(Entity entity){var data=entity.getPersistentDataContainer();return data!=null&&ENEMY.equals(data.get(typeKey,PersistentDataType.STRING));}
|
||||
public boolean managed(Entity entity){var data=entity.getPersistentDataContainer();return enemy(entity)||data!=null&&PROJECTILE.equals(data.get(typeKey,PersistentDataType.STRING));}
|
||||
public Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin> encounterOrigin(Entity entity){if(!enemy(entity))return Optional.empty();var data=entity.getPersistentDataContainer();UUID site=site(entity),catalog=parse(data.get(catalogKey,PersistentDataType.STRING)),source=parse(data.get(attemptKey,PersistentDataType.STRING));String encoded=data.get(encounterKindKey,PersistentDataType.STRING);if(site==null||catalog==null||source==null||encoded==null)return Optional.empty();games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind kind;try{kind=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.valueOf(encoded);}catch(IllegalArgumentException invalid){return Optional.empty();}if(kind!=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CHAMPION&&kind!=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CAMP)return Optional.empty();var active=attempts.get(site);if(active!=null&&(!active.catalog.equals(catalog)||!active.id.equals(source)))return Optional.empty();return Optional.of(new games.dmg.fantasystick.progression.ManagedEncounterOrigin(kind,source));}
|
||||
public void reconcile(Chunk chunk){if(closed)return;var desired=new HashMap<UUID,AdventureQuestState.Giver>();for(var giver:directory.snapshot().givers())if(giver.world().equals(chunk.getWorld().getUID())&§ion(giver.x())==chunk.getX()&§ion(giver.z())==chunk.getZ())desired.put(giver.id(),giver);var retained=new HashSet<UUID>();for(var entity:chunk.getEntities()){if(managed(entity)){entity.remove();continue;}if(!giver(entity))continue;UUID id=id(entity,idKey);var value=id==null?null:desired.get(id);if(!(entity instanceof Villager villager)||value==null||!retained.add(id)){entity.remove();continue;}configure(villager,value);var expected=location(value);var current=villager.getLocation();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();spawnGiver(value);}}}for(var value:desired.values())if(!retained.contains(value.id()))spawnGiver(value);}
|
||||
public void removeGiverLoaded(UUID id){for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(giver(entity)&&id.equals(id(entity,idKey)))entity.remove();}
|
||||
public void removeSiteLoaded(UUID id){var attempt=attempts.remove(id);if(attempt!=null)cleanup(attempt);readyAt.remove(id);refreshTrackingPlayers();}
|
||||
public boolean hasActiveEncounter(){return !attempts.isEmpty()||!starting.isEmpty();}
|
||||
public AdventureQuestState.Arena arena(AdventureQuestState.Site site){return site.arenaOrDefault(settings.rules().radius());}
|
||||
public void registerFishing(FishingController fishing){if(closed||this.fishing!=null)throw new IllegalStateException("Fishing already attached or adventure controller closed");this.fishing=Objects.requireNonNull(fishing);}
|
||||
public void registerResetParticipant(AdventureResetParticipant participant){if(closed)throw new IllegalStateException("Adventure controller closed");resetParticipants.add(Objects.requireNonNull(participant));}
|
||||
public void registerDungeonRoomPresence(java.util.function.BiPredicate<UUID,Location> presence){if(closed)throw new IllegalStateException("Adventure controller closed");dungeonRoomPresence=Objects.requireNonNull(presence);}
|
||||
public CompletionStage<Boolean> publishPendingReset(){var reset=directory.snapshot().reset();if(reset==null)return CompletableFuture.completedFuture(true);if(resetWork!=null)return resetWork;for(var attempt:new ArrayList<>(attempts.values()))cleanup(attempt);attempts.clear();starting.clear();readyAt.clear();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(managed(entity))entity.remove();refreshTrackingPlayers();var work=resetParticipants.stream().map(participant->{try{return participant.reset(reset).toCompletableFuture();}catch(RuntimeException failure){return CompletableFuture.<Void>failedFuture(failure);}}).toArray(CompletableFuture[]::new);resetWork=CompletableFuture.allOf(work).thenCompose(ignored->directory.completeReset(reset.operation())).whenComplete((ignored,failure)->resetWork=null);return resetWork;}
|
||||
private void spawnGiver(AdventureQuestState.Giver value){var world=plugin.getServer().getWorld(value.world());if(world==null||!world.isChunkLoaded(section(value.x()),section(value.z())))return;world.spawn(location(value),Villager.class,villager->configure(villager,value));}
|
||||
private void configure(Villager villager,AdventureQuestState.Giver value){var data=villager.getPersistentDataContainer();data.set(typeKey,PersistentDataType.STRING,GIVER);data.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.displayName()));villager.setCustomNameVisible(true);}
|
||||
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 Giver refused: actively play a living fantasy character first.");return;}var pending=directory.pendingClaim(context.character);if(pending.isPresent()){var record=sessions.currentRecord(context.active).orElse(null);if(record==null){player.sendMessage("Reward claim recovery requires the exact active character.");return;}attemptClaim(player,context.active,pending.orElseThrow(),record);return;}directory.ensureToday().whenComplete((catalog,failure)->schedule(()->{var currentContext=context(player);if(failure!=null||currentContext==null||!currentContext.active.equals(context.active)){player.sendMessage("Daily quests are unavailable.");return;}if(directory.snapshot().reset()!=null){publishPendingReset();player.sendMessage("Daily adventure reset is completing; try again.");return;}int pages=Math.max(1,(catalog.sites().size()+44)/45),page=Math.max(0,Math.min(requestedPage,pages-1));int count=Math.min(45,Math.max(0,catalog.sites().size()-page*45));int size=pages>1?54:Math.max(9,((count+8)/9)*9);var inventory=plugin.getServer().createInventory(new MenuHolder(catalog.revision(),page),size,net.kyori.adventure.text.Component.text("Daily Adventure Quests"));int slot=0;for(UUID siteId:catalog.sites().stream().skip(page*45L).limit(45).toList()){var site=directory.findSite(siteId).orElse(null);var item=new ItemStack(Material.PAPER);var meta=item.getItemMeta();String name=site==null?"Unavailable site":site.displayName();meta.displayName(net.kyori.adventure.text.Component.text(name));var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog.revision(),siteId,context.character);boolean rewardsReady=unclaimed.xp().signum()>0||unclaimed.coins().signum()>0;String state=rewardsReady?"Rewards ready — click to claim":active!=null&&active.site().equals(siteId)?"Active — right-click to abandon":active!=null?"Another quest is active":directory.characterClaimed(catalog.revision(),siteId,context.character)?directory.rewardClaimed(catalog.revision(),siteId,context.character)?"Already claimed":"Completed with nothing owed":site==null||!directory.offerOpen(siteId)||!directory.available(site)?"Unavailable / cleared":"Available — click to accept";String kind=site==null?"Unavailable":switch(site.type()){case CAMP->"Monster camp";case CHAMPION->"Champion";case DUNGEON->"Dungeon (maximum)";};var reward=site==null?new AdventureQuestDirectory.Reward(BigInteger.ZERO,BigInteger.ZERO):directory.reward(site);var rewardXp=rewardsReady?unclaimed.xp():active!=null&&active.site().equals(siteId)?active.rewardXp():reward.xp();var rewardCoins=rewardsReady?unclaimed.coins():active!=null&&active.site().equals(siteId)?active.rewardCoins():reward.coins();String rewardLabel=rewardsReady?"Banked reward: ":site!=null&&site.type()==AdventureQuestState.SiteType.DUNGEON?"Maximum reward: ":"Reward: ";meta.lore(List.of(net.kyori.adventure.text.Component.text(kind),net.kyori.adventure.text.Component.text(rewardLabel+rewardXp+" XP, "+rewardCoins+" coins"),net.kyori.adventure.text.Component.text("Expires: "+catalog.expiresAt()),net.kyori.adventure.text.Component.text(state)));meta.getPersistentDataContainer().set(menuSiteKey,PersistentDataType.STRING,siteId.toString());meta.getPersistentDataContainer().set(menuCatalogKey,PersistentDataType.STRING,catalog.revision().toString());item.setItemMeta(meta);inventory.setItem(slot++,item);}if(pages>1){if(page>0)inventory.setItem(45,pageItem(page-1,"Previous page"));if(page+1<pages)inventory.setItem(53,pageItem(page+1,"Next page"));}player.openInventory(inventory);}));}
|
||||
private void open(Player player,int requestedPage){var context=context(player);if(context==null){player.sendMessage("Quest Giver refused: actively play a living fantasy character first.");return;}var pending=directory.pendingClaim(context.character);if(pending.isPresent()){var record=sessions.currentRecord(context.active).orElse(null);if(record==null){player.sendMessage("Reward claim recovery requires the exact active character.");return;}attemptClaim(player,context.active,pending.orElseThrow(),record);return;}directory.ensureToday().whenComplete((catalog,failure)->schedule(()->{var currentContext=context(player);if(failure!=null||currentContext==null||!currentContext.active.equals(context.active)){player.sendMessage("Daily quests are unavailable.");return;}if(directory.snapshot().reset()!=null){publishPendingReset();player.sendMessage("Daily adventure reset is completing; try again.");return;}int pages=Math.max(1,(catalog.sites().size()+44)/45),page=Math.max(0,Math.min(requestedPage,pages-1));int count=Math.min(45,Math.max(0,catalog.sites().size()-page*45));int size=pages>1?54:Math.min(54,Math.max(18,((count+8)/9)*9+9));var inventory=plugin.getServer().createInventory(new MenuHolder(catalog.revision(),page),size,net.kyori.adventure.text.Component.text("Daily Adventure Quests"));int slot=0;for(UUID siteId:catalog.sites().stream().skip(page*45L).limit(45).toList()){var site=directory.findSite(siteId).orElse(null);var item=new ItemStack(Material.PAPER);var meta=item.getItemMeta();String name=site==null?"Unavailable site":site.displayName();meta.displayName(net.kyori.adventure.text.Component.text(name));var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog.revision(),siteId,context.character);boolean rewardsReady=unclaimed.xp().signum()>0||unclaimed.coins().signum()>0;String state=rewardsReady?"Rewards ready — click to claim":active!=null&&active.site().equals(siteId)?"Active — right-click to abandon":active!=null?"Another quest is active":directory.characterClaimed(catalog.revision(),siteId,context.character)?directory.rewardClaimed(catalog.revision(),siteId,context.character)?"Already claimed":"Completed with nothing owed":site==null||!directory.offerOpen(siteId)||!directory.available(site)?"Unavailable / cleared":"Available — click to accept";String kind=site==null?"Unavailable":switch(site.type()){case CAMP->"Monster camp";case CHAMPION->"Champion";case DUNGEON->"Dungeon";case FISHING->"Fishing";};var reward=site==null?new AdventureQuestDirectory.Reward(BigInteger.ZERO,BigInteger.ZERO):directory.reward(site);var rewardXp=rewardsReady?unclaimed.xp():active!=null&&active.site().equals(siteId)?active.rewardXp():reward.xp();var rewardCoins=rewardsReady?unclaimed.coins():active!=null&&active.site().equals(siteId)?active.rewardCoins():reward.coins();String rewardLabel=rewardsReady?"Banked reward: ":site!=null&&site.type()==AdventureQuestState.SiteType.DUNGEON?"Completion trophy pool: ":"Reward: ";var lore=new ArrayList<net.kyori.adventure.text.Component>();lore.add(net.kyori.adventure.text.Component.text(kind));var fishingTerms=active!=null&&active.site().equals(siteId)&&active.fishing()!=null?active.fishing():site==null||site.fishing()==null?null:site.fishing().terms();if(fishingTerms!=null){lore.add(net.kyori.adventure.text.Component.text("Item: "+fishingTerms.itemName()+" × "+fishingTerms.required()));lore.add(net.kyori.adventure.text.Component.text("Catch chance: "+fishingTerms.chancePercent().toPlainString()+"%"));if(active!=null&&active.site().equals(siteId)&&fishing!=null)lore.add(net.kyori.adventure.text.Component.text("Held: "+fishing.quantity(context.character,fishingTerms.itemId())+" / "+fishingTerms.required()));}lore.add(net.kyori.adventure.text.Component.text(rewardLabel+rewardXp+" XP, "+rewardCoins+" coins"));if(site!=null&&site.type()==AdventureQuestState.SiteType.DUNGEON)lore.add(net.kyori.adventure.text.Component.text("Mob kills: additional variable XP, coins and native loot"));if(site!=null){lore.add(net.kyori.adventure.text.Component.text("Mode: "+site.participationMode().value()));if(active==null&&!rewardsReady&&!directory.characterClaimed(catalog.revision(),siteId,context.character)&&directory.offerOpen(siteId)&&directory.available(site))acceptanceLore(lore,site.participationMode(),context);}lore.add(net.kyori.adventure.text.Component.text("Expires: "+catalog.expiresAt()));lore.add(net.kyori.adventure.text.Component.text(state));meta.lore(lore);meta.getPersistentDataContainer().set(menuSiteKey,PersistentDataType.STRING,siteId.toString());meta.getPersistentDataContainer().set(menuCatalogKey,PersistentDataType.STRING,catalog.revision().toString());item.setItemMeta(meta);inventory.setItem(slot++,item);}if(pages>1){if(page>0)inventory.setItem(45,pageItem(page-1,"Previous page"));if(page+1<pages)inventory.setItem(53,pageItem(page+1,"Next page"));}if(fishing!=null){var turnIn=new ItemStack(Material.ENDER_CHEST);var turLine truncated
|
||||
private ItemStack pageItem(int page,String name){var item=new ItemStack(Material.ARROW);var meta=item.getItemMeta();meta.displayName(net.kyori.adventure.text.Component.text(name));meta.getPersistentDataContainer().set(menuPageKey,PersistentDataType.INTEGER,page);item.setItemMeta(meta);return item;}
|
||||
private static void acceptanceLore(List<net.kyori.adventure.text.Component> lore,AdventureQuestState.ParticipationMode mode,Context context){if(mode!=AdventureQuestState.ParticipationMode.PARTY_REQUIRED)lore.add(net.kyori.adventure.text.Component.text("Left click: Accept individually"));if(mode!=AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY){if(context.partyId!=null&&context.leader)lore.add(net.kyori.adventure.text.Component.text("Right click: Accept for party"));else lore.add(net.kyori.adventure.text.Component.text("Party acceptance requires the active leader"));}}
|
||||
private record Context(EquipmentSessions.Active active,UUID character,UUID partyId,boolean leader,Set<UUID> members){}
|
||||
private Context context(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return null;var record=sessions.currentRecord(active).orElse(null);if(record==null||record.dead()||player.isDead())return null;var party=parties.partyOf(player.getUniqueId()).orElse(null);if(party==null)return new Context(active,record.id(),null,true,Set.of(record.id()));return new Context(active,record.id(),party.id(),party.leaderCharacter().equals(record.id()),Set.copyOf(party.characters()));}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void interact(PlayerInteractEntityEvent event){if(!giver(event.getRightClicked()))return;event.setCancelled(true);if(event.getHand()!=EquipmentSlot.HAND)return;UUID id=id(event.getRightClicked(),idKey);if(id!=null&&directory.findGiver(id).isPresent())open(event.getPlayer());}
|
||||
@EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof MenuHolder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null)return;var data=event.getCurrentItem().getPersistentDataContainer();Integer page=data.get(menuPageKey,PersistentDataType.INTEGER);if(page!=null){open(player,page);return;}UUID site=parse(data.get(menuSiteKey,PersistentDataType.STRING)),catalog=parse(data.get(menuCatalogKey,PersistentDataType.STRING));if(site==null||catalog==null||!holder.catalog.equals(catalog))return;var context=context(player);if(context==null){player.sendMessage("Quest action refused: stale fantasy session.");return;}var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog,site,context.character);if(unclaimed.xp().signum()>0||unclaimed.coins().signum()>0){claim(player,context,catalog,site);return;}if(active!=null){if(!active.site().equals(site)){player.sendMessage("Quest action refused: another quest is active.");return;}if(!event.isRightClick()){player.sendMessage("This quest is active; right-click its Paper to abandon it.");return;}directory.abandon(active.id(),active.unit()).whenComplete((changed,failure)->schedule(()->{refreshTracking(player);player.sendMessage(failure==null&&changed?"Adventure quest abandoned.":"Quest abandonment failed; retained state is unchanged.");open(player);}));return;}if(directory.characterClaimed(catalog,site,context.character)){player.sendMessage(directory.rewardClaimed(catalog,site,context.character)?"Adventure rewards were already claimed.":"Adventure complete with no reward owed.");return;}if(!directory.offerOpen(site)||directory.findSite(site).filter(directory::available).isEmpty()){player.sendMessage("Quest action refused: this offer is unavailable.");return;}if(context.partyId!=null&&!context.leader){player.sendMessage("Only the active party leader may accept a party quest.");return;}var type=context.partyId==null?AdventureQuestState.UnitType.SOLO:AdventureQuestState.UnitType.PARTY;var unit=context.partyId==null?context.character:context.partyId;directory.accept(catalog,site,type,unit,context.members).whenComplete((result,failure)->schedule(()->{if(failure!=null){player.sendMessage("Quest acceptance failed; retained state is unchanged.");return;}if(result.status()!=AdventureQuestDirectory.Acceptance.APPLIED){player.sendMessage("Quest acceptance refused: "+result.status().name().toLowerCase(Locale.ROOT)+".");return;}var location=directory.findSite(site).orElseThrow();refreshTracking(player);player.sendMessage("Accepted "+location.displayName()+"; expires "+result.quest().orElseThrow().expiresAt()+". Follow the Quest Compass.");}));}
|
||||
@EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof MenuHolder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null)return;var data=event.getCurrentItem().getPersistentDataContainer();Integer page=data.get(menuPageKey,PersistentDataType.INTEGER);if(page!=null){open(player,page);return;}if(data.has(menuFishingKey,PersistentDataType.INTEGER)&&fishing!=null){fishing.open(player);return;}UUID site=parse(data.get(menuSiteKey,PersistentDataType.STRING)),catalog=parse(data.get(menuCatalogKey,PersistentDataType.STRING));if(site==null||catalog==null||!holder.catalog.equals(catalog))return;var context=context(player);if(context==null){player.sendMessage("Quest action refused: stale fantasy session.");return;}var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog,site,context.character);if(unclaimed.xp().signum()>0||unclaimed.coins().signum()>0){claim(player,context,catalog,site);return;}if(active!=null){if(!active.site().equals(site)){player.sendMessage("Quest action refused: another quest is active.");return;}if(!event.isRightClick()){player.sendMessage("This quest is active; right-click its Paper to abandon it.");return;}directory.abandon(active.id(),active.unit()).whenComplete((changed,failure)->schedule(()->{refreshTracking(player);player.sendMessage(failure==null&&changed?"Adventure quest abandoned.":"Quest abandonment failed; retained state is unchanged.");open(player);}));return;}if(directory.characterClaimed(catalog,site,context.character)){player.sendMessage(directory.rewardClaimed(catalog,site,context.character)?"Adventure rewards were already claimed.":"Adventure complete with no reward owed.");return;}if(!directory.offerOpen(site)||directory.findSite(site).filter(directory::available).isEmpty()){player.sendMessage("Quest action refused: this offer is unavailable.");return;}var offered=directory.findSite(site).orElseThrow();var type=event.isRightClick()?AdventureQuestState.UnitType.PARTY:AdventureQuestState.UnitType.SOLO;if(!offered.participationMode().permits(type)){player.sendMessage("Quest acceptance refused: this site is "+offered.participationMode().value()+".");return;}if(type==AdventureQuestState.UnitType.PARTY&&(context.partyId==null||!context.leader)){player.sendMessage("Only the active party leader may accept a party quest.");return;}var unit=type==AdventureQuestState.UnitType.SOLO?context.character:context.partyId;var members=type==AdventureQuestState.UnitType.SOLO?Set.of(context.character):context.members;directory.accept(catalog,site,type,unit,members,offered.participationMode()).whenComplete((result,failure)->schedule(()->{if(failure!=null){player.sendMessage("Quest acceptance failed; retained state is unchanged.");return;}if(result.status()!=AdventureQuestDirectory.Acceptance.APPLIED){player.sendMessage("Quest acceptance refused: "+result.status().name().toLowerCase(Locale.ROOT)+".");return;}var quest=result.quest().orElseThrow();var current=context(player);boolean same=current!=null&¤t.active.equals(context.active)&¤t.character.equals(context.character)&&(type==AdventureQuestState.UnitType.SOLO||current.leader&&Objects.equals(current.partyId,context.partyId)&¤t.members.equals(context.members));if(!same){directory.abandon(quest.id(),quest.unit()).whenComplete((ignored,rollbackFailure)->schedule(()->player.sendMessage(rollbackFailure==null?"Quest acceptance refused: the character or party changed.":"Quest acceptance is pending safe cancellation; retry after recovery.")));return;}var location=directory.findSite(site).orElseThrow();refreshTracking(player);player.sendMessage("Accepted "+location.displayName()+"; expires "+quest.expiresAt()+". Follow the Quest Compass.");}));}
|
||||
private void claim(Player player,Context expected,UUID catalog,UUID site){directory.beginClaim(catalog,site,expected.character).whenComplete((operation,failure)->schedule(()->{if(failure!=null||operation.isEmpty()){player.sendMessage("Reward claim failed; retained rewards are unchanged.");return;}var current=context(player);if(current==null||!current.active.equals(expected.active)||!current.character.equals(operation.orElseThrow().character())){player.sendMessage("Reward claim paused: actively play the exact living character and try again.");return;}var record=sessions.currentRecord(current.active).orElse(null);if(record==null){player.sendMessage("Reward claim paused: the current character changed.");return;}attemptClaim(player,current.active,operation.orElseThrow(),record);}));}
|
||||
private void attemptClaim(Player player,EquipmentSessions.Active active,AdventureQuestState.RewardClaim operation,games.dmg.fantasystick.persistence.CharacterRecord record){characters.awardCombatReward(record.id(),record.revision(),operation.id(),operation.xp(),operation.coins(),progression,games.dmg.fantasystick.progression.CombatRewardRules.defaults(),null).whenComplete((award,failure)->{if(failure!=null){schedule(()->player.sendMessage("Reward claim failed; retained rewards are unchanged."));return;}if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){attemptClaim(player,active,operation,award.record().orElseThrow());return;}if(award.record().isEmpty()){schedule(()->player.sendMessage("Reward claim failed; retained rewards are unchanged."));return;}var saved=award.record().orElseThrow();directory.finishClaim(operation.id()).whenComplete((finished,finishFailure)->schedule(()->{if(finishFailure!=null||!finished){player.sendMessage("Reward claim is pending recovery; retry at a Quest Giver.");return;}var holder=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE&&holder!=null)sessions.acknowledgeProgress(holder,saved.revision().subtract(BigInteger.ONE),saved);var currentPlayer=holder==null?null:plugin.getServer().getPlayer(holder.player());var current=currentPlayer==null?null:context(currentPlayer);if(current!=null&¤t.character.equals(operation.character())){currentPlayer.sendMessage("Claimed "+operation.xp()+" fantasy XP and "+operation.coins()+" character coins.");open(currentPlayer);}}));});}
|
||||
public boolean trackingAvailable(Player player){var context=context(player);return context!=null&&directory.activeFor(context.character,context.partyId).filter(quest->quest.snapshot().contains(context.character)).isPresent();}
|
||||
public void tracking(Player player,Boolean enabled){var context=context(player);if(context==null){player.sendMessage("Quest tracking refused: actively play a living fantasy character first.");return;}var quest=directory.activeFor(context.character,context.partyId).filter(value->value.snapshot().contains(context.character)).orElse(null);if(quest==null){player.sendMessage("Quest tracking refused: this character has no eligible active quest.");refreshTracking(player);return;}if(enabled==null){player.sendMessage("Quest tracking is "+(directory.trackingEnabled(quest.id(),context.character)?"on":"off")+" for the current adventure quest.");return;}directory.setTracking(quest.id(),context.character,enabled).whenComplete((status,failure)->schedule(()->{if(failure!=null||status==AdventureQuestDirectory.TrackingStatus.INELIGIBLE){player.sendMessage("Quest tracking change failed; retained state is unchanged.");return;}refreshTracking(player);player.sendMessage("Quest tracking is now "+(enabled?"on.":"off."));}));}
|
||||
public void inspect(Player player){var context=context(player);if(context==null){player.sendMessage("No active fantasy character.");return;}var quest=directory.activeFor(context.character,context.partyId).orElse(null);if(quest==null){player.sendMessage("No active adventure quest.");return;}var site=directory.findSite(quest.site()).orElse(null);if(site==null){player.sendMessage("Active quest site is unavailable.");return;}player.sendMessage(site.displayName()+"; state="+(attempts.containsKey(site.id())?"active":"waiting")+"; expires "+quest.expiresAt()+"; Quest Compass guidance="+(directory.trackingEnabled(quest.id(),context.character)?"on":"off")+".");}
|
||||
private void pulse(){if(closed)return;directory.ensureToday().whenComplete((catalog,failure)->{if(failure==null)schedule(()->{if(directory.snapshot().reset()!=null)publishPendingReset();});});if(directory.snapshot().reset()!=null){publishPendingReset();return;}directory.expireParties(parties::exists);refreshTrackingPlayers(true);showArenaBoundaries();for(var attempt:new ArrayList<>(attempts.values())){var catalog=directory.snapshot().catalog();if(catalog==null||!catalog.revision().equals(attempt.catalog)||!clock.instant().isBefore(catalog.expiresAt())){cleanup(attempt);attempts.remove(attempt.site);readyAt.remove(attempt.site);continue;}contain(attempt);syncBossBar(attempt);boolean present=eligibleInside(attempt.site,attempt.arena).findAny().isPresent();if(present)attempt.absentSince=null;else if(attempt.absentSince==null)attempt.absentSince=clock.instant();else if(!clock.instant().isBefore(attempt.absentSince.plusSeconds(attempt.resetSeconds))){cleanup(attempt);attempts.remove(attempt.site);readyAt.put(attempt.site,clock.instant());}}for(var quest:directory.active()){if(directory.findSite(quest.site()).map(AdventureQuestState.Site::type).orElse(null)==AdventureQuestState.SiteType.DUNGEON)continue;if(attempts.containsKey(quest.site())||starting.contains(quest.site()))continue;var ready=readyAt.get(quest.site());if(ready!=null&&clock.instant().isBefore(ready))continue;if(eligibleInside(quest.site()).findAny().isPresent())start(quest.site(),quest.catalog());}}
|
||||
private void pulse(){if(closed)return;directory.ensureToday().whenComplete((catalog,failure)->{if(failure==null)schedule(()->{if(directory.snapshot().reset()!=null)publishPendingReset();});});if(directory.snapshot().reset()!=null){publishPendingReset();return;}directory.expireParties(parties::exists);refreshTrackingPlayers(true);showArenaBoundaries();for(var attempt:new ArrayList<>(attempts.values())){var catalog=directory.snapshot().catalog();if(catalog==null||!catalog.revision().equals(attempt.catalog)||!clock.instant().isBefore(catalog.expiresAt())){cleanup(attempt);attempts.remove(attempt.site);readyAt.remove(attempt.site);continue;}contain(attempt);syncBossBar(attempt);boolean present=eligibleInside(attempt.site,attempt.arena).findAny().isPresent();if(present)attempt.absentSince=null;else if(attempt.absentSince==null)attempt.absentSince=clock.instant();else if(!clock.instant().isBefore(attempt.absentSince.plusSeconds(attempt.resetSeconds))){cleanup(attempt);attempts.remove(attempt.site);readyAt.put(attempt.site,clock.instant());}}for(var quest:directory.active()){var type=directory.findSite(quest.site()).map(AdventureQuestState.Site::type).orElse(null);if(type==AdventureQuestState.SiteType.DUNGEON||type==AdventureQuestState.SiteType.FISHING)continue;if(attempts.containsKey(quest.site())||starting.contains(quest.site()))continue;var ready=readyAt.get(quest.site());if(ready!=null&&clock.instant().isBefore(ready))continue;if(eligibleInside(quest.site()).findAny().isPresent())start(quest.site(),quest.catalog());}}
|
||||
private void showArenaBoundaries(){for(UUID siteId:directory.active().stream().map(AdventureQuestState.Quest::site).distinct().toList()){var site=directory.findSite(siteId).orElse(null);if(site==null||site.type()==AdventureQuestState.SiteType.DUNGEON)continue;var world=plugin.getServer().getWorld(site.world());if(world==null)continue;var center=new Location(world,site.x(),site.y(),site.z());var points=perimeter(arena(site));for(var player:plugin.getServer().getOnlinePlayers()){if(eligible(player,siteId)==null)continue;var viewer=player.getLocation();if(viewer.getWorld()==null||!viewer.getWorld().getUID().equals(site.world())||viewer.distanceSquared(center)>64d*64d)continue;for(var point:points)particles.viewer(player,Particle.END_ROD,new Location(world,site.x()+point[0],site.y()+0.2,site.z()+point[1]),1,0,0,0,0);}}}
|
||||
private static List<double[]> perimeter(AdventureQuestState.Arena arena){var result=new ArrayList<double[]>();if(arena.shape()==AdventureQuestState.ArenaShape.CIRCLE){int count=Math.min(256,Math.max(16,(int)Math.ceil(Math.PI*arena.xBlocks())));for(int index=0;index<count;index++){double angle=2*Math.PI*index/count;result.add(new double[]{Math.cos(angle)*arena.xBlocks(),Math.sin(angle)*arena.xBlocks()});}}else{double halfX=arena.xBlocks()/2d,halfZ=arena.zBlocks()/2d;edge(result,-halfX,halfX,-halfZ,true);edge(result,-halfX,halfX,halfZ,true);edge(result,-halfZ,halfZ,-halfX,false);edge(result,-halfZ,halfZ,halfX,false);}return result.size()<=256?List.copyOf(result):List.copyOf(result.subList(0,256));}
|
||||
private static void edge(List<double[]> result,double from,double to,double fixed,boolean xAxis){for(double value=from;value<to;value+=2)result.add(xAxis?new double[]{value,fixed}:new double[]{fixed,value});result.add(xAxis?new double[]{to,fixed}:new double[]{fixed,to});}
|
||||
@@ -78,17 +82,17 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
|
||||
private java.util.stream.Stream<Player> eligibleInside(UUID site){var value=directory.findSite(site).orElse(null);return value==null?java.util.stream.Stream.empty():eligibleInside(site,arena(value));}
|
||||
private java.util.stream.Stream<Player> eligibleInside(UUID site,AdventureQuestState.Arena arena){var value=directory.findSite(site).orElse(null);if(value==null)return java.util.stream.Stream.empty();return plugin.getServer().getOnlinePlayers().stream().map(Player.class::cast).filter(player->eligible(player,site)!=null).filter(player->inside(player.getLocation(),value,arena));}
|
||||
private AdventureQuestState.Quest eligible(Player player,UUID site){var context=context(player);if(context==null)return null;var quest=directory.activeFor(context.character,context.partyId).orElse(null);return quest!=null&&quest.site().equals(site)&&quest.snapshot().contains(context.character)?quest:null;}
|
||||
private void start(UUID site,UUID catalog){var value=directory.findSite(site).orElse(null);if(value==null||value.type()==AdventureQuestState.SiteType.DUNGEON)return;if(value.type()==AdventureQuestState.SiteType.CHAMPION){startChampion(value,catalog);return;}starting.add(site);directory.roster(site).whenComplete((roster,failure)->schedule(()->{starting.remove(site);if(failure!=null||closed||attempts.containsKey(site))return;var current=directory.findSite(site).orElse(null);if(current==null)return;var world=plugin.getServer().getWorld(current.world());if(world==null||!world.isChunkLoaded(section(current.x()),section(current.z())))return;var rules=settings.rules();var attempt=new Attempt(site,catalog,current.type(),arena(current),rules.resetSeconds());attempts.put(site,attempt);for(int index=0;index<roster.size();index++)spawnEnemy(world,current,roster.get(index),attempt,index);}));}
|
||||
private void start(UUID site,UUID catalog){var value=directory.findSite(site).orElse(null);if(value==null||value.type()==AdventureQuestState.SiteType.DUNGEON||value.type()==AdventureQuestState.SiteType.FISHING)return;if(value.type()==AdventureQuestState.SiteType.CHAMPION){startChampion(value,catalog);return;}starting.add(site);directory.roster(site).whenComplete((roster,failure)->schedule(()->{starting.remove(site);if(failure!=null||closed||attempts.containsKey(site))return;var current=directory.findSite(site).orElse(null);if(current==null)return;var world=plugin.getServer().getWorld(current.world());if(world==null||!world.isChunkLoaded(section(current.x()),section(current.z())))return;var rules=settings.rules();var attempt=new Attempt(site,catalog,current.type(),arena(current),rules.resetSeconds());attempts.put(site,attempt);for(int index=0;index<roster.size();index++)spawnEnemy(world,current,roster.get(index),attempt,index);}));}
|
||||
private void startChampion(AdventureQuestState.Site site,UUID catalog){if(closed||attempts.containsKey(site.id())||starting.contains(site.id()))return;var world=plugin.getServer().getWorld(site.world());if(world==null||!world.isChunkLoaded(section(site.x()),section(site.z())))return;starting.add(site.id());try{var rules=settings.rules();var attempt=new Attempt(site.id(),catalog,site.type(),arena(site),rules.resetSeconds());attempt.bossBar=plugin.getServer().createBossBar("Daybreak Champion",BarColor.RED,BarStyle.SOLID);attempts.put(site.id(),attempt);world.spawn(new Location(world,site.x(),site.y(),site.z()),Vindicator.class,champion->{configureEnemy(champion,attempt);var maximum=Objects.requireNonNull(champion.getAttribute(Attribute.MAX_HEALTH));maximum.setBaseValue(rules.championHealth());champion.setHealth(rules.championHealth());Objects.requireNonNull(champion.getAttribute(Attribute.ATTACK_DAMAGE)).setBaseValue(rules.championDamage());champion.customName(net.kyori.adventure.text.Component.text("Daybreak Champion"));champion.setCustomNameVisible(true);attempt.champion=champion;});syncBossBar(attempt);}catch(RuntimeException failure){var attempt=attempts.remove(site.id());if(attempt!=null)cleanup(attempt);plugin.getLogger().log(java.util.logging.Level.WARNING,"Champion attempt could not start at site "+site.id(),failure);}finally{starting.remove(site.id());}}
|
||||
private void spawnEnemy(World world,AdventureQuestState.Site site,org.bukkit.entity.EntityType type,Attempt attempt,int index){var location=new Location(world,site.x()+(index%3)-1,site.y(),site.z()+(index/3));switch(type){case HUSK->world.spawn(location,Husk.class,mob->configureEnemy(mob,attempt));case PILLAGER->world.spawn(location,Pillager.class,mob->configureEnemy(mob,attempt));case VINDICATOR->world.spawn(location,Vindicator.class,mob->configureEnemy(mob,attempt));case WITCH->world.spawn(location,Witch.class,mob->configureEnemy(mob,attempt));default->throw new IllegalArgumentException("Unsupported camp entity");}}
|
||||
private void configureEnemy(Mob mob,Attempt attempt){mob.getPersistentDataContainer().set(typeKey,PersistentDataType.STRING,ENEMY);mob.getPersistentDataContainer().set(siteKey,PersistentDataType.STRING,attempt.site.toString());mob.getPersistentDataContainer().set(catalogKey,PersistentDataType.STRING,attempt.catalog.toString());mob.setCanPickupItems(false);mob.setRemoveWhenFarAway(false);mob.setPersistent(true);mob.setFireTicks(0);if(mob instanceof Husk husk){husk.setShouldBurnInDay(false);husk.setCanBreakDoors(false);}if(mob instanceof Raider raider){raider.setPatrolLeader(false);raider.setCanJoinRaid(false);}attempt.entities.add(mob.getUniqueId());}
|
||||
private void configureEnemy(Mob mob,Attempt attempt){mob.getPersistentDataContainer().set(typeKey,PersistentDataType.STRING,ENEMY);mob.getPersistentDataContainer().set(siteKey,PersistentDataType.STRING,attempt.site.toString());mob.getPersistentDataContainer().set(catalogKey,PersistentDataType.STRING,attempt.catalog.toString());mob.getPersistentDataContainer().set(attemptKey,PersistentDataType.STRING,attempt.id.toString());var kind=attempt.type==AdventureQuestState.SiteType.CHAMPION?games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CHAMPION:games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CAMP;mob.getPersistentDataContainer().set(encounterKindKey,PersistentDataType.STRING,kind.name());mob.setCanPickupItems(false);mob.setRemoveWhenFarAway(false);mob.setPersistent(true);mob.setFireTicks(0);if(mob instanceof Husk husk){husk.setShouldBurnInDay(false);husk.setCanBreakDoors(false);}if(mob instanceof Raider raider){raider.setPatrolLeader(false);raider.setCanJoinRaid(false);}attempt.entities.add(mob.getUniqueId());}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void damage(EntityDamageEvent event){if(enemy(event.getEntity())){if(!(event instanceof EntityDamageByEntityEvent by)||source(by)==null||eligible(source(by),site(event.getEntity()))==null)event.setCancelled(true);return;}if(event instanceof EntityDamageByEntityEvent by&&managed(by.getDamager())){UUID site=site(by.getDamager());if(!(event.getEntity() instanceof Player player)||site==null||eligible(player,site)==null)event.setCancelled(true);}}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void accepted(EntityDamageByEntityEvent event){if(!enemy(event.getEntity())||event.getFinalDamage()<=0||!Double.isFinite(event.getFinalDamage()))return;var player=source(event);if(player==null)return;var quest=eligible(player,site(event.getEntity()));if(quest!=null){var attempt=attempts.get(quest.site());if(attempt!=null){attempt.represented.add(quest.id());if(attempt.champion==event.getEntity()&&attempt.bossBar!=null){double maximum=Objects.requireNonNull(attempt.champion.getAttribute(Attribute.MAX_HEALTH)).getValue();attempt.bossBar.setProgress(Math.max(0,Math.min(1,(attempt.champion.getHealth()-event.getFinalDamage())/maximum)));}}}}
|
||||
@EventHandler(priority=EventPriority.HIGHEST)public void death(EntityDeathEvent event){if(!enemy(event.getEntity()))return;event.getDrops().clear();event.setDroppedExp(0);UUID site=site(event.getEntity());var attempt=site==null?null:attempts.get(site);if(attempt==null)return;attempt.entities.remove(event.getEntity().getUniqueId());if(attempt.entities.isEmpty())clear(attempt);}
|
||||
private void clear(Attempt attempt){attempts.remove(attempt.site);if(attempt.bossBar!=null)attempt.bossBar.removeAll();readyAt.put(attempt.site,clock.instant().plusSeconds(attempt.resetSeconds));for(UUID questId:Set.copyOf(attempt.represented)){var quest=directory.findQuest(questId).orElse(null);if(quest==null)continue;var recipients=recipients(quest,attempt.site,attempt.arena);if(!recipients.isEmpty())award(quest,recipients);} }
|
||||
private void clear(Attempt attempt){attempts.remove(attempt.site);if(attempt.bossBar!=null)attempt.bossBar.removeAll();readyAt.put(attempt.site,clock.instant().plusSeconds(attempt.resetSeconds));for(UUID questId:Set.copyOf(attempt.represented)){var quest=directory.findQuest(questId).orElse(null);if(quest==null)continue;var recipients=recipients(quest,attempt.site,attempt.arena);if(!recipients.isEmpty())award(quest,recipients,attempt);} }
|
||||
private List<UUID> recipients(AdventureQuestState.Quest quest,UUID site){var value=directory.findSite(site).orElse(null);return value==null?List.of():recipients(quest,site,arena(value));}
|
||||
private List<UUID> recipients(AdventureQuestState.Quest quest,UUID site,AdventureQuestState.Arena arena){var value=directory.findSite(site).orElse(null);if(value==null)return List.of();var result=new ArrayList<UUID>();for(UUID character:quest.snapshot()){if(directory.characterClaimed(quest.catalog(),quest.site(),character))continue;var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);if(active==null)continue;var player=plugin.getServer().getPlayer(active.player());if(player==null||!inside(player.getLocation(),value,arena))continue;if(quest.unitType()==AdventureQuestState.UnitType.PARTY){var party=parties.partyOf(player.getUniqueId()).orElse(null);if(party==null||!party.id().equals(quest.unit())||!party.characters().contains(character))continue;}result.add(character);}return result.stream().distinct().sorted().toList();}
|
||||
private void award(AdventureQuestState.Quest quest,List<UUID> recipients){var unit=new RewardAllocator.Unit<UUID>(quest.unit(),recipients);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);var rewards=new LinkedHashMap<UUID,AdventureQuestDirectory.Reward>();for(UUID character:recipients)rewards.put(character,new AdventureQuestDirectory.Reward(xp.get(character),coins.get(character)));directory.bankAndComplete(quest.id(),quest.id(),rewards).whenComplete((banked,failure)->schedule(()->{if(failure!=null||!banked){plugin.getLogger().log(java.util.logging.Level.WARNING,"Adventure completion reward banking failed for quest "+quest.id(),failure);return;}for(UUID character:recipients){var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);var player=active==null?null:plugin.getServer().getPlayer(active.player());if(player!=null)player.sendMessage("Adventure complete: rewards are ready at any Quest Giver.");}refreshTrackingPlayers();}));}
|
||||
private void award(AdventureQuestState.Quest quest,List<UUID> recipients,Attempt attempt){var unit=new RewardAllocator.Unit<UUID>(quest.unit(),recipients);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);if((attempt.type==AdventureQuestState.SiteType.CHAMPION||attempt.type==AdventureQuestState.SiteType.CAMP)&&fishing!=null){var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(UUID character:recipients)rewards.put(character,new FishingState.Reward(xp.get(character),coins.get(character)));UUID carrier=quest.unitType()==AdventureQuestState.UnitType.SOLO?quest.unit():recipients.stream().map(parties::partyOfCharacter).flatMap(Optional::stream).filter(value->value.id().equals(quest.unit())).map(PartyRoster.Party::leaderCharacter).findFirst().orElse(null);var site=directory.findSite(quest.site()).orElse(null);if(carrier==null||site==null){plugin.getLogger().warning("Champion trophy carrier unavailable for quest "+quest.id());return;}var trophyType=attempt.type==AdventureQuestState.SiteType.CHAMPION?FishingState.TrophyType.CHAMPION:FishingState.TrophyType.CAMP;UUID trophy=UUID.nameUUIDFromBytes((trophyType.name().toLowerCase(java.util.Locale.ROOT)+":"+quest.id()).getBytes(java.nio.charset.StandardCharsets.UTF_8));String trophyName=trophyType==FishingState.TrophyType.CHAMPION?"Daybreak Champion Head":"Spoils of "+site.displayName();var plan=new FishingState.Trophy(trophy,trophyType,carrier,quest.catalog(),quest.site(),quest.id(),attempt.id,trophyName,site.displayName(),rewards);fishing.earnTrophy(plan).whenComplete((earned,failure)->schedule(()->{if(failure!=null||!earned){plugin.getLogger().log(java.util.logging.Level.WARNING,"Champion trophy persistence failed for quest "+quest.id(),failure);return;}for(UUID character:recipients){var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);var player=active==null?null:plugin.getServer().getPlayer(active.player());if(player!=null)player.sendMessage("Adventure complete: its trophy is stored with the captured carrier.");}refreshTrackingPlayers();}));return;}var rewards=new LinkedHashMap<UUID,AdventureQuestDirectory.Reward>();for(UUID character:recipients)rewards.put(character,new AdventureQuestDirectory.Reward(xp.get(character),coins.get(character)));directory.bankAndComplete(quest.id(),quest.id(),rewards).whenComplete((banked,failure)->schedule(()->{if(failure!=null||!banked){plugin.getLogger().log(java.util.logging.Level.WARNING,"Adventure completion reward banking failed for quest "+quest.id(),failure);return;}for(UUID character:recipients){var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);var player=active==null?null:plugin.getServer().getPlayer(active.player());if(player!=null)player.sendMessage("Adventure complete: rewards are ready at any Quest Giver.");}refreshTrackingPlayers();}));}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void splash(org.bukkit.event.entity.PotionSplashEvent event){if(!managed(event.getPotion()))return;UUID site=site(event.getPotion());for(var affected:List.copyOf(event.getAffectedEntities()))if(!(affected instanceof Player player)||site==null||eligible(player,site)==null)event.setIntensity(affected,0);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void target(EntityTargetLivingEntityEvent event){if(enemy(event.getEntity())&&event.getTarget()!=null){UUID site=site(event.getEntity());if(!(event.getTarget() instanceof Player player)||site==null||eligible(player,site)==null)event.setCancelled(true);}}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void projectile(org.bukkit.event.entity.ProjectileLaunchEvent event){if(event.getEntity().getShooter() instanceof Entity shooter&&enemy(shooter)){event.getEntity().getPersistentDataContainer().set(typeKey,PersistentDataType.STRING,PROJECTILE);event.getEntity().getPersistentDataContainer().set(siteKey,PersistentDataType.STRING,Objects.requireNonNull(site(shooter)).toString());}}
|
||||
@@ -111,7 +115,7 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
|
||||
private void refreshTrackingPlayers(){refreshTrackingPlayers(false);}
|
||||
private void refreshTrackingPlayers(boolean actionBar){for(var value:plugin.getServer().getOnlinePlayers())refreshTracking(value,actionBar);}
|
||||
private void refreshTracking(Player player){refreshTracking(player,false);}
|
||||
private void refreshTracking(Player player,boolean actionBar){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return;var record=sessions.currentRecord(active).orElse(null);if(record==null||record.dead()||player.isDead()){sessions.restoreBook(active);return;}var party=parties.partyOf(player.getUniqueId()).orElse(null);UUID partyId=party==null?null:party.id();var quest=directory.activeFor(record.id(),partyId).filter(value->value.snapshot().contains(record.id())).orElse(null);var site=quest==null?null:directory.findSite(quest.site()).filter(directory::available).orElse(null);if(quest==null||site==null||!directory.trackingEnabled(quest.id(),record.id())){sessions.restoreBook(active);return;}var location=player.getLocation().clone();boolean arrived=site.type()==AdventureQuestState.SiteType.DUNGEON?dungeonRoomPresence.test(site.id(),location):inside(location,site,arena(site));boolean published=sessions.presentBook(active,book->navigation.compass(book,quest,site,location));if(published&&actionBar)navigation.guidance(player,site,arrived).ifPresent(player::sendActionBar);}
|
||||
private void refreshTracking(Player player,boolean actionBar){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return;var record=sessions.currentRecord(active).orElse(null);if(record==null||record.dead()||player.isDead()||directory.snapshot().reset()!=null){sessions.restoreBook(active);return;}var party=parties.partyOf(player.getUniqueId()).orElse(null);UUID partyId=party==null?null:party.id();var quest=directory.activeFor(record.id(),partyId).filter(value->value.snapshot().contains(record.id())).orElse(null);var site=quest==null?null:directory.findSite(quest.site()).filter(directory::available).orElse(null);if(quest==null||site==null){sessions.restoreBook(active);return;}var location=player.getLocation().clone();boolean arrived=site.type()==AdventureQuestState.SiteType.DUNGEON?dungeonRoomPresence.test(site.id(),location):inside(location,site,site.type()==AdventureQuestState.SiteType.FISHING?AdventureQuestState.Arena.circle(quest.fishing().radius()):arena(site));if(site.type()==AdventureQuestState.SiteType.FISHING&&arrived){sessions.presentBook(active,book->navigation.fishingRod(book,quest,site));return;}if(!directory.trackingEnabled(quest.id(),record.id())){sessions.restoreBook(active);return;}boolean published=sessions.presentBook(active,book->navigation.compass(book,quest,site,location));if(published&&actionBar)navigation.guidance(player,site,arrived).ifPresent(player::sendActionBar);}
|
||||
private void schedule(Runnable task){if(closed)return;try{nextTick.accept(()->{if(!closed)task.run();});}catch(RuntimeException ignored){}}
|
||||
private static boolean inside(Location location,AdventureQuestState.Site site,AdventureQuestState.Arena arena){return location.getWorld()!=null&&location.getWorld().getUID().equals(site.world())&&arena.contains(location.getX()-site.x(),location.getZ()-site.z());}
|
||||
private Location location(AdventureQuestState.Giver value){return new Location(plugin.getServer().getWorld(value.world()),value.x(),value.y(),value.z(),value.yaw(),value.pitch());}
|
||||
|
||||
@@ -12,28 +12,40 @@ import org.bukkit.entity.EntityType;
|
||||
|
||||
/** Serialized durable daily catalog, acceptance, roster and completion authority. */
|
||||
public final class AdventureQuestDirectory implements AutoCloseable {
|
||||
public enum Acceptance { APPLIED, STALE, UNAVAILABLE, CONFLICT, CLAIMED }
|
||||
public enum Acceptance { APPLIED, STALE, UNAVAILABLE, POLICY, CONFLICT, CLAIMED }
|
||||
public enum ModeStatus { APPLIED, UNCHANGED, STALE, NOT_FOUND }
|
||||
public enum FishingUpdateStatus { APPLIED, UNCHANGED, STALE, NOT_FOUND }
|
||||
public enum RerollStatus { APPLIED, STALE }
|
||||
public enum TrackingStatus { APPLIED, UNCHANGED, INELIGIBLE }
|
||||
public record AcceptResult(Acceptance status,Optional<AdventureQuestState.Quest> quest) { }
|
||||
public record ModeResult(ModeStatus status,Optional<AdventureQuestState.Site> site) { }
|
||||
public record FishingUpdateResult(FishingUpdateStatus status,Optional<AdventureQuestState.Site> site) { }
|
||||
public record RerollResult(RerollStatus status,Optional<AdventureQuestState.Catalog> catalog) { }
|
||||
public record Reward(java.math.BigInteger xp,java.math.BigInteger coins) {public Reward{Objects.requireNonNull(xp);Objects.requireNonNull(coins);if(xp.signum()<0||coins.signum()<0)throw new IllegalArgumentException("Invalid reward");}}
|
||||
public record Reward(java.math.BigInteger xp,java.math.BigInteger coins) {public Reward{Objects.requireNonNull(xp);Objects.requireNonNull(coins);if(xp.signum()<0||coins.signum()<0)throw new IllegalArgumentException("Invalid reward");}public static Reward empty(){return new Reward(BigInteger.ZERO,BigInteger.ZERO);}}
|
||||
public record LegacyDungeonReward(Reward reward,UUID bossRoom,boolean defeated){public LegacyDungeonReward{Objects.requireNonNull(reward);Objects.requireNonNull(bossRoom);}}
|
||||
private final YamlAdventureQuestRepository repository;private final Clock clock;private final Supplier<AdventureQuestSettings.Rules> settings;private final java.util.random.RandomGenerator random;
|
||||
private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-adventure-persistence"));
|
||||
private volatile AdventureQuestState snapshot;private volatile java.util.function.Function<AdventureQuestState.Site,Reward> dungeonRewards=ignored->new Reward(java.math.BigInteger.ZERO,java.math.BigInteger.ZERO);private volatile java.util.function.Predicate<AdventureQuestState.Site> dungeonAvailable=ignored->true;private volatile java.util.function.Supplier<? extends Collection<AdventureQuestState.Site>> dungeonSites=List::of;private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
public AdventureQuestDirectory(YamlAdventureQuestRepository repository,Clock clock,Supplier<AdventureQuestSettings.Rules> settings,java.util.random.RandomGenerator random)throws IOException{this.repository=Objects.requireNonNull(repository);this.clock=Objects.requireNonNull(clock);this.settings=Objects.requireNonNull(settings);this.random=Objects.requireNonNull(random);snapshot=repository.load();}
|
||||
public AdventureQuestState snapshot(){return snapshot;}
|
||||
public int defaultRadius(){return settings.get().radius();}
|
||||
public void registerDungeonRewards(java.util.function.Function<AdventureQuestState.Site,Reward> rewards){dungeonRewards=Objects.requireNonNull(rewards);}
|
||||
public CompletableFuture<Boolean> migrateLegacyDungeonRewards(java.util.function.Function<UUID,Optional<LegacyDungeonReward>> rewards){Objects.requireNonNull(rewards);return mutate(state->{boolean[] changed={false};var claims=new HashSet<>(state.characterClaims());var quests=new ArrayList<AdventureQuestState.Quest>();for(var quest:state.quests()){var migration=rewards.apply(quest.site());if(migration.isEmpty()||quest.fishing()!=null){quests.add(quest);continue;}var value=migration.orElseThrow();UUID source=UUID.nameUUIDFromBytes((quest.catalog()+":"+quest.site()+":"+value.bossRoom()+":"+quest.id()).getBytes(java.nio.charset.StandardCharsets.UTF_8));var allocations=state.rewards().allocations().stream().filter(allocation->allocation.quest().equals(quest.id())&&allocation.source().equals(source)).toList();if(!allocations.isEmpty()){allocations.forEach(allocation->claims.add(new AdventureQuestState.CharacterClaim(quest.catalog(),quest.site(),allocation.character())));changed[0]=true;continue;}if(value.defeated()){quests.add(quest);continue;}var reward=value.reward();if(quest.rewardXp().equals(reward.xp())&&quest.rewardCoins().equals(reward.coins()))quests.add(quest);else{quests.add(new AdventureQuestState.Quest(quest.id(),quest.catalog(),quest.site(),quest.unitType(),quest.unit(),quest.snapshot(),quest.expiresAt(),reward.xp(),reward.coins(),quest.participationMode()));changed[0]=true;}}var migrated=new AdventureQuestState(state.sites(),state.givers(),state.catalog(),quests,state.unitClaims(),claims,state.rosters(),validTracking(state.tracking(),quests),state.rewards(),state.reset());return result(migrated,changed[0]);});}
|
||||
public void registerDungeonAvailability(java.util.function.Predicate<AdventureQuestState.Site> available){dungeonAvailable=Objects.requireNonNull(available);}
|
||||
public void registerDungeonSites(java.util.function.Supplier<? extends Collection<AdventureQuestState.Site>> sites){dungeonSites=Objects.requireNonNull(sites);}
|
||||
public boolean available(AdventureQuestState.Site site){return site.type()!=AdventureQuestState.SiteType.DUNGEON||dungeonAvailable.test(site);}
|
||||
public boolean offerOpen(UUID site){var catalog=snapshot.catalog();return catalog!=null&&catalog.sites().contains(site)&&!catalog.closed().contains(site);}
|
||||
public Reward reward(AdventureQuestState.Site site){var rules=settings.get();return switch(site.type()){case CAMP->new Reward(rules.campRewardXp(),rules.campRewardCoins());case CHAMPION->new Reward(rules.championRewardXp(),rules.championRewardCoins());case DUNGEON->dungeonRewards.apply(site);};}
|
||||
public Reward reward(AdventureQuestState.Site site){var rules=settings.get();return switch(site.type()){case CAMP->new Reward(rules.campRewardXp(),rules.campRewardCoins());case CHAMPION->new Reward(rules.championRewardXp(),rules.championRewardCoins());case DUNGEON->dungeonRewards.apply(site);case FISHING->new Reward(site.fishing().rewardXp(),site.fishing().rewardCoins());};}
|
||||
public Optional<AdventureQuestState.Site> findSite(UUID id){return site(snapshot,id);}
|
||||
public Optional<AdventureQuestState.Giver> findGiver(UUID id){return snapshot.givers().stream().filter(v->v.id().equals(id)).findFirst();}
|
||||
public Optional<AdventureQuestState.Quest> findQuest(UUID id){return snapshot.quests().stream().filter(v->v.id().equals(id)).findFirst();}
|
||||
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z){return placeSite(type,name,world,x,y,z,null);}
|
||||
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z,AdventureQuestState.Arena arena){var value=new AdventureQuestState.Site(UUID.randomUUID(),type,name,world,x,y,z,arena);return mutate(state->{var sites=new ArrayList<>(state.sites());sites.add(value);return result(copy(state,List.copyOf(sites),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
|
||||
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z){return placeSite(type,name,world,x,y,z,null,AdventureQuestState.ParticipationMode.PARTY_AVAILABLE);}
|
||||
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z,AdventureQuestState.Arena arena){return placeSite(type,name,world,x,y,z,arena,AdventureQuestState.ParticipationMode.PARTY_AVAILABLE);}
|
||||
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z,AdventureQuestState.Arena arena,AdventureQuestState.ParticipationMode mode){if(type==AdventureQuestState.SiteType.FISHING)throw new IllegalArgumentException("Use complete fishing definition");var value=new AdventureQuestState.Site(UUID.randomUUID(),type,name,world,x,y,z,arena,mode);return mutate(state->{var sites=new ArrayList<>(state.sites());sites.add(value);return result(copy(state,List.copyOf(sites),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
|
||||
public CompletableFuture<AdventureQuestState.Site> placeFishingSite(String name,String itemName,UUID world,double x,double y,double z,int radius,java.math.BigDecimal chance,BigInteger required,BigInteger rewardXp,BigInteger rewardCoins,AdventureQuestState.ParticipationMode mode){var fishing=new AdventureQuestState.FishingDefinition(UUID.randomUUID(),itemName,required,chance,rewardXp,rewardCoins,radius);var value=new AdventureQuestState.Site(UUID.randomUUID(),AdventureQuestState.SiteType.FISHING,name,world,x,y,z,AdventureQuestState.Arena.circle(radius),mode,fishing);return mutate(state->{var sites=new ArrayList<>(state.sites());sites.add(value);return result(copy(state,List.copyOf(sites),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
|
||||
public CompletableFuture<FishingUpdateResult> updateFishing(UUID id,AdventureQuestState.FishingDefinition expected,AdventureQuestState.FishingDefinition requested){var current=findSite(id).orElse(null);if(current==null||current.type()!=AdventureQuestState.SiteType.FISHING)return CompletableFuture.completedFuture(new FishingUpdateResult(FishingUpdateStatus.NOT_FOUND,Optional.empty()));if(!current.fishing().equals(expected))return CompletableFuture.completedFuture(new FishingUpdateResult(FishingUpdateStatus.STALE,Optional.of(current)));return updateFishingSite(id,current,current.name(),requested,current.participationMode());}
|
||||
public CompletableFuture<FishingUpdateResult> updateFishingSite(UUID id,AdventureQuestState.Site expected,String name,AdventureQuestState.FishingDefinition requested,AdventureQuestState.ParticipationMode mode){Objects.requireNonNull(expected);Objects.requireNonNull(requested);Objects.requireNonNull(mode);return mutate(state->{var found=state.sites().stream().filter(value->value.id().equals(id)&&value.type()==AdventureQuestState.SiteType.FISHING).findFirst();if(found.isEmpty())return result(state,new FishingUpdateResult(FishingUpdateStatus.NOT_FOUND,Optional.empty()));var current=found.orElseThrow();if(!current.equals(expected))return result(state,new FishingUpdateResult(FishingUpdateStatus.STALE,Optional.of(current)));if(!current.fishing().itemId().equals(requested.itemId()))throw new IllegalArgumentException("Fishing item identity cannot change");var changed=new AdventureQuestState.Site(current.id(),current.type(),name,current.world(),current.x(),current.y(),current.z(),AdventureQuestState.Arena.circle(requested.radius()),mode,requested);if(changed.equals(current))return result(state,new FishingUpdateResult(FishingUpdateStatus.UNCHANGED,Optional.of(current)));var sites=state.sites().stream().map(value->value.id().equals(id)?changed:value).toList();return result(copy(state,sites,state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),new FishingUpdateResult(FishingUpdateStatus.APPLIED,Optional.of(changed)));});}
|
||||
public CompletableFuture<ModeResult> setSiteMode(UUID id,AdventureQuestState.ParticipationMode expected,AdventureQuestState.ParticipationMode requested){Objects.requireNonNull(expected);Objects.requireNonNull(requested);return mutate(state->{var found=state.sites().stream().filter(value->value.id().equals(id)&&value.type()!=AdventureQuestState.SiteType.DUNGEON).findFirst();if(found.isEmpty())return result(state,new ModeResult(ModeStatus.NOT_FOUND,Optional.empty()));var current=found.orElseThrow();if(current.participationMode()!=expected)return result(state,new ModeResult(ModeStatus.STALE,Optional.of(current)));if(current.participationMode()==requested)return result(state,new ModeResult(ModeStatus.UNCHANGED,Optional.of(current)));var changed=new AdventureQuestState.Site(current.id(),current.type(),current.name(),current.world(),current.x(),current.y(),current.z(),current.arena(),requested,current.fishing());var sites=state.sites().stream().map(value->value.id().equals(id)?changed:value).toList();return result(copy(state,sites,state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),new ModeResult(ModeStatus.APPLIED,Optional.of(changed)));});}
|
||||
public CompletableFuture<Optional<AdventureQuestState.Site>> removeSite(UUID id){return mutate(state->{var found=state.sites().stream().filter(v->v.id().equals(id)&&v.type()!=AdventureQuestState.SiteType.DUNGEON).findFirst();if(found.isEmpty())return result(state,Optional.empty());var sites=state.sites().stream().filter(v->!v.id().equals(id)).toList();var quests=state.quests().stream().filter(v->!v.site().equals(id)).toList();var rosters=new HashMap<>(state.rosters());rosters.remove(id);return result(copy(state,sites,state.givers(),state.catalog(),quests,state.unitClaims(),state.characterClaims(),rosters),found);});}
|
||||
public CompletableFuture<Optional<AdventureQuestState.Site>> removeDungeonSite(UUID id){return mutate(state->{var found=state.sites().stream().filter(v->v.id().equals(id)&&v.type()==AdventureQuestState.SiteType.DUNGEON).findFirst();if(found.isEmpty())return result(state,Optional.empty());var sites=state.sites().stream().filter(v->!v.id().equals(id)).toList();return result(copy(state,sites,state.givers(),state.catalog(),state.quests().stream().filter(v->!v.site().equals(id)).toList(),state.unitClaims(),state.characterClaims(),state.rosters()),found);});}
|
||||
public CompletableFuture<AdventureQuestState.Giver> placeGiver(String name,UUID world,double x,double y,double z,float yaw,float pitch){var value=new AdventureQuestState.Giver(UUID.randomUUID(),name,world,x,y,z,yaw,pitch);return mutate(state->{var values=new ArrayList<>(state.givers());values.add(value);return result(copy(state,state.sites(),List.copyOf(values),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
|
||||
@@ -44,7 +56,8 @@ public final class AdventureQuestDirectory implements AutoCloseable {
|
||||
public CompletableFuture<RerollResult> rerollToday(){var current=snapshot.catalog();return rerollToday(current==null?null:current.revision());}
|
||||
public CompletableFuture<RerollResult> rerollToday(UUID expectedRevision){return mutate(original->{var state=expire(original);var current=state.catalog();UUID actual=current==null?null:current.revision();if(state.reset()!=null||!Objects.equals(expectedRevision,actual))return result(state,new RerollResult(RerollStatus.STALE,Optional.ofNullable(current)));var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));boolean sameDay=current!=null&¤t.day().equals(today);var catalog=roll(state,today,sameDay?Set.copyOf(current.sites()):Set.of());var reset=new AdventureQuestState.Reset(UUID.randomUUID(),catalog.revision(),clock.instant());var next=copyReset(state,state.sites(),state.givers(),catalog,List.of(),Set.of(),Set.of(),Map.of(),reset);return result(next,new RerollResult(RerollStatus.APPLIED,Optional.of(catalog)));});}
|
||||
public CompletableFuture<Boolean> completeReset(UUID operation){return mutate(state->{if(state.reset()==null||!state.reset().operation().equals(operation))return result(state,false);return result(copyReset(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters(),null),true);});}
|
||||
public CompletableFuture<AcceptResult> accept(UUID catalog,UUID site,AdventureQuestState.UnitType type,UUID unit,Set<UUID> members){return mutate(original->{var state=expire(original);var current=state.catalog();if(state.reset()!=null||current==null||!current.day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC)))||!current.revision().equals(catalog))return result(state,new AcceptResult(Acceptance.STALE,Optional.empty()));if(!current.sites().contains(site)||current.closed().contains(site)||site(state,site).filter(this::available).isEmpty())return result(state,new AcceptResult(Acceptance.UNAVAILABLE,Optional.empty()));boolean unitClaimed=state.unitClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.type()==type&&claim.unit().equals(unit));boolean characterClaimed=members.stream().anyMatch(member->state.characterClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.character().equals(member)));if(unitClaimed||characterClaimed)return result(state,new AcceptResult(Acceptance.CLAIMED,Optional.empty()));boolean conflict=state.quests().stream().anyMatch(q->q.unit().equals(unit)||type==AdventureQuestState.UnitType.PARTY&&q.unitType()==AdventureQuestState.UnitType.SOLO&&members.contains(q.unit()));if(conflict)return result(state,new AcceptResult(Acceptance.CONFLICT,Optional.empty()));var offered=site(state,site).orElseThrow();var reward=reward(offered);var quest=new AdventureQuestState.Quest(UUID.randomUUID(),catalog,site,type,unit,members,current.expiresAt(),reward.xp(),reward.coins());var quests=new ArrayList<>(state.quests());quests.add(quest);var tracking=new HashSet<>(state.tracking());for(UUID character:members)tracking.add(new AdventureQuestState.Tracking(quest.id(),character,true));return result(copyTracking(state,state.sites(),state.givers(),current,List.copyOf(quests),state.unitClaims(),state.characterClaims(),state.rosters(),tracking),new AcceptResult(Acceptance.APPLIED,Optional.of(quest)));});}
|
||||
public CompletableFuture<AcceptResult> accept(UUID catalog,UUID site,AdventureQuestState.UnitType type,UUID unit,Set<UUID> members){var offered=findSite(site).orElse(null);return offered==null?CompletableFuture.completedFuture(new AcceptResult(Acceptance.UNAVAILABLE,Optional.empty())):accept(catalog,site,type,unit,members,offered.participationMode());}
|
||||
public CompletableFuture<AcceptResult> accept(UUID catalog,UUID site,AdventureQuestState.UnitType type,UUID unit,Set<UUID> members,AdventureQuestState.ParticipationMode expectedMode){Objects.requireNonNull(expectedMode);var requestedMembers=Set.copyOf(members);return mutate(original->{var state=expire(original);var current=state.catalog();if(state.reset()!=null||current==null||!current.day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC)))||!current.revision().equals(catalog))return result(state,new AcceptResult(Acceptance.STALE,Optional.empty()));var offered=site(state,site).filter(this::available).orElse(null);if(!current.sites().contains(site)||current.closed().contains(site)||offered==null)return result(state,new AcceptResult(Acceptance.UNAVAILABLE,Optional.empty()));if(offered.participationMode()!=expectedMode)return result(state,new AcceptResult(Acceptance.STALE,Optional.empty()));if(!offered.participationMode().permits(type))return result(state,new AcceptResult(Acceptance.POLICY,Optional.empty()));boolean unitClaimed=state.unitClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.type()==type&&claim.unit().equals(unit));boolean characterClaimed=requestedMembers.stream().anyMatch(member->state.characterClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.character().equals(member)));if(unitClaimed||characterClaimed)return result(state,new AcceptResult(Acceptance.CLAIMED,Optional.empty()));boolean conflict=state.quests().stream().anyMatch(q->q.unit().equals(unit)||!Collections.disjoint(q.snapshot(),requestedMembers));if(conflict)return result(state,new AcceptResult(Acceptance.CONFLICT,Optional.empty()));var reward=reward(offered);var quest=new AdventureQuestState.Quest(UUID.randomUUID(),catalog,site,type,unit,requestedMembers,current.expiresAt(),reward.xp(),reward.coins(),offered.participationMode(),offered.fishing()==null?null:offered.fishing().terms());var quests=new ArrayList<>(state.quests());quests.add(quest);var tracking=new HashSet<>(state.tracking());for(UUID character:requestedMembers)tracking.add(new AdventureQuestState.Tracking(quest.id(),character,true));return result(copyTracking(state,state.sites(),state.givers(),current,List.copyOf(quests),state.unitClaims(),state.characterClaims(),state.rosters(),tracking),new AcceptResult(Acceptance.APPLIED,Optional.of(quest)));});}
|
||||
public CompletableFuture<Integer> expireOfferQuests(UUID site){return mutate(state->{var quests=state.quests().stream().filter(value->!value.site().equals(site)).toList();int removed=state.quests().size()-quests.size();return removed==0?result(state,0):result(copy(state,state.sites(),state.givers(),state.catalog(),quests,state.unitClaims(),state.characterClaims(),state.rosters()),removed);});}
|
||||
public CompletableFuture<Boolean> abandon(UUID quest,UUID unit){return mutate(state->{var found=state.quests().stream().filter(q->q.id().equals(quest)&&q.unit().equals(unit)).findFirst();if(found.isEmpty())return result(state,false);return result(copy(state,state.sites(),state.givers(),state.catalog(),state.quests().stream().filter(q->!q.id().equals(quest)).toList(),state.unitClaims(),state.characterClaims(),state.rosters()),true);});}
|
||||
public CompletableFuture<List<EntityType>> roster(UUID site){return mutate(state->{state=expire(state);if(state.reset()!=null||state.catalog()==null||!state.catalog().day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC)))||!state.catalog().sites().contains(site)||find(state.sites(),site).filter(v->v.type()==AdventureQuestState.SiteType.CAMP).isEmpty())throw new IllegalArgumentException("Unavailable camp");var existing=state.rosters().get(site);if(existing!=null)return result(state,existing);var rolled=CampRoster.roll(settings.get().campEnemies(),random);var rosters=new HashMap<>(state.rosters());rosters.put(site,rolled);return result(copy(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),rosters),rolled);});}
|
||||
@@ -59,8 +72,10 @@ public final class AdventureQuestDirectory implements AutoCloseable {
|
||||
public CompletableFuture<Boolean> bankAndComplete(UUID quest,UUID source,Map<UUID,Reward> rewards){return bank(quest,source,rewards,true);}
|
||||
private CompletableFuture<Boolean> bank(UUID questId,UUID source,Map<UUID,Reward> rewards,boolean complete){Objects.requireNonNull(source);var requested=Map.copyOf(rewards);return mutate(original->{var state=expire(original);var quest=state.quests().stream().filter(value->value.id().equals(questId)).findFirst().orElse(null);if(quest==null||!quest.snapshot().containsAll(requested.keySet())||complete&&requested.isEmpty())return result(state,false);var allocations=new ArrayList<>(state.rewards().allocations());for(var entry:requested.entrySet()){var reward=Objects.requireNonNull(entry.getValue());if(reward.xp().signum()==0&&reward.coins().signum()==0)continue;UUID id=rewardId(quest,source,entry.getKey());var existing=allocations.stream().filter(value->value.id().equals(id)).findFirst().orElse(null);if(existing!=null){if(!existing.xp().equals(reward.xp())||!existing.coins().equals(reward.coins()))throw new IllegalStateException("Conflicting adventure reward allocation");continue;}allocations.add(new AdventureQuestState.RewardAllocation(id,quest.catalog(),quest.site(),quest.id(),source,entry.getKey(),reward.xp(),reward.coins()));}var quests=state.quests();var units=state.unitClaims();var characters=state.characterClaims();if(complete){quests=state.quests().stream().filter(value->!value.id().equals(quest.id())).toList();var changedUnits=new HashSet<>(units);changedUnits.add(new AdventureQuestState.UnitClaim(quest.catalog(),quest.site(),quest.unitType(),quest.unit()));units=Set.copyOf(changedUnits);var changedCharacters=new HashSet<>(characters);for(UUID character:requested.keySet())changedCharacters.add(new AdventureQuestState.CharacterClaim(quest.catalog(),quest.site(),character));characters=Set.copyOf(changedCharacters);}var ledger=new AdventureQuestState.RewardLedger(allocations,state.rewards().operations(),state.rewards().receipts());return result(copyRewards(state,state.sites(),state.givers(),state.catalog(),quests,units,characters,state.rosters(),ledger),true);});}
|
||||
public Reward unclaimed(UUID catalog,UUID site,UUID character){BigInteger xp=BigInteger.ZERO,coins=BigInteger.ZERO;for(var value:snapshot.rewards().allocations())if(value.catalog().equals(catalog)&&value.site().equals(site)&&value.character().equals(character)){xp=xp.add(value.xp());coins=coins.add(value.coins());}return new Reward(xp,coins);}
|
||||
public Reward unclaimedAll(UUID character){BigInteger xp=BigInteger.ZERO,coins=BigInteger.ZERO;for(var value:snapshot.rewards().allocations())if(value.character().equals(character)){xp=xp.add(value.xp());coins=coins.add(value.coins());}return new Reward(xp,coins);}
|
||||
public boolean rewardClaimed(UUID catalog,UUID site,UUID character){return snapshot.rewards().receipts().contains(new AdventureQuestState.RewardReceipt(catalog,site,character));}
|
||||
public Optional<AdventureQuestState.RewardClaim> pendingClaim(UUID character){return snapshot.rewards().operations().stream().filter(value->value.character().equals(character)).findFirst();}
|
||||
public CompletableFuture<Optional<AdventureQuestState.RewardClaim>> beginLegacyClaim(UUID character){return mutate(original->{var state=expire(original);var pending=state.rewards().operations().stream().filter(value->value.character().equals(character)).findFirst();if(pending.isPresent())return result(state,pending);var allocations=state.rewards().allocations().stream().filter(value->value.character().equals(character)).sorted(Comparator.comparing(AdventureQuestState.RewardAllocation::id)).toList();if(allocations.isEmpty())return result(state,Optional.empty());BigInteger xp=allocations.stream().map(AdventureQuestState.RewardAllocation::xp).reduce(BigInteger.ZERO,BigInteger::add),coins=allocations.stream().map(AdventureQuestState.RewardAllocation::coins).reduce(BigInteger.ZERO,BigInteger::add);var ids=allocations.stream().map(AdventureQuestState.RewardAllocation::id).collect(java.util.stream.Collectors.toSet());var first=allocations.getFirst();UUID operation=UUID.nameUUIDFromBytes((character+":"+ids.stream().sorted().map(UUID::toString).collect(java.util.stream.Collectors.joining(","))).getBytes(java.nio.charset.StandardCharsets.UTF_8));var claim=new AdventureQuestState.RewardClaim(operation,first.catalog(),first.site(),character,ids,xp,coins);var operations=new ArrayList<>(state.rewards().operations());operations.add(claim);var ledger=new AdventureQuestState.RewardLedger(state.rewards().allocations(),operations,state.rewards().receipts());return result(copyRewards(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters(),ledger),Optional.of(claim));});}
|
||||
public CompletableFuture<Optional<AdventureQuestState.RewardClaim>> beginClaim(UUID catalog,UUID site,UUID character){return mutate(original->{var state=expire(original);var current=state.catalog();if(current==null||!current.revision().equals(catalog)||!current.sites().contains(site)||!clock.instant().isBefore(current.expiresAt()))return result(state,Optional.empty());var pending=state.rewards().operations().stream().filter(value->value.catalog().equals(catalog)&&value.site().equals(site)&&value.character().equals(character)).findFirst();if(pending.isPresent())return result(state,pending);var allocations=state.rewards().allocations().stream().filter(value->value.catalog().equals(catalog)&&value.site().equals(site)&&value.character().equals(character)).toList();if(allocations.isEmpty())return result(state,Optional.empty());BigInteger xp=allocations.stream().map(AdventureQuestState.RewardAllocation::xp).reduce(BigInteger.ZERO,BigInteger::add),coins=allocations.stream().map(AdventureQuestState.RewardAllocation::coins).reduce(BigInteger.ZERO,BigInteger::add);var ids=allocations.stream().map(AdventureQuestState.RewardAllocation::id).collect(java.util.stream.Collectors.toSet());UUID operation=UUID.nameUUIDFromBytes((catalog+":"+site+":"+character+":"+ids.stream().sorted().map(UUID::toString).collect(java.util.stream.Collectors.joining(","))).getBytes(java.nio.charset.StandardCharsets.UTF_8));var claim=new AdventureQuestState.RewardClaim(operation,catalog,site,character,ids,xp,coins);var operations=new ArrayList<>(state.rewards().operations());operations.add(claim);var ledger=new AdventureQuestState.RewardLedger(state.rewards().allocations(),operations,state.rewards().receipts());return result(copyRewards(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters(),ledger),Optional.of(claim));});}
|
||||
public CompletableFuture<Boolean> finishClaim(UUID operation){return mutate(state->{var claim=state.rewards().operations().stream().filter(value->value.id().equals(operation)).findFirst().orElse(null);if(claim==null)return result(state,false);var allocations=state.rewards().allocations().stream().filter(value->!claim.allocations().contains(value.id())).toList();var operations=state.rewards().operations().stream().filter(value->!value.id().equals(operation)).toList();var receipts=new HashSet<>(state.rewards().receipts());receipts.add(new AdventureQuestState.RewardReceipt(claim.catalog(),claim.site(),claim.character()));var ledger=new AdventureQuestState.RewardLedger(allocations,operations,receipts);return result(copyRewards(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters(),ledger),true);});}
|
||||
public CompletableFuture<Integer> expireParties(java.util.function.Predicate<UUID> exists){return mutate(state->{var quests=state.quests().stream().filter(q->q.unitType()!=AdventureQuestState.UnitType.PARTY||exists.test(q.unit())).toList();int removed=state.quests().size()-quests.size();return removed==0?result(state,0):result(copy(state,state.sites(),state.givers(),state.catalog(),quests,state.unitClaims(),state.characterClaims(),state.rosters()),removed);});}
|
||||
@@ -75,7 +90,7 @@ public final class AdventureQuestDirectory implements AutoCloseable {
|
||||
private static AdventureQuestState copyTracking(AdventureQuestState original,List<AdventureQuestState.Site> sites,List<AdventureQuestState.Giver> givers,AdventureQuestState.Catalog catalog,List<AdventureQuestState.Quest> quests,Set<AdventureQuestState.UnitClaim> unitClaims,Set<AdventureQuestState.CharacterClaim> characterClaims,Map<UUID,List<EntityType>> rosters,Set<AdventureQuestState.Tracking> tracking){return new AdventureQuestState(sites,givers,catalog,quests,unitClaims,characterClaims,rosters,validTracking(tracking,quests),original.rewards(),original.reset());}
|
||||
private static AdventureQuestState copyRewards(AdventureQuestState original,List<AdventureQuestState.Site> sites,List<AdventureQuestState.Giver> givers,AdventureQuestState.Catalog catalog,List<AdventureQuestState.Quest> quests,Set<AdventureQuestState.UnitClaim> unitClaims,Set<AdventureQuestState.CharacterClaim> characterClaims,Map<UUID,List<EntityType>> rosters,AdventureQuestState.RewardLedger rewards){return new AdventureQuestState(sites,givers,catalog,quests,unitClaims,characterClaims,rosters,validTracking(original.tracking(),quests),rewards,original.reset());}
|
||||
private static AdventureQuestState copyReset(AdventureQuestState original,List<AdventureQuestState.Site> sites,List<AdventureQuestState.Giver> givers,AdventureQuestState.Catalog catalog,List<AdventureQuestState.Quest> quests,Set<AdventureQuestState.UnitClaim> unitClaims,Set<AdventureQuestState.CharacterClaim> characterClaims,Map<UUID,List<EntityType>> rosters,AdventureQuestState.Reset reset){return new AdventureQuestState(sites,givers,catalog,quests,unitClaims,characterClaims,rosters,validTracking(original.tracking(),quests),pendingRewards(original.rewards()),reset);}
|
||||
private static AdventureQuestState.RewardLedger pendingRewards(AdventureQuestState.RewardLedger rewards){var retainedIds=rewards.operations().stream().flatMap(value->value.allocations().stream()).collect(java.util.stream.Collectors.toSet());return new AdventureQuestState.RewardLedger(rewards.allocations().stream().filter(value->retainedIds.contains(value.id())).toList(),rewards.operations(),Set.of());}
|
||||
private static AdventureQuestState.RewardLedger pendingRewards(AdventureQuestState.RewardLedger rewards){return rewards;}
|
||||
private static Set<AdventureQuestState.Tracking> validTracking(Set<AdventureQuestState.Tracking> tracking,List<AdventureQuestState.Quest> quests){var active=quests.stream().collect(java.util.stream.Collectors.toMap(AdventureQuestState.Quest::id,value->value));return tracking.stream().filter(value->{var quest=active.get(value.quest());return quest!=null&&quest.snapshot().contains(value.character());}).collect(java.util.stream.Collectors.toUnmodifiableSet());}
|
||||
private static UUID rewardId(AdventureQuestState.Quest quest,UUID source,UUID character){return UUID.nameUUIDFromBytes((quest.catalog()+":"+quest.site()+":"+quest.id()+":"+source+":"+character).getBytes(java.nio.charset.StandardCharsets.UTF_8));}
|
||||
private record Result<T>(AdventureQuestState state,T value){}
|
||||
|
||||
@@ -3,6 +3,7 @@ package games.dmg.fantasystick.adventure;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
@@ -11,10 +12,13 @@ import org.bukkit.World;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.CompassMeta;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
|
||||
/** Pure presentation of one accepted quest through the authentic bound book control. */
|
||||
public final class AdventureQuestNavigation {
|
||||
private static final String[] BEARINGS={"N","NE","E","SE","S","SW","W","NW"};
|
||||
public static final org.bukkit.NamespacedKey FISHING_QUEST=new org.bukkit.NamespacedKey("fantasystick","fishing_quest");
|
||||
public static final org.bukkit.NamespacedKey FISHING_SITE=new org.bukkit.NamespacedKey("fantasystick","fishing_site");
|
||||
private final Server server;
|
||||
|
||||
public AdventureQuestNavigation(Server server){this.server=Objects.requireNonNull(server);}
|
||||
@@ -42,6 +46,9 @@ public final class AdventureQuestNavigation {
|
||||
return item;
|
||||
}
|
||||
|
||||
public ItemStack fishingRod(ItemStack ordinaryBook,AdventureQuestState.Quest quest,AdventureQuestState.Site site){Objects.requireNonNull(ordinaryBook);Objects.requireNonNull(quest);Objects.requireNonNull(site);if(quest.fishing()==null||site.type()!=AdventureQuestState.SiteType.FISHING)throw new IllegalArgumentException("Fishing quest required");var item=ordinaryBook.clone();item.setType(Material.FISHING_ROD);var meta=item.getItemMeta();meta.displayName(Component.text("Quest Fishing Rod"));meta.lore(List.of(Component.text(site.displayName()),Component.text(quest.fishing().itemName()+": "+quest.fishing().required()),Component.text("Right-click: Cast / reel"),Component.text("Sneak-right-click: Character book")));meta.setMaxStackSize(1);for(var enchantment:List.copyOf(meta.getEnchants().keySet()))meta.removeEnchant(enchantment);meta.getPersistentDataContainer().set(FISHING_QUEST,PersistentDataType.STRING,quest.id().toString());meta.getPersistentDataContainer().set(FISHING_SITE,PersistentDataType.STRING,site.id().toString());item.setItemMeta(meta);return item;}
|
||||
public static UUID fishingQuest(ItemStack item){try{String value=item==null?null:item.getItemMeta().getPersistentDataContainer().get(FISHING_QUEST,PersistentDataType.STRING);return value==null?null:UUID.fromString(value);}catch(RuntimeException invalid){return null;}}
|
||||
|
||||
public Optional<Component> guidance(Player player,AdventureQuestState.Site site,boolean arrived){
|
||||
Objects.requireNonNull(player);Objects.requireNonNull(site);
|
||||
if(arrived)return Optional.empty();
|
||||
@@ -56,6 +63,6 @@ public final class AdventureQuestNavigation {
|
||||
|
||||
static long distance(Location viewer,AdventureQuestState.Site site){return Math.round(Math.hypot(site.x()-viewer.getX(),site.z()-viewer.getZ()));}
|
||||
static String bearing(Location viewer,AdventureQuestState.Site site){double angle=Math.atan2(site.x()-viewer.getX(),-(site.z()-viewer.getZ()));int index=(int)Math.round(angle/(Math.PI/4));return BEARINGS[Math.floorMod(index,8)];}
|
||||
private static String type(AdventureQuestState.SiteType type){return switch(type){case CAMP->"Monster camp";case CHAMPION->"Champion";case DUNGEON->"Dungeon entrance";};}
|
||||
private static String type(AdventureQuestState.SiteType type){return switch(type){case CAMP->"Monster camp";case CHAMPION->"Champion";case DUNGEON->"Dungeon entrance";case FISHING->"Fishing site";};}
|
||||
private static String worldName(World world){return world==null||world.getName()==null||world.getName().isBlank()?"unavailable world":world.getName();}
|
||||
}
|
||||
@@ -10,9 +10,24 @@ import org.bukkit.entity.EntityType;
|
||||
public record AdventureQuestState(List<Site> sites,List<Giver> givers,Catalog catalog,List<Quest> quests,
|
||||
Set<UnitClaim> unitClaims,Set<CharacterClaim> characterClaims,
|
||||
Map<UUID,List<EntityType>> rosters,Set<Tracking> tracking,RewardLedger rewards,Reset reset) {
|
||||
public enum SiteType { CAMP, CHAMPION, DUNGEON }
|
||||
public enum SiteType { CAMP, CHAMPION, DUNGEON, FISHING }
|
||||
public enum UnitType { SOLO, PARTY }
|
||||
public enum ParticipationMode {
|
||||
INDIVIDUAL_ONLY("individual-only"), PARTY_AVAILABLE("party-available"), PARTY_REQUIRED("party-required");
|
||||
private final String value;
|
||||
ParticipationMode(String value){this.value=value;}
|
||||
public String value(){return value;}
|
||||
public static ParticipationMode parse(String value){return Arrays.stream(values()).filter(mode->mode.value.equals(value)).findFirst().orElseThrow(()->new IllegalArgumentException("Invalid participation mode"));}
|
||||
public boolean permits(UnitType type){return switch(this){case INDIVIDUAL_ONLY->type==UnitType.SOLO;case PARTY_AVAILABLE->true;case PARTY_REQUIRED->type==UnitType.PARTY;};}
|
||||
}
|
||||
public enum ArenaShape { CIRCLE, RECTANGLE }
|
||||
public record FishingTerms(UUID itemId,String itemName,BigInteger required,java.math.BigDecimal chancePercent,BigInteger rewardXp,BigInteger rewardCoins,int radius) {
|
||||
public FishingTerms {Objects.requireNonNull(itemId);itemName=validName(itemName);Objects.requireNonNull(required);Objects.requireNonNull(chancePercent);Objects.requireNonNull(rewardXp);Objects.requireNonNull(rewardCoins);chancePercent=chancePercent.stripTrailingZeros();if(itemName.isBlank()||required.signum()<=0||required.compareTo(BigInteger.valueOf(Integer.MAX_VALUE))>0||chancePercent.scale()>6||chancePercent.compareTo(java.math.BigDecimal.ZERO)<0||chancePercent.compareTo(java.math.BigDecimal.valueOf(100))>0||rewardXp.signum()<0||rewardCoins.signum()<0||rewardXp.compareTo(BigInteger.valueOf(Integer.MAX_VALUE))>0||rewardCoins.compareTo(BigInteger.valueOf(Integer.MAX_VALUE))>0||radius<4||radius>64)throw new IllegalArgumentException("Invalid fishing terms");}
|
||||
}
|
||||
public record FishingDefinition(UUID itemId,String itemName,BigInteger required,java.math.BigDecimal chancePercent,BigInteger rewardXp,BigInteger rewardCoins,int radius) {
|
||||
public FishingDefinition {new FishingTerms(itemId,itemName,required,chancePercent,rewardXp,rewardCoins,radius);itemName=validName(itemName);chancePercent=chancePercent.stripTrailingZeros();}
|
||||
public FishingTerms terms(){return new FishingTerms(itemId,itemName,required,chancePercent,rewardXp,rewardCoins,radius);}
|
||||
}
|
||||
public record Arena(ArenaShape shape,int xBlocks,int zBlocks) {
|
||||
public Arena {
|
||||
Objects.requireNonNull(shape);
|
||||
@@ -26,10 +41,12 @@ public record AdventureQuestState(List<Site> sites,List<Giver> givers,Catalog ca
|
||||
public boolean contains(double xOffset,double zOffset){return shape==ArenaShape.CIRCLE?xOffset*xOffset+zOffset*zOffset<=xBlocks*(double)xBlocks:Math.abs(xOffset)<=xBlocks/2d&&Math.abs(zOffset)<=zBlocks/2d;}
|
||||
public String display(){return shape==ArenaShape.CIRCLE?"circle radius="+xBlocks:"rectangle x="+xBlocks+" z="+zBlocks;}
|
||||
}
|
||||
public record Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z,Arena arena) {
|
||||
public Site { Objects.requireNonNull(id);Objects.requireNonNull(type);name=validName(name);Objects.requireNonNull(world);finite(x,y,z); }
|
||||
public Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z){this(id,type,name,world,x,y,z,null);}
|
||||
public String displayName(){return name.isBlank()?switch(type){case CAMP->"Monster Camp";case CHAMPION->"Champion Site";case DUNGEON->"Dungeon";}:name;}
|
||||
public record Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z,Arena arena,ParticipationMode participationMode,FishingDefinition fishing) {
|
||||
public Site { Objects.requireNonNull(id);Objects.requireNonNull(type);name=validName(name);Objects.requireNonNull(world);finite(x,y,z);Objects.requireNonNull(participationMode);if((type==SiteType.FISHING)!=(fishing!=null)||fishing!=null&&(!Arena.circle(fishing.radius()).equals(arena)))throw new IllegalArgumentException("Invalid fishing site"); }
|
||||
public Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z,Arena arena,ParticipationMode participationMode){this(id,type,name,world,x,y,z,arena,participationMode,null);}
|
||||
public Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z,Arena arena){this(id,type,name,world,x,y,z,arena,ParticipationMode.PARTY_AVAILABLE,null);}
|
||||
public Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z){this(id,type,name,world,x,y,z,null,ParticipationMode.PARTY_AVAILABLE,null);}
|
||||
public String displayName(){return name.isBlank()?switch(type){case CAMP->"Monster Camp";case CHAMPION->"Champion Site";case DUNGEON->"Dungeon";case FISHING->"Fishing Site";}:name;}
|
||||
public Arena arenaOrDefault(int radius){return arena==null?Arena.circle(radius):arena;}
|
||||
}
|
||||
public record Giver(UUID id,String name,UUID world,double x,double y,double z,float yaw,float pitch) {
|
||||
@@ -42,8 +59,10 @@ public record AdventureQuestState(List<Site> sites,List<Giver> givers,Catalog ca
|
||||
public Instant expiresAt(){return day.plusDays(1).atStartOfDay(java.time.ZoneOffset.UTC).toInstant();}
|
||||
}
|
||||
public record Quest(UUID id,UUID catalog,UUID site,UnitType unitType,UUID unit,Set<UUID> snapshot,
|
||||
Instant expiresAt,BigInteger rewardXp,BigInteger rewardCoins) {
|
||||
public Quest { Objects.requireNonNull(id);Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(unitType);Objects.requireNonNull(unit);snapshot=Set.copyOf(snapshot);Objects.requireNonNull(expiresAt);Objects.requireNonNull(rewardXp);Objects.requireNonNull(rewardCoins);if(snapshot.isEmpty()||rewardXp.signum()<0||rewardCoins.signum()<0||unitType==UnitType.SOLO&&(!snapshot.equals(Set.of(unit))))throw new IllegalArgumentException("Invalid quest"); }
|
||||
Instant expiresAt,BigInteger rewardXp,BigInteger rewardCoins,ParticipationMode participationMode,FishingTerms fishing) {
|
||||
public Quest { Objects.requireNonNull(id);Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(unitType);Objects.requireNonNull(unit);snapshot=Set.copyOf(snapshot);Objects.requireNonNull(expiresAt);Objects.requireNonNull(rewardXp);Objects.requireNonNull(rewardCoins);Objects.requireNonNull(participationMode);if(snapshot.isEmpty()||rewardXp.signum()<0||rewardCoins.signum()<0||unitType==UnitType.SOLO&&(!snapshot.equals(Set.of(unit)))||!participationMode.permits(unitType)||fishing!=null&&(!fishing.rewardXp().equals(rewardXp)||!fishing.rewardCoins().equals(rewardCoins)))throw new IllegalArgumentException("Invalid quest"); }
|
||||
public Quest(UUID id,UUID catalog,UUID site,UnitType unitType,UUID unit,Set<UUID> snapshot,Instant expiresAt,BigInteger rewardXp,BigInteger rewardCoins,ParticipationMode participationMode){this(id,catalog,site,unitType,unit,snapshot,expiresAt,rewardXp,rewardCoins,participationMode,null);}
|
||||
public Quest(UUID id,UUID catalog,UUID site,UnitType unitType,UUID unit,Set<UUID> snapshot,Instant expiresAt,BigInteger rewardXp,BigInteger rewardCoins){this(id,catalog,site,unitType,unit,snapshot,expiresAt,rewardXp,rewardCoins,ParticipationMode.PARTY_AVAILABLE,null);}
|
||||
}
|
||||
public record UnitClaim(UUID catalog,UUID site,UnitType type,UUID unit) {
|
||||
public UnitClaim {Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(type);Objects.requireNonNull(unit);}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package games.dmg.fantasystick.adventure;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.persistence.*;
|
||||
import games.dmg.fantasystick.progression.*;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.entity.*;
|
||||
import org.bukkit.event.*;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.event.player.PlayerFishEvent;
|
||||
import org.bukkit.inventory.*;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
/** Owned quest-rod fishing, virtual Inventory, recoverable submission and non-expiring claims. */
|
||||
public final class FishingController implements Listener,AutoCloseable,AdventureResetParticipant {
|
||||
public record Failure(UUID operation,UUID character,String phase,Throwable cause){}
|
||||
private record Context(EquipmentSessions.Active active,CharacterRecord record,UUID party,Set<UUID> members){}
|
||||
private record HookClaim(UUID player,UUID session,UUID character,UUID quest,UUID hook,BigInteger sampledXp){}
|
||||
private record Holder(UUID player,UUID session,UUID character,UUID nonce,int page) implements InventoryHolder{public Inventory getInventory(){return null;}}
|
||||
private final Plugin plugin;private final AdventureQuestDirectory adventures;private final FishingDirectory fishing;private final AdventureQuestSettings settings;private final EquipmentSessions sessions;private final PartyRoster parties;private final CharacterStorage characters;private final ProgressionRules progression;private final Consumer<Runnable> nextTick;private final java.util.random.RandomGenerator random;private final Consumer<Failure> failures;
|
||||
private final NamespacedKey actionKey;private final Map<UUID,HookClaim> hooks=new HashMap<>();private final Map<UUID,UUID> menus=new HashMap<>();private org.bukkit.scheduler.BukkitTask pulseTask;private boolean closed;
|
||||
public FishingController(Plugin plugin,AdventureQuestDirectory adventures,FishingDirectory fishing,AdventureQuestSettings settings,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random,Consumer<Failure> failures){this.plugin=Objects.requireNonNull(plugin);this.adventures=Objects.requireNonNull(adventures);this.fishing=Objects.requireNonNull(fishing);this.settings=Objects.requireNonNull(settings);this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.characters=Objects.requireNonNull(characters);this.progression=Objects.requireNonNull(progression);this.nextTick=Objects.requireNonNull(nextTick);this.random=Objects.requireNonNull(random);this.failures=Objects.requireNonNull(failures);actionKey=new NamespacedKey(plugin,"fishing_menu_action");}
|
||||
public void install(){for(var pending:fishing.pendingCatches())recoverCatch(pending);for(var trophy:fishing.snapshot().trophies())reconcileTrophy(trophy);for(var submission:fishing.pendingSubmissions())recoverSubmission(submission);for(var claim:fishing.snapshot().claims())recoverClaim(claim);pulseTask=plugin.getServer().getScheduler().runTaskTimer(plugin,this::pulse,20,20);}
|
||||
private void pulse(){if(closed)return;for(var player:plugin.getServer().getOnlinePlayers())if(hooks.containsKey(player.getUniqueId())&&!nativeUse(player,player.getInventory().getItemInMainHand()))cleanup(player);}
|
||||
@Override public java.util.concurrent.CompletionStage<Void> reset(AdventureQuestState.Reset reset){for(var player:plugin.getServer().getOnlinePlayers())if(hooks.containsKey(player.getUniqueId()))cleanup(player);hooks.clear();menus.clear();return java.util.concurrent.CompletableFuture.completedFuture(null);}
|
||||
public boolean nativeUse(Player player,ItemStack item){if(closed||adventures.snapshot().reset()!=null||AdventureQuestNavigation.fishingQuest(item)==null)return false;var selected=sessions.selectedControl(player,item).orElse(null);if(selected==null||selected.slot()!=0)return false;var context=context(player);var quest=context==null?null:adventures.findQuest(AdventureQuestNavigation.fishingQuest(item)).orElse(null);return quest!=null&&eligible(context,quest)&&quest.fishing()!=null&&quest.site().equals(site(item));}
|
||||
public BigInteger quantity(UUID character,UUID item){return fishing.quantity(character,item);}
|
||||
public java.util.concurrent.CompletionStage<Boolean> earnTrophy(FishingState.Trophy trophy){return fishing.prepareTrophy(trophy).thenCompose(status->{if(status==FishingDirectory.TrophyPrepareStatus.REDEEMED)return java.util.concurrent.CompletableFuture.completedFuture(true);var retained=fishing.trophy(trophy.id()).orElseThrow();return adventures.complete(retained.quest(),retained.rewards().keySet()).thenCompose(ignored->fishing.commitTrophy(retained.id())).thenApply(committed->committed!=FishingDirectory.TrophyCommitStatus.NOT_FOUND);});}
|
||||
private void reconcileTrophy(FishingState.Trophy trophy){if(trophy.phase()==FishingState.TrophyPhase.EARNED)return;adventures.complete(trophy.quest(),trophy.rewards().keySet()).thenCompose(ignored->fishing.commitTrophy(trophy.id())).whenComplete((ignored,failure)->{if(failure!=null)report(trophy.id(),trophy.carrier(),"trophy-reconcile",failure);});}
|
||||
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.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);}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void sneakBook(org.bukkit.event.player.PlayerInteractEvent event){if(event.getPlayer().isSneaking()&&AdventureQuestNavigation.fishingQuest(event.getItem())!=null)cleanup(event.getPlayer());}
|
||||
private void cleanup(Player player){hooks.remove(player.getUniqueId());try{var hook=player.getFishHook();if(hook!=null)hook.remove();}catch(RuntimeException ignored){}}
|
||||
private void recoverCatch(FishingState.Catch plan){characters.find(plan.character()).whenComplete((record,failure)->{if(failure!=null||record.isEmpty()){if(failure!=null)report(plan,"load",failure);return;}awardCatch(plan,record.orElseThrow());});}
|
||||
private void awardCatch(FishingState.Catch plan,CharacterRecord record){characters.awardCombatReward(record.id(),record.revision(),plan.id(),plan.xp(),BigInteger.ZERO,progression,CombatRewardRules.defaults(),null).whenComplete((award,failure)->{if(failure!=null){report(plan,"award",failure);return;}if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){awardCatch(plan,award.record().orElseThrow());return;}if(award.record().isEmpty()){return;}var saved=award.record().orElseThrow();fishing.completeCatch(plan.id()).whenComplete((status,completeFailure)->{if(completeFailure!=null){report(plan,"complete",completeFailure);return;}schedule(()->{var active=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(active!=null&&award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE)sessions.acknowledgeProgress(active,saved.revision().subtract(BigInteger.ONE),saved);var player=active==null?null:plugin.getServer().getPlayer(active.player());if(player!=null&&status==FishingDirectory.CompleteStatus.APPLIED){String result=plan.itemAwarded()?"Caught "+plan.itemName()+".":"The catch yielded no quest item.";String xp=award.status()==CharacterStorage.CombatAwardStatus.AT_CAP?" Fantasy XP remained at the level cap.":award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE?" Its Fantasy XP was already recorded.":" Earned "+saved.experience().subtract(record.experience()).max(BigInteger.ZERO)+" Fantasy XP.";player.sendMessage(result+xp);}});});});}
|
||||
private void submit(Player player,Context context){var quest=fishingQuest(context);if(quest==null){player.sendMessage("Fishing submission refused: no eligible accepted fishing quest.");return;}var beneficiaries=beneficiaries(context,quest);if(beneficiaries.isEmpty()||!beneficiaries.contains(context.record().id())){player.sendMessage("Fishing submission refused: party eligibility changed.");return;}var unit=new RewardAllocator.Unit<UUID>(quest.unit(),beneficiaries);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(UUID character:beneficiaries)rewards.put(character,new FishingState.Reward(xp.get(character),coins.get(character)));UUID operation=UUID.nameUUIDFromBytes((quest.catalog()+":"+quest.site()+":"+quest.id()+":"+context.record().id()).getBytes(StandardCharsets.UTF_8));var plan=new FishingState.Submission(operation,quest.id(),quest.catalog(),quest.site(),context.record().id(),quest.fishing().itemId(),quest.fishing().required(),rewards,FishingState.SubmissionPhase.PREPARED);fishing.prepareSubmission(plan).whenComplete((status,failure)->{if(failure!=null){report(operation,context.record().id(),"submit-prepare",failure);return;}if(status==FishingDirectory.PrepareStatus.INSUFFICIENT||status==FishingDirectory.PrepareStatus.COMPLETED){schedule(()->player.sendMessage(status==FishingDirectory.PrepareStatus.INSUFFICIENT?"Fishing submission refused: the full required quantity is not held.":"Fishing submission refused: this character already completed the offered generation."));return;}commitSubmission(plan,player);});}
|
||||
private void recoverSubmission(FishingState.Submission plan){if(plan.phase()==FishingState.SubmissionPhase.PREPARED)fishing.commitSubmission(plan.id()).whenComplete((status,failure)->{if(failure!=null){report(plan.id(),plan.submitter(),"submission-recovery-commit",failure);return;}if(status==FishingDirectory.CommitStatus.APPLIED||status==FishingDirectory.CommitStatus.DUPLICATE)finishRecoveredSubmission(plan);});else finishRecoveredSubmission(plan);}
|
||||
private void finishRecoveredSubmission(FishingState.Submission plan){adventures.complete(plan.quest(),plan.rewards().keySet()).whenComplete((ignored,failure)->{if(failure!=null){report(plan.id(),plan.submitter(),"submission-recovery-quest",failure);return;}fishing.finishSubmission(plan.id()).whenComplete((finished,finishFailure)->{if(finishFailure!=null)report(plan.id(),plan.submitter(),"submission-recovery-finish",finishFailure);});});}
|
||||
private void commitSubmission(FishingState.Submission plan,Player player){fishing.commitSubmission(plan.id()).whenComplete((status,failure)->{if(failure!=null){report(plan.id(),plan.submitter(),"submit-commit",failure);return;}if(status==FishingDirectory.CommitStatus.INSUFFICIENT){schedule(()->player.sendMessage("Fishing submission refused: stock changed before commitment."));return;}if(status==FishingDirectory.CommitStatus.NOT_FOUND)return;adventures.complete(plan.quest(),plan.rewards().keySet()).whenComplete((completed,questFailure)->{if(questFailure!=null){report(plan.id(),plan.submitter(),"quest-complete",questFailure);return;}fishing.finishSubmission(plan.id()).whenComplete((finished,finishFailure)->{if(finishFailure!=null)report(plan.id(),plan.submitter(),"submit-finish",finishFailure);schedule(()->{player.sendMessage("Fishing quest submitted; earned shares are ready at any Quest Giver.");open(player);});});});});}
|
||||
private void redeemTrophy(Player player,Context context,String encoded){UUID trophy;try{trophy=UUID.fromString(encoded);}catch(IllegalArgumentException invalid){player.sendMessage("Trophy redemption refused: invalid authority.");return;}fishing.redeemTrophy(trophy,context.record().id()).whenComplete((status,failure)->schedule(()->{if(failure!=null){report(trophy,context.record().id(),"trophy-redeem",failure);player.sendMessage("Trophy redemption failed; the earned trophy is retained.");return;}if(status==FishingDirectory.TrophyRedeemStatus.APPLIED)player.sendMessage("Adventure trophy redeemed; frozen shares are ready for each beneficiary.");else player.sendMessage("Trophy redemption refused: the receipt is unavailable for this character.");open(player);}));}
|
||||
private void claimLegacy(Player player,Context context){adventures.beginLegacyClaim(context.record().id()).whenComplete((claim,failure)->{if(failure!=null||claim.isEmpty()){if(failure!=null)report(UUID.randomUUID(),context.record().id(),"legacy-claim-prepare",failure);schedule(()->player.sendMessage("Retained adventure reward claim failed; earned shares remain."));return;}awardLegacy(player,claim.orElseThrow(),context.record());});}
|
||||
private void awardLegacy(Player player,AdventureQuestState.RewardClaim claim,CharacterRecord record){characters.awardCombatReward(record.id(),record.revision(),claim.id(),claim.xp(),claim.coins(),progression,CombatRewardRules.defaults(),null).whenComplete((award,failure)->{if(failure!=null){report(claim.id(),claim.character(),"legacy-claim-award",failure);return;}if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){awardLegacy(player,claim,award.record().orElseThrow());return;}if(award.record().isEmpty())return;var saved=award.record().orElseThrow();adventures.finishClaim(claim.id()).whenComplete((finished,finishFailure)->{if(finishFailure!=null){report(claim.id(),claim.character(),"legacy-claim-finish",finishFailure);return;}schedule(()->{var current=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(current!=null&&award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE)sessions.acknowledgeProgress(current,saved.revision().subtract(BigInteger.ONE),saved);var target=current==null?null:plugin.getServer().getPlayer(current.player());if(target!=null){target.sendMessage(award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE?"Finished a previously applied adventure reward claim.":"Claimed "+saved.experience().subtract(record.experience()).max(BigInteger.ZERO)+" retained adventure XP and "+saved.coins().subtract(record.coins()).max(BigInteger.ZERO)+" character coins.");open(target);}});});});}
|
||||
private void claim(Player player,Context context){fishing.beginClaim(context.record().id()).whenComplete((claim,failure)->{if(failure!=null||claim.isEmpty()){if(failure!=null)report(UUID.randomUUID(),context.record().id(),"claim-prepare",failure);schedule(()->player.sendMessage("Fishing reward claim failed; earned shares are retained."));return;}awardClaim(player,context.active(),claim.orElseThrow(),context.record());});}
|
||||
private void recoverClaim(FishingState.Claim claim){characters.find(claim.character()).whenComplete((record,failure)->{if(failure!=null){report(claim.id(),claim.character(),"claim-load",failure);return;}record.ifPresent(value->awardClaim(null,null,claim,value));});}
|
||||
private void awardClaim(Player player,EquipmentSessions.Active active,FishingState.Claim claim,CharacterRecord record){characters.awardCombatReward(record.id(),record.revision(),claim.id(),claim.xp(),claim.coins(),progression,CombatRewardRules.defaults(),null).whenComplete((award,failure)->{if(failure!=null){report(claim.id(),claim.character(),"claim-award",failure);return;}if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){awardClaim(player,active,claim,award.record().orElseThrow());return;}if(award.record().isEmpty())return;var saved=award.record().orElseThrow();fishing.finishClaim(claim.id()).whenComplete((finished,finishFailure)->{if(finishFailure!=null){report(claim.id(),claim.character(),"claim-finish",finishFailure);return;}schedule(()->{var current=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(current!=null&&award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE)sessions.acknowledgeProgress(current,saved.revision().subtract(BigInteger.ONE),saved);var target=current==null?null:plugin.getServer().getPlayer(current.player());if(target!=null){target.sendMessage(award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE?"Finished a previously applied fishing reward claim.":"Claimed "+saved.experience().subtract(record.experience()).max(BigInteger.ZERO)+" quest XP and "+saved.coins().subtract(record.coins()).max(BigInteger.ZERO)+" character coins.");open(target);}});});});}
|
||||
private AdventureQuestState.Quest fishingQuest(Context context){if(adventures.snapshot().reset()!=null)return null;return adventures.activeFor(context.record().id(),context.party()).filter(value->value.fishing()!=null&&eligible(context,value)).orElse(null);}
|
||||
private boolean eligible(Context context,AdventureQuestState.Quest quest){if(quest.unitType()==AdventureQuestState.UnitType.SOLO)return quest.unit().equals(context.record().id());return context.party()!=null&&quest.unit().equals(context.party())&&quest.snapshot().contains(context.record().id())&&context.members().contains(context.record().id());}
|
||||
private List<UUID> beneficiaries(Context context,AdventureQuestState.Quest quest){if(quest.unitType()==AdventureQuestState.UnitType.SOLO)return List.of(context.record().id());var party=parties.partyOfCharacter(context.record().id()).filter(value->value.id().equals(quest.unit())).orElse(null);return party==null?List.of():quest.snapshot().stream().filter(party.characters()::contains).distinct().sorted().toList();}
|
||||
private Context context(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return null;var record=sessions.currentRecord(active).orElse(null);if(record==null||record.dead()||player.isDead())return null;var party=parties.partyOf(player.getUniqueId()).orElse(null);return new Context(active,record,party==null?null:party.id(),party==null?Set.of(record.id()):Set.copyOf(party.characters()));}
|
||||
private boolean inside(Location location,AdventureQuestState.Quest quest){var site=adventures.findSite(quest.site()).orElse(null);return site!=null&&site.type()==AdventureQuestState.SiteType.FISHING&&location.getWorld()!=null&&location.getWorld().getUID().equals(site.world())&&AdventureQuestState.Arena.circle(quest.fishing().radius()).contains(location.getX()-site.x(),location.getZ()-site.z());}
|
||||
private BigInteger sampleXp(){var rules=settings.rules();long minimum=rules.fishingCatchXpMin().longValueExact(),maximum=rules.fishingCatchXpMax().longValueExact();return BigInteger.valueOf(minimum+(maximum==minimum?0:random.nextLong(maximum-minimum+1)));}
|
||||
private UUID site(ItemStack item){try{String value=item.getItemMeta().getPersistentDataContainer().get(AdventureQuestNavigation.FISHING_SITE,PersistentDataType.STRING);return value==null?null:UUID.fromString(value);}catch(RuntimeException invalid){return null;}}
|
||||
private ItemStack item(Material material,String name,String action,List<String> lore){var item=new ItemStack(material);var meta=item.getItemMeta();meta.displayName(net.kyori.adventure.text.Component.text(name));meta.lore(lore.stream().map(net.kyori.adventure.text.Component::text).toList());if(action!=null)meta.getPersistentDataContainer().set(actionKey,PersistentDataType.STRING,action);item.setItemMeta(meta);return item;}
|
||||
private void report(FishingState.Catch plan,String phase,Throwable failure){report(plan.id(),plan.character(),phase,failure);}
|
||||
private void report(UUID operation,UUID character,String phase,Throwable failure){try{failures.accept(new Failure(operation,character,phase,failure));}catch(RuntimeException ignored){}}
|
||||
private void schedule(Runnable task){if(closed)return;try{nextTick.accept(()->{if(!closed)task.run();});}catch(RuntimeException ignored){}}
|
||||
@Override public void close(){closed=true;if(pulseTask!=null)pulseTask.cancel();for(var player:plugin.getServer().getOnlinePlayers())if(hooks.containsKey(player.getUniqueId()))cleanup(player);hooks.clear();menus.clear();}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package games.dmg.fantasystick.adventure;
|
||||
|
||||
import games.dmg.fantasystick.persistence.YamlFishingRepository;
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
|
||||
/** Serialized save-before-publication authority for fishing stock, submissions and earned shares. */
|
||||
public final class FishingDirectory implements AutoCloseable {
|
||||
public enum PrepareStatus { APPLIED, EXISTING, DUPLICATE, INSUFFICIENT, COMPLETED }
|
||||
public enum CompleteStatus { APPLIED, DUPLICATE, NOT_FOUND }
|
||||
public enum CommitStatus { APPLIED, DUPLICATE, INSUFFICIENT, NOT_FOUND }
|
||||
public enum TrophyPrepareStatus { APPLIED, EXISTING, REDEEMED }
|
||||
public enum TrophyRedeemStatus { APPLIED, REDEEMED, NOT_FOUND, WRONG_CARRIER }
|
||||
public enum TrophyCommitStatus { APPLIED, DUPLICATE, NOT_FOUND }
|
||||
private final YamlFishingRepository repository;
|
||||
private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-fishing-persistence"));
|
||||
private volatile FishingState snapshot;private volatile java.util.function.Function<FishingState.ItemStack,String> labels=FishingState.ItemStack::name;private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
public FishingDirectory(YamlFishingRepository repository)throws IOException{this.repository=Objects.requireNonNull(repository);snapshot=repository.load();}
|
||||
public FishingState snapshot(){return snapshot;}
|
||||
public void registerLabels(java.util.function.Function<FishingState.ItemStack,String> labels){this.labels=Objects.requireNonNull(labels);}
|
||||
public List<FishingState.ItemStack> inventory(UUID character){var resolver=labels;return snapshot.inventory().stream().filter(value->value.character().equals(character)).map(value->{String name=resolver.apply(value);return name.equals(value.name())?value:new FishingState.ItemStack(value.character(),value.item(),value.site(),name,value.amount());}).sorted(Comparator.comparing(FishingState.ItemStack::name).thenComparing(FishingState.ItemStack::item)).toList();}
|
||||
public BigInteger quantity(UUID character,UUID item){return snapshot.inventory().stream().filter(value->value.character().equals(character)&&value.item().equals(item)).map(FishingState.ItemStack::amount).findFirst().orElse(BigInteger.ZERO);}
|
||||
public Optional<FishingState.Trophy> trophy(UUID id){return snapshot.trophies().stream().filter(value->value.id().equals(id)).findFirst();}
|
||||
public List<FishingState.Trophy> trophies(UUID carrier){return snapshot.trophies().stream().filter(value->value.phase()==FishingState.TrophyPhase.EARNED&&value.carrier().equals(carrier)).sorted(Comparator.comparing(FishingState.Trophy::name).thenComparing(FishingState.Trophy::id)).toList();}
|
||||
public CompletableFuture<TrophyPrepareStatus> prepareTrophy(FishingState.Trophy requested){Objects.requireNonNull(requested);return mutate(state->{if(state.trophyReceipts().contains(requested.id()))return result(state,TrophyPrepareStatus.REDEEMED);var existing=state.trophies().stream().filter(value->value.id().equals(requested.id())).findFirst();if(existing.isPresent()){if(!sameTrophyCompletion(existing.orElseThrow(),requested))throw new IllegalStateException("Conflicting adventure trophy");return result(state,TrophyPrepareStatus.EXISTING);}var trophies=new ArrayList<>(state.trophies());trophies.add(requested);return result(copyTrophies(state,trophies,state.trophyReceipts(),state.allocations()),TrophyPrepareStatus.APPLIED);});}
|
||||
public CompletableFuture<TrophyCommitStatus> commitTrophy(UUID id){return mutate(state->{var trophy=state.trophies().stream().filter(value->value.id().equals(id)).findFirst().orElse(null);if(trophy==null)return result(state,TrophyCommitStatus.NOT_FOUND);if(trophy.phase()==FishingState.TrophyPhase.EARNED)return result(state,TrophyCommitStatus.DUPLICATE);var trophies=state.trophies().stream().map(value->value.id().equals(id)?value.earned():value).toList();return result(copyTrophies(state,trophies,state.trophyReceipts(),state.allocations()),TrophyCommitStatus.APPLIED);});}
|
||||
public CompletableFuture<TrophyRedeemStatus> redeemTrophy(UUID id,UUID carrier){Objects.requireNonNull(id);Objects.requireNonNull(carrier);return mutate(state->{if(state.trophyReceipts().contains(id))return result(state,TrophyRedeemStatus.REDEEMED);var trophy=state.trophies().stream().filter(value->value.id().equals(id)).findFirst().orElse(null);if(trophy==null||trophy.phase()!=FishingState.TrophyPhase.EARNED)return result(state,TrophyRedeemStatus.NOT_FOUND);if(!trophy.carrier().equals(carrier))return result(state,TrophyRedeemStatus.WRONG_CARRIER);var allocations=new ArrayList<>(state.allocations());for(var entry:trophy.rewards().entrySet()){var reward=entry.getValue();if(reward.xp().signum()==0&&reward.coins().signum()==0)continue;UUID allocation=trophyAllocationId(trophy.id(),entry.getKey());if(allocations.stream().noneMatch(value->value.id().equals(allocation)))allocations.add(new FishingState.Allocation(allocation,trophy.id(),trophy.catalog(),trophy.site(),entry.getKey(),reward.xp(),reward.coins()));}var receipts=new HashSet<>(state.trophyReceipts());receipts.add(id);return result(copyTrophies(state,state.trophies().stream().filter(value->!value.id().equals(id)).toList(),receipts,allocations),TrophyRedeemStatus.APPLIED);});}
|
||||
public List<FishingState.Catch> pendingCatches(){return snapshot.catches();}
|
||||
public CompletableFuture<PrepareStatus> prepareCatch(FishingState.Catch requested){Objects.requireNonNull(requested);return mutate(state->{if(state.catchReceipts().contains(requested.id()))return result(state,PrepareStatus.DUPLICATE);var existing=state.catches().stream().filter(value->value.id().equals(requested.id())).findFirst();if(existing.isPresent()){if(!existing.orElseThrow().equals(requested))throw new IllegalStateException("Conflicting fishing catch");return result(state,PrepareStatus.EXISTING);}var catches=new ArrayList<>(state.catches());catches.add(requested);return result(copy(state,state.inventory(),catches,state.catchReceipts(),state.submissions(),state.completions(),state.allocations(),state.claims()),PrepareStatus.APPLIED);});}
|
||||
public CompletableFuture<CompleteStatus> completeCatch(UUID operation){return mutate(state->{if(state.catchReceipts().contains(operation))return result(state,CompleteStatus.DUPLICATE);var caught=state.catches().stream().filter(value->value.id().equals(operation)).findFirst().orElse(null);if(caught==null)return result(state,CompleteStatus.NOT_FOUND);var inventory=new ArrayList<>(state.inventory());if(caught.itemAwarded()){var existing=inventory.stream().filter(value->value.character().equals(caught.character())&&value.item().equals(caught.item())).findFirst().orElse(null);if(existing==null)inventory.add(new FishingState.ItemStack(caught.character(),caught.item(),caught.site(),caught.itemName(),BigInteger.ONE));else{var updated=new FishingState.ItemStack(existing.character(),existing.item(),existing.site(),caught.itemName(),existing.amount().add(BigInteger.ONE));inventory.replaceAll(value->value==existing?updated:value);}}var receipts=new HashSet<>(state.catchReceipts());receipts.add(operation);var catches=state.catches().stream().filter(value->!value.id().equals(operation)).toList();return result(copy(state,inventory,catches,receipts,state.submissions(),state.completions(),state.allocations(),state.claims()),CompleteStatus.APPLIED);});}
|
||||
public CompletableFuture<PrepareStatus> prepareSubmission(FishingState.Submission requested){Objects.requireNonNull(requested);if(requested.phase()!=FishingState.SubmissionPhase.PREPARED)throw new IllegalArgumentException("Submission must begin prepared");return mutate(state->{var existing=state.submissions().stream().filter(value->value.id().equals(requested.id())).findFirst();if(existing.isPresent()){if(!sameSubmission(existing.orElseThrow(),requested))throw new IllegalStateException("Conflicting fishing submission");return result(state,PrepareStatus.EXISTING);}if(state.submissions().stream().anyMatch(value->value.quest().equals(requested.quest())))return result(state,PrepareStatus.COMPLETED);boolean completed=requested.rewards().keySet().stream().anyMatch(character->state.completions().contains(new FishingState.Completion(requested.catalog(),requested.site(),character)));if(completed)return result(state,PrepareStatus.COMPLETED);if(quantity(state,requested.submitter(),requested.item()).compareTo(requested.quantity())<0)return result(state,PrepareStatus.INSUFFICIENT);var submissions=new ArrayList<>(state.submissions());submissions.add(requested);return result(copy(state,state.inventory(),state.catches(),state.catchReceipts(),submissions,state.completions(),state.allocations(),state.claims()),PrepareStatus.APPLIED);});}
|
||||
public CompletableFuture<CommitStatus> commitSubmission(UUID operation){return mutate(state->{var submission=state.submissions().stream().filter(value->value.id().equals(operation)).findFirst().orElse(null);if(submission==null)return result(state,CommitStatus.NOT_FOUND);if(submission.phase()==FishingState.SubmissionPhase.COMMITTED)return result(state,CommitStatus.DUPLICATE);if(quantity(state,submission.submitter(),submission.item()).compareTo(submission.quantity())<0)return result(state,CommitStatus.INSUFFICIENT);var inventory=new ArrayList<>(state.inventory());var existing=inventory.stream().filter(value->value.character().equals(submission.submitter())&&value.item().equals(submission.item())).findFirst().orElseThrow();var remaining=existing.amount().subtract(submission.quantity());if(remaining.signum()==0)inventory.remove(existing);else inventory.replaceAll(value->value==existing?new FishingState.ItemStack(existing.character(),existing.item(),existing.site(),existing.name(),remaining):value);var completions=new HashSet<>(state.completions());var allocations=new ArrayList<>(state.allocations());for(var entry:submission.rewards().entrySet()){completions.add(new FishingState.Completion(submission.catalog(),submission.site(),entry.getKey()));var reward=entry.getValue();if(reward.xp().signum()==0&&reward.coins().signum()==0)continue;UUID id=allocationId(submission.id(),entry.getKey());allocations.add(new FishingState.Allocation(id,submission.id(),submission.catalog(),submission.site(),entry.getKey(),reward.xp(),reward.coins()));}var submissions=state.submissions().stream().map(value->value.id().equals(operation)?value.committed():value).toList();return result(copy(state,inventory,state.catches(),state.catchReceipts(),submissions,completions,allocations,state.claims()),CommitStatus.APPLIED);});}
|
||||
public CompletableFuture<Boolean> finishSubmission(UUID operation){return mutate(state->{var found=state.submissions().stream().filter(value->value.id().equals(operation)&&value.phase()==FishingState.SubmissionPhase.COMMITTED).findFirst();if(found.isEmpty())return result(state,false);return result(copy(state,state.inventory(),state.catches(),state.catchReceipts(),state.submissions().stream().filter(value->!value.id().equals(operation)).toList(),state.completions(),state.allocations(),state.claims()),true);});}
|
||||
public List<FishingState.Submission> pendingSubmissions(){return snapshot.submissions();}
|
||||
public boolean completed(UUID catalog,UUID site,UUID character){return snapshot.completions().contains(new FishingState.Completion(catalog,site,character));}
|
||||
public FishingState.Reward unclaimed(UUID character){return snapshot.allocations().stream().filter(value->value.character().equals(character)).map(FishingState.Allocation::reward).reduce(FishingState.Reward.empty(),FishingState.Reward::add);}
|
||||
public Optional<FishingState.Claim> pendingClaim(UUID character){return snapshot.claims().stream().filter(value->value.character().equals(character)).findFirst();}
|
||||
public CompletableFuture<Optional<FishingState.Claim>> beginClaim(UUID character){return mutate(state->{var existing=state.claims().stream().filter(value->value.character().equals(character)).findFirst();if(existing.isPresent())return result(state,existing);var allocations=state.allocations().stream().filter(value->value.character().equals(character)).toList();if(allocations.isEmpty())return result(state,Optional.empty());var ids=allocations.stream().map(FishingState.Allocation::id).collect(java.util.stream.Collectors.toSet());BigInteger xp=allocations.stream().map(FishingState.Allocation::xp).reduce(BigInteger.ZERO,BigInteger::add),coins=allocations.stream().map(FishingState.Allocation::coins).reduce(BigInteger.ZERO,BigInteger::add);UUID id=UUID.nameUUIDFromBytes((character+":"+ids.stream().sorted().map(UUID::toString).collect(java.util.stream.Collectors.joining(","))).getBytes(StandardCharsets.UTF_8));var claim=new FishingState.Claim(id,character,ids,xp,coins);var claims=new ArrayList<>(state.claims());claims.add(claim);return result(copy(state,state.inventory(),state.catches(),state.catchReceipts(),state.submissions(),state.completions(),state.allocations(),claims),Optional.of(claim));});}
|
||||
public CompletableFuture<Boolean> finishClaim(UUID operation){return mutate(state->{var claim=state.claims().stream().filter(value->value.id().equals(operation)).findFirst().orElse(null);if(claim==null)return result(state,false);var allocations=state.allocations().stream().filter(value->!claim.allocations().contains(value.id())).toList();var claims=state.claims().stream().filter(value->!value.id().equals(operation)).toList();return result(copy(state,state.inventory(),state.catches(),state.catchReceipts(),state.submissions(),state.completions(),allocations,claims),true);});}
|
||||
private static boolean sameTrophyCompletion(FishingState.Trophy left,FishingState.Trophy right){return left.id().equals(right.id())&&left.type()==right.type()&&left.carrier().equals(right.carrier())&&left.catalog().equals(right.catalog())&&left.site().equals(right.site())&&left.quest().equals(right.quest())&&left.name().equals(right.name())&&left.origin().equals(right.origin())&&left.rewards().equals(right.rewards());}
|
||||
private static boolean sameSubmission(FishingState.Submission left,FishingState.Submission right){return left.id().equals(right.id())&&left.quest().equals(right.quest())&&left.catalog().equals(right.catalog())&&left.site().equals(right.site())&&left.submitter().equals(right.submitter())&&left.item().equals(right.item())&&left.quantity().equals(right.quantity())&&left.rewards().equals(right.rewards());}
|
||||
private static BigInteger quantity(FishingState state,UUID character,UUID item){return state.inventory().stream().filter(value->value.character().equals(character)&&value.item().equals(item)).map(FishingState.ItemStack::amount).findFirst().orElse(BigInteger.ZERO);}
|
||||
private static UUID allocationId(UUID submission,UUID character){return UUID.nameUUIDFromBytes(("fishing:"+submission+":"+character).getBytes(StandardCharsets.UTF_8));}
|
||||
private static UUID trophyAllocationId(UUID trophy,UUID character){return UUID.nameUUIDFromBytes(("trophy:"+trophy+":"+character).getBytes(StandardCharsets.UTF_8));}
|
||||
private static FishingState copy(FishingState original,List<FishingState.ItemStack> inventory,List<FishingState.Catch> catches,Set<UUID> receipts,List<FishingState.Submission> submissions,Set<FishingState.Completion> completions,List<FishingState.Allocation> allocations,List<FishingState.Claim> claims){return new FishingState(inventory,original.trophies(),original.trophyReceipts(),catches,receipts,submissions,completions,allocations,claims);}
|
||||
private static FishingState copyTrophies(FishingState original,List<FishingState.Trophy> trophies,Set<UUID> receipts,List<FishingState.Allocation> allocations){return new FishingState(original.inventory(),trophies,receipts,original.catches(),original.catchReceipts(),original.submissions(),original.completions(),allocations,original.claims());}
|
||||
private record Result<T>(FishingState state,T value){}
|
||||
private static <T>Result<T> result(FishingState state,T value){return new Result<>(state,value);}
|
||||
private <T>CompletableFuture<T> mutate(java.util.function.Function<FishingState,Result<T>> operation){var future=new CompletableFuture<T>();synchronized(this){if(closed){future.completeExceptionally(new IOException("Fishing directory closed"));return future;}pending++;}worker.execute(()->{try{var result=operation.apply(snapshot);if(result.state()!=snapshot){repository.save(result.state());snapshot=result.state();}future.complete(result.value());}catch(Throwable failure){future.completeExceptionally(failure);}finally{finished();}});return future;}
|
||||
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,59 @@
|
||||
package games.dmg.fantasystick.adventure;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.*;
|
||||
|
||||
/** Durable character-owned fishing stock, recoverable operations and non-expiring reward shares. */
|
||||
public record FishingState(List<ItemStack> inventory,List<Trophy> trophies,Set<UUID> trophyReceipts,
|
||||
List<Catch> catches,Set<UUID> catchReceipts,List<Submission> submissions,
|
||||
Set<Completion> completions,List<Allocation> allocations,List<Claim> claims) {
|
||||
public record Reward(BigInteger xp,BigInteger coins) {
|
||||
public Reward {Objects.requireNonNull(xp);Objects.requireNonNull(coins);if(xp.signum()<0||coins.signum()<0)throw new IllegalArgumentException("Invalid fishing reward");}
|
||||
public static Reward empty(){return new Reward(BigInteger.ZERO,BigInteger.ZERO);}
|
||||
public Reward add(Reward other){return new Reward(xp.add(other.xp),coins.add(other.coins));}
|
||||
}
|
||||
public record ItemStack(UUID character,UUID item,UUID site,String name,BigInteger amount) {
|
||||
public ItemStack {Objects.requireNonNull(character);Objects.requireNonNull(item);Objects.requireNonNull(site);name=validName(name);Objects.requireNonNull(amount);if(name.isBlank()||amount.signum()<=0)throw new IllegalArgumentException("Invalid fishing item stack");}
|
||||
}
|
||||
public enum TrophyType { CHAMPION, CAMP, DUNGEON }
|
||||
public enum TrophyPhase { PREPARED, EARNED }
|
||||
public record Trophy(UUID id,TrophyType type,UUID carrier,UUID catalog,UUID site,UUID quest,UUID source,String name,String origin,Map<UUID,Reward> rewards,TrophyPhase phase) {
|
||||
public Trophy {Objects.requireNonNull(id);Objects.requireNonNull(type);Objects.requireNonNull(carrier);Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(quest);Objects.requireNonNull(source);name=validName(name);origin=validName(origin);rewards=Map.copyOf(rewards);Objects.requireNonNull(phase);if(name.isBlank()||origin.isBlank()||rewards.isEmpty()||rewards.entrySet().stream().anyMatch(entry->entry.getKey()==null||entry.getValue()==null))throw new IllegalArgumentException("Invalid adventure trophy");}
|
||||
public Trophy(UUID id,TrophyType type,UUID carrier,UUID catalog,UUID site,UUID quest,UUID source,String name,String origin,Map<UUID,Reward> rewards){this(id,type,carrier,catalog,site,quest,source,name,origin,rewards,TrophyPhase.PREPARED);}
|
||||
public Reward total(){return rewards.values().stream().reduce(Reward.empty(),Reward::add);}
|
||||
public Trophy earned(){return phase==TrophyPhase.EARNED?this:new Trophy(id,type,carrier,catalog,site,quest,source,name,origin,rewards,TrophyPhase.EARNED);}
|
||||
}
|
||||
public record Catch(UUID id,UUID character,UUID quest,UUID site,UUID item,String itemName,BigInteger xp,boolean itemAwarded) {
|
||||
public Catch {Objects.requireNonNull(id);Objects.requireNonNull(character);Objects.requireNonNull(quest);Objects.requireNonNull(site);Objects.requireNonNull(item);itemName=validName(itemName);Objects.requireNonNull(xp);if(itemName.isBlank()||xp.signum()<0||xp.compareTo(BigInteger.valueOf(Integer.MAX_VALUE))>0)throw new IllegalArgumentException("Invalid fishing catch");}
|
||||
}
|
||||
public enum SubmissionPhase { PREPARED, COMMITTED }
|
||||
public record Submission(UUID id,UUID quest,UUID catalog,UUID site,UUID submitter,UUID item,BigInteger quantity,
|
||||
Map<UUID,Reward> rewards,SubmissionPhase phase) {
|
||||
public Submission {Objects.requireNonNull(id);Objects.requireNonNull(quest);Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(submitter);Objects.requireNonNull(item);Objects.requireNonNull(quantity);rewards=Map.copyOf(rewards);Objects.requireNonNull(phase);if(quantity.signum()<=0||rewards.isEmpty()||!rewards.containsKey(submitter)||rewards.entrySet().stream().anyMatch(entry->entry.getKey()==null||entry.getValue()==null))throw new IllegalArgumentException("Invalid fishing submission");}
|
||||
public Submission committed(){return new Submission(id,quest,catalog,site,submitter,item,quantity,rewards,SubmissionPhase.COMMITTED);}
|
||||
}
|
||||
public record Completion(UUID catalog,UUID site,UUID character) {public Completion {Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(character);}}
|
||||
public record Allocation(UUID id,UUID submission,UUID catalog,UUID site,UUID character,BigInteger xp,BigInteger coins) {
|
||||
public Allocation {Objects.requireNonNull(id);Objects.requireNonNull(submission);Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(character);Objects.requireNonNull(xp);Objects.requireNonNull(coins);if(xp.signum()<0||coins.signum()<0||xp.signum()==0&&coins.signum()==0)throw new IllegalArgumentException("Invalid fishing allocation");}
|
||||
public Reward reward(){return new Reward(xp,coins);}
|
||||
}
|
||||
public record Claim(UUID id,UUID character,Set<UUID> allocations,BigInteger xp,BigInteger coins) {
|
||||
public Claim {Objects.requireNonNull(id);Objects.requireNonNull(character);allocations=Set.copyOf(allocations);Objects.requireNonNull(xp);Objects.requireNonNull(coins);if(allocations.isEmpty()||xp.signum()<0||coins.signum()<0||xp.signum()==0&&coins.signum()==0)throw new IllegalArgumentException("Invalid fishing claim");}
|
||||
}
|
||||
public FishingState {
|
||||
inventory=List.copyOf(inventory);trophies=List.copyOf(trophies);trophyReceipts=Set.copyOf(trophyReceipts);catches=List.copyOf(catches);catchReceipts=Set.copyOf(catchReceipts);
|
||||
submissions=List.copyOf(submissions);completions=Set.copyOf(completions);allocations=List.copyOf(allocations);claims=List.copyOf(claims);
|
||||
unique(inventory.stream().map(value->value.character()+":"+value.item()).toList());
|
||||
unique(trophies.stream().map(Trophy::id).toList());unique(catches.stream().map(Catch::id).toList());unique(submissions.stream().map(Submission::id).toList());
|
||||
unique(allocations.stream().map(Allocation::id).toList());unique(claims.stream().map(Claim::id).toList());
|
||||
var redeemedTrophies=trophyReceipts;if(trophies.stream().anyMatch(value->redeemedTrophies.contains(value.id())))throw new IllegalArgumentException("Redeemed trophy remains in Inventory");
|
||||
var completedCatchReceipts=catchReceipts;if(catches.stream().anyMatch(value->completedCatchReceipts.contains(value.id())))throw new IllegalArgumentException("Pending catch already completed");
|
||||
var allocated=allocations.stream().collect(java.util.stream.Collectors.toMap(Allocation::id,value->value));
|
||||
var claiming=new HashSet<UUID>();
|
||||
for(var claim:claims){if(!claiming.add(claim.character()))throw new IllegalArgumentException("Duplicate fishing claim");BigInteger xp=BigInteger.ZERO,coins=BigInteger.ZERO;for(UUID id:claim.allocations()){var value=allocated.get(id);if(value==null||!value.character().equals(claim.character()))throw new IllegalArgumentException("Invalid fishing claim allocation");xp=xp.add(value.xp());coins=coins.add(value.coins());}if(!xp.equals(claim.xp())||!coins.equals(claim.coins()))throw new IllegalArgumentException("Invalid fishing claim total");}
|
||||
}
|
||||
public FishingState(List<ItemStack> inventory,List<Catch> catches,Set<UUID> catchReceipts,List<Submission> submissions,Set<Completion> completions,List<Allocation> allocations,List<Claim> claims){this(inventory,List.of(),Set.of(),catches,catchReceipts,submissions,completions,allocations,claims);}
|
||||
public static FishingState empty(){return new FishingState(List.of(),List.of(),Set.of(),List.of(),Set.of(),List.of(),Set.of(),List.of(),List.of());}
|
||||
private static String validName(String value){Objects.requireNonNull(value);value=value.strip();if(value.codePointCount(0,value.length())>64||value.codePoints().anyMatch(c->{int type=Character.getType(c);return type==Character.CONTROL||type==Character.FORMAT||type==Character.SURROGATE;}))throw new IllegalArgumentException("Invalid fishing item name");return value;}
|
||||
private static void unique(List<?> values){if(new HashSet<>(values).size()!=values.size())throw new IllegalArgumentException("Duplicate fishing identity");}
|
||||
}
|
||||
@@ -29,33 +29,51 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
private final games.dmg.fantasystick.ability.AbilityEngine abilityEngine;
|
||||
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 final Map<UUID, Object> adminPending = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, AdminView> adminViews = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, SavedView> savedViews = new ConcurrentHashMap<>();
|
||||
private volatile boolean closed;
|
||||
private static final int VOLUME_SIZE = 30; // Reserve room for linked ability details, navigation and party pages.
|
||||
private static final net.kyori.adventure.key.Key PAGES = net.kyori.adventure.key.Key.key("fantasystick", "character_pages");
|
||||
private static final net.kyori.adventure.key.Key ADMIN_PAGES = net.kyori.adventure.key.Key.key("fantasystick", "admin_character_pages");
|
||||
private static final net.kyori.adventure.key.Key SAVED_PAGES = net.kyori.adventure.key.Key.key("fantasystick", "saved_character_pages");
|
||||
private static final net.kyori.adventure.key.Key STATS = net.kyori.adventure.key.Key.key("fantasystick", "character_stat");
|
||||
private static final net.kyori.adventure.key.Key ABILITY_UNLOCK = net.kyori.adventure.key.Key.key("fantasystick", "ability_unlock");
|
||||
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 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 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 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,
|
||||
@@ -98,7 +116,12 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
this.partyControls = new PartyBookControls(parties, server, names, this::accept, this::report);
|
||||
}
|
||||
|
||||
@Override public void accept(ControlInput input) { load(input, 0); }
|
||||
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);}
|
||||
|
||||
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);}
|
||||
|
||||
@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);
|
||||
@@ -123,6 +146,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
UUID targetId = target.getUniqueId();
|
||||
Object ticket = new Object();
|
||||
adminViews.remove(administratorId);
|
||||
savedViews.remove(administratorId);
|
||||
adminPending.put(administratorId, ticket);
|
||||
characters.find(active.character()).whenComplete((record, failure) -> {
|
||||
if (closed || adminPending.get(administratorId) != ticket) return;
|
||||
@@ -164,7 +188,56 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
});
|
||||
}
|
||||
|
||||
private void load(ControlInput input, int offset) {
|
||||
public void openSavedReadOnly(org.bukkit.entity.Player administrator, UUID character) {
|
||||
Objects.requireNonNull(administrator); Objects.requireNonNull(character);
|
||||
if (closed) return;
|
||||
if (!administrator.isOnline() || !administrator.hasPermission("fantasystick.admin")) {
|
||||
if (administrator.isOnline()) administrator.sendMessage("Saved character view refused: administrator permission is required.");
|
||||
return;
|
||||
}
|
||||
loadSavedReadOnly(administrator, character, 0);
|
||||
}
|
||||
|
||||
private void loadSavedReadOnly(org.bukkit.entity.Player administrator, UUID character, int offset) {
|
||||
if (closed) return;
|
||||
UUID administratorId = administrator.getUniqueId();
|
||||
Object ticket = new Object();
|
||||
adminViews.remove(administratorId);
|
||||
savedViews.remove(administratorId);
|
||||
adminPending.put(administratorId, ticket);
|
||||
characters.inspect(character).whenComplete((record, failure) -> {
|
||||
if (closed || adminPending.get(administratorId) != ticket) return;
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed || !adminPending.remove(administratorId, ticket)) return;
|
||||
if (!administrator.isOnline() || !administrator.hasPermission("fantasystick.admin")) {
|
||||
if (administrator.isOnline()) administrator.sendMessage("Saved character view refused: administrator permission changed.");
|
||||
return;
|
||||
}
|
||||
if (failure != null) {
|
||||
report(new Failure(administratorId, character, failure));
|
||||
administrator.sendMessage("Saved character view could not be loaded; no state was changed.");
|
||||
return;
|
||||
}
|
||||
if (record.isEmpty()) {
|
||||
administrator.sendMessage("Saved character view refused: character not found.");
|
||||
return;
|
||||
}
|
||||
try { administrator.openBook(renderSavedReadOnly(record.orElseThrow(), administrator, offset)); }
|
||||
catch (RuntimeException error) {
|
||||
savedViews.remove(administratorId);
|
||||
report(new Failure(administratorId, character, error));
|
||||
administrator.sendMessage("Saved character view could not be opened; no state was changed.");
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException rejected) {
|
||||
adminPending.remove(administratorId, ticket);
|
||||
report(new Failure(administratorId, character, rejected));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void load(ControlInput input, int offset, boolean userRequested) {
|
||||
if (closed || input.kind() != ControlInput.Kind.BOOK || input.gesture() != ControlInput.Gesture.USE
|
||||
|| !input.stillCurrent()) return;
|
||||
var owner = input.session().player();
|
||||
@@ -173,46 +246,66 @@ 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 audit=userRequested&&bookEvents!=null&¤t!=null?bookEvents.request(input,current,offset):null;
|
||||
var superseded=pendingBookEvents.remove(owner);if(superseded!=null)bookEvents.refused(superseded,"superseded");
|
||||
if(audit!=null)pendingBookEvents.put(owner,audit);
|
||||
Object ticket = new Object();
|
||||
pending.put(owner, ticket);
|
||||
characters.find(input.session().character()).whenComplete((record, failure) -> {
|
||||
if (closed || pending.get(owner) != ticket) return;
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed || !pending.remove(owner, ticket) || !input.stillCurrent()) return;
|
||||
if (closed || !pending.remove(owner, ticket)) return;
|
||||
if (!input.stillCurrent()) { finishRefused(owner,audit,"stale_session"); return; }
|
||||
if (failure != null) {
|
||||
finishFailed(owner,audit,"load_failed");
|
||||
report(input, failure);
|
||||
input.player().sendMessage("Character book could not be loaded; no character state was changed.");
|
||||
return;
|
||||
}
|
||||
if (record.isEmpty() || record.orElseThrow().dead()) {
|
||||
finishRefused(owner,audit,"character_unavailable");
|
||||
input.player().sendMessage("This character is no longer available.");
|
||||
return;
|
||||
}
|
||||
try { input.player().openBook(render(record.orElseThrow(), input, offset)); }
|
||||
catch (RuntimeException error) { partyControls.forget(owner); views.remove(owner); report(input, error); }
|
||||
try {
|
||||
var book=render(record.orElseThrow(), input, offset);input.player().openBook(book);
|
||||
if(audit!=null&&pendingBookEvents.remove(owner,audit)){var pages=((BookMeta)book.getItemMeta()).pages().size();bookEvents.opened(audit,record.orElseThrow(),pages,1);}
|
||||
}
|
||||
catch (RuntimeException error) { finishFailed(owner,audit,"native_open_failed");partyControls.forget(owner); views.remove(owner); report(input, error); }
|
||||
});
|
||||
} catch (RuntimeException rejected) {
|
||||
pending.remove(owner, ticket);
|
||||
pending.remove(owner, ticket);finishFailed(owner,audit,"scheduler_rejected");
|
||||
report(input, rejected);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void finishRefused(UUID owner,games.dmg.fantasystick.events.BookEventBridge.Request request,String reason){if(request!=null&&pendingBookEvents.remove(owner,request))bookEvents.refused(request,reason);}
|
||||
private void finishFailed(UUID owner,games.dmg.fantasystick.events.BookEventBridge.Request request,String reason){if(request!=null&&pendingBookEvents.remove(owner,request))bookEvents.failed(request,reason);}
|
||||
|
||||
private ItemStack render(CharacterRecord record, ControlInput input, int offset) {
|
||||
return renderBook(record, input.player(), input, offset, null, null);
|
||||
return renderBook(record, input.player(), input, offset, null, null, false);
|
||||
}
|
||||
|
||||
private ItemStack renderReadOnly(CharacterRecord record, org.bukkit.entity.Player administrator,
|
||||
org.bukkit.entity.Player target,
|
||||
games.dmg.fantasystick.session.EquipmentSessions.Active active,
|
||||
int offset) {
|
||||
return renderBook(record, target, null, offset, administrator, active);
|
||||
return renderBook(record, target, null, offset, administrator, active, false);
|
||||
}
|
||||
|
||||
private ItemStack renderSavedReadOnly(CharacterRecord record, org.bukkit.entity.Player administrator, int offset) {
|
||||
return renderBook(record, administrator, null, offset, administrator, null, true);
|
||||
}
|
||||
|
||||
private ItemStack renderBook(CharacterRecord record, org.bukkit.entity.Player subject,
|
||||
ControlInput input, int offset, org.bukkit.entity.Player administrator,
|
||||
games.dmg.fantasystick.session.EquipmentSessions.Active adminActive) {
|
||||
games.dmg.fantasystick.session.EquipmentSessions.Active adminActive,
|
||||
boolean savedState) {
|
||||
boolean interactive = input != null;
|
||||
var item = new ItemStack(Material.WRITTEN_BOOK);
|
||||
var meta = (BookMeta) item.getItemMeta();
|
||||
@@ -220,10 +313,12 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
meta.setAuthor("Fantasy Stick");
|
||||
|
||||
var statView = interactive ? new StatView(UUID.randomUUID().toString(), input, record.revision()) : null;
|
||||
var allOverview = overviewPages(record, subject, statView);
|
||||
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());
|
||||
var pages = new java.util.ArrayList<Component>(allOverview.subList(start, end));
|
||||
var pages = new java.util.ArrayList<Component>();
|
||||
if (savedState && start == 0) pages.addAll(savedSummaryPages(record));
|
||||
pages.addAll(allOverview.subList(start, end));
|
||||
if(end==allOverview.size())pages.add(gearPage(record));
|
||||
if (interactive && record.unspentPoints().signum() > 0 && start == 0)
|
||||
statViews.put(input.session().player(), statView);
|
||||
@@ -304,6 +399,14 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
.append(pageButton("Previous pages", view, 0));
|
||||
if (view.next() >= 0) navigation = navigation.append(Component.newline())
|
||||
.append(pageButton("Next pages", view, 1));
|
||||
} else if (savedState) {
|
||||
var view = new SavedView(UUID.randomUUID().toString(), administrator.getUniqueId(), record.id(),
|
||||
start > 0 ? start - VOLUME_SIZE : -1, end < allOverview.size() ? end : -1);
|
||||
savedViews.put(administrator.getUniqueId(), view);
|
||||
if (view.previous() >= 0) navigation = navigation.append(Component.newline())
|
||||
.append(savedPageButton("Previous pages", view, 0));
|
||||
if (view.next() >= 0) navigation = navigation.append(Component.newline())
|
||||
.append(savedPageButton("Next pages", view, 1));
|
||||
} else {
|
||||
var view = new AdminView(UUID.randomUUID().toString(), administrator.getUniqueId(),
|
||||
subject, adminActive, start > 0 ? start - VOLUME_SIZE : -1,
|
||||
@@ -316,18 +419,38 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
}
|
||||
pages.add(navigation);
|
||||
}
|
||||
if(end==allOverview.size())pages.addAll(inventoryPages(record.id(),input));
|
||||
if (end == allOverview.size()) pages.addAll(interactive
|
||||
? partyControls.pages(input) : partyControls.readOnlyPages(subject.getUniqueId()));
|
||||
? partyControls.pages(input) : savedState
|
||||
? partyControls.readOnlyCharacterPages(record.id())
|
||||
: partyControls.readOnlyPages(subject.getUniqueId()));
|
||||
meta.pages(pages);
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
private java.util.List<Component> inventoryPages(UUID character,ControlInput input){var directory=fishing;var rare=rareInventory;if(directory==null&&rare==null)return java.util.List.of();var values=directory==null?java.util.List.<games.dmg.fantasystick.adventure.FishingState.ItemStack>of():directory.inventory(character);var trophies=directory==null?java.util.List.<games.dmg.fantasystick.adventure.FishingState.Trophy>of():directory.trophies(character);var goods=rare==null?java.util.List.<games.dmg.fantasystick.shop.RareInventoryDirectory.Item>of():rare.inventory(character);RareView view=input==null?null:new RareView(UUID.randomUUID().toString(),input,goods.stream().filter(games.dmg.fantasystick.shop.RareInventoryDirectory.Item::available).collect(java.util.stream.Collectors.toUnmodifiableMap(games.dmg.fantasystick.shop.RareInventoryDirectory.Item::id,games.dmg.fantasystick.shop.RareInventoryDirectory.Item::quantity)));if(view!=null&&!view.quantities().isEmpty())rareViews.put(input.session().player(),view);var builder=new PageBuilder();builder.text("Inventory");builder.blank();if(values.isEmpty()&&trophies.isEmpty()&&goods.isEmpty())builder.text("No items.");else{int shown=0;for(var value:values){if(shown++>=256)break;builder.text(value.name()+": "+value.amount());builder.text("Fishing origin: "+value.site());}for(var trophy:trophies){if(shown++>=256)break;builder.text(trophy.name()+": 1");builder.text("Trophy origin: "+trophy.origin());}for(var good:goods){if(shown++>=256)break;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);}
|
||||
|
||||
private java.util.List<Component> savedSummaryPages(CharacterRecord record) {
|
||||
var builder = new PageBuilder();
|
||||
boolean active = sessions.activeCharacter(record.id()).filter(sessions::current).isPresent();
|
||||
builder.text("Saved state");
|
||||
builder.blank();
|
||||
builder.text("Character UUID:");
|
||||
builder.text(record.id().toString());
|
||||
builder.text(active ? "Active" : "Inactive");
|
||||
builder.text(record.dead() ? "Dead" : "Alive");
|
||||
if (record.deathId() != null) builder.text("Death: " + record.deathId());
|
||||
if (record.diedAt() != null) builder.text("Died: " + record.diedAt());
|
||||
return builder.pages();
|
||||
}
|
||||
|
||||
private java.util.List<Component> overviewPages(CharacterRecord record, org.bukkit.entity.Player subject,
|
||||
StatView view) {
|
||||
StatView view, boolean savedState) {
|
||||
var builder = new PageBuilder();
|
||||
builder.text(record.name());
|
||||
builder.blank();
|
||||
@@ -343,8 +466,16 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
builder.text("XP: " + xp);
|
||||
builder.text("Unspent points: " + record.unspentPoints());
|
||||
builder.text("Coins: " + record.coins());
|
||||
builder.text("Health: " + java.math.BigDecimal.valueOf(subject.getHealth()).min(maximum).stripTrailingZeros().toPlainString()
|
||||
var health = savedState ? record.health() : java.math.BigDecimal.valueOf(subject.getHealth()).min(maximum);
|
||||
builder.text((savedState ? "Saved health: " : "Health: ") + health.stripTrailingZeros().toPlainString()
|
||||
+ "/" + maximum.stripTrailingZeros().toPlainString());
|
||||
if (savedState) {
|
||||
builder.blank();
|
||||
builder.text("Saved cooldowns");
|
||||
if (record.cooldowns().isEmpty()) builder.text("None");
|
||||
else record.cooldowns().entrySet().stream().sorted(java.util.Map.Entry.comparingByKey())
|
||||
.forEach(entry -> builder.text(entry.getKey() + ": " + entry.getValue()));
|
||||
}
|
||||
return builder.pages();
|
||||
}
|
||||
|
||||
@@ -585,6 +716,28 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + view.nonce() + ":" + direction + "\"")));
|
||||
}
|
||||
|
||||
private static Component savedPageButton(String label, SavedView view, int direction) {
|
||||
return Component.text("[" + label + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(SAVED_PAGES,
|
||||
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + view.nonce() + ":" + direction + "\"")));
|
||||
}
|
||||
|
||||
@org.bukkit.event.EventHandler
|
||||
public void savedPageAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
|
||||
if (closed || !SAVED_PAGES.equals(event.getIdentifier())
|
||||
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return;
|
||||
var administrator = connection.getPlayer();
|
||||
var view = savedViews.get(administrator.getUniqueId());
|
||||
if (view == null || event.getTag() == null || !view.administrator().equals(administrator.getUniqueId())) return;
|
||||
String raw = event.getTag().string();
|
||||
if (raw.length() > 64) return;
|
||||
var match = PAGE_PAYLOAD.matcher(raw);
|
||||
if (!match.matches() || !view.nonce().equals(match.group(1))) return;
|
||||
int offset = match.group(2).equals("0") ? view.previous() : view.next();
|
||||
if (offset < 0 || !savedViews.remove(administrator.getUniqueId(), view)) return;
|
||||
loadSavedReadOnly(administrator, view.character(), offset);
|
||||
}
|
||||
|
||||
@org.bukkit.event.EventHandler
|
||||
public void adminPageAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
|
||||
if (closed || !ADMIN_PAGES.equals(event.getIdentifier())
|
||||
@@ -607,15 +760,20 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return;
|
||||
var player = connection.getPlayer();
|
||||
var view = views.get(player.getUniqueId());
|
||||
if (view == null || view.input().player() != player || event.getTag() == null) return;
|
||||
if (view == null) { refusePage(player,"missing_or_used_view"); return; }
|
||||
if (view.input().player() != player || event.getTag() == null) { refusePage(player,"foreign_or_missing_payload"); return; }
|
||||
String raw = event.getTag().string();
|
||||
if (raw.length() > 64) return;
|
||||
if (raw.length() > 64) { refusePage(player,"invalid_payload"); return; }
|
||||
var match = PAGE_PAYLOAD.matcher(raw);
|
||||
if (!match.matches() || !view.nonce().equals(match.group(1))) return;
|
||||
if (!match.matches() || !view.nonce().equals(match.group(1))) { refusePage(player,"stale_or_forged_payload"); return; }
|
||||
int offset = match.group(2).equals("0") ? view.previous() : view.next();
|
||||
if (offset < 0 || !views.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
|
||||
if (offset < 0 || !views.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) { refusePage(player,"stale_or_unavailable_page"); return; }
|
||||
// Reread authoritative data, rather than retaining a stale giant character snapshot.
|
||||
load(view.input(), offset);
|
||||
load(view.input(), offset, true);
|
||||
}
|
||||
|
||||
private void refusePage(org.bukkit.entity.Player player,String reason){
|
||||
if(bookEvents==null)return;var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return;var current=sessions.currentRecord(active).orElse(null);if(current!=null)bookEvents.refused(player,active,current,"character_book_navigate",reason);
|
||||
}
|
||||
|
||||
private void report(ControlInput input, Throwable failure) {
|
||||
@@ -671,7 +829,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
return;
|
||||
}
|
||||
input.player().sendMessage(stat + " increased to " + saved.stats().get(stat) + ".");
|
||||
load(input, 0);
|
||||
load(input, 0, false);
|
||||
return;
|
||||
}
|
||||
String message = switch (purchase.status()) {
|
||||
@@ -681,7 +839,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
|
||||
@@ -710,7 +868,7 @@ 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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -738,7 +896,7 @@ 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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -775,6 +933,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); }
|
||||
|
||||
@@ -782,16 +948,20 @@ 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);
|
||||
views.remove(owner);
|
||||
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(); 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();
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,11 @@ public final class PartyBookControls implements AutoCloseable {
|
||||
return rootPages(Objects.requireNonNull(account));
|
||||
}
|
||||
|
||||
public List<Component> readOnlyCharacterPages(UUID character) {
|
||||
if (closed) return List.of();
|
||||
return rootPages(parties.partyOfCharacter(Objects.requireNonNull(character)));
|
||||
}
|
||||
|
||||
private List<Component> render(ControlInput input, Menu menu, int offset) {
|
||||
if (closed || !input.stillCurrent()) return List.of();
|
||||
var build = new Pages(input);
|
||||
@@ -104,8 +109,11 @@ public final class PartyBookControls implements AutoCloseable {
|
||||
}
|
||||
|
||||
private List<Component> rootPages(UUID owner) {
|
||||
return rootPages(parties.partyOf(owner));
|
||||
}
|
||||
|
||||
private List<Component> rootPages(java.util.Optional<PartyRoster.Party> party) {
|
||||
var pages = new ArrayList<Component>();
|
||||
var party = parties.partyOf(owner);
|
||||
Component summary = Component.text("Party\n\n");
|
||||
if (party.isEmpty()) {
|
||||
pages.add(summary.append(Component.text("No party.\n\nVisit a Party\nRegistrar.\nCreate, join, or\nmanage one.")));
|
||||
|
||||
@@ -36,15 +36,15 @@ public final class CharacterAnvilNames implements Listener {
|
||||
public void onTaken(AnvilTakeResultEvent event) {
|
||||
var sheets = new CharacterSheets();
|
||||
var result = event.getResult();
|
||||
var id = sheets.identify(result);
|
||||
if (id.isEmpty()) return;
|
||||
var identity = sheets.identity(result);
|
||||
if (identity.isEmpty()) return;
|
||||
var input = event.getInventory().getItem(0);
|
||||
if (!id.equals(sheets.identify(input))) return;
|
||||
if (!identity.equals(sheets.identity(input))) return;
|
||||
String name = name(result);
|
||||
if (name.equals(name(input))) return;
|
||||
var player = event.getPlayer().getUniqueId();
|
||||
var character = id.orElseThrow();
|
||||
storage.renameCharacter(character, name).whenComplete((renamed, failure) -> {
|
||||
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()); }
|
||||
catch (RuntimeException | LinkageError error) { failures.accept(new Failure(player, character, error)); }
|
||||
|
||||
@@ -20,30 +20,31 @@ public final class DungeonCommands {
|
||||
private final Plugin plugin;private final DungeonDirectory directory;private final DungeonController controller;private final games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures;private final Consumer<Runnable> nextTick;private final games.dmg.fantasystick.adventure.AdventureLocationPresentation locations;private final Function<UUID,CompletableFuture<Optional<CharacterRecord>>> characters;private final Predicate<UUID> activeCharacter;private final Predicate<UUID> partyCharacter;private final CharacterSheets sheets=new CharacterSheets();private final AdventureAdminTeleport teleports;
|
||||
public DungeonCommands(Plugin plugin,DungeonDirectory directory,DungeonController controller,games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures,Consumer<Runnable> nextTick){this(plugin,directory,controller,adventures,nextTick,id->CompletableFuture.completedFuture(Optional.empty()),id->false,id->false);}
|
||||
public DungeonCommands(Plugin plugin,DungeonDirectory directory,DungeonController controller,games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures,Consumer<Runnable> nextTick,Function<UUID,CompletableFuture<Optional<CharacterRecord>>> characters,Predicate<UUID> activeCharacter,Predicate<UUID> partyCharacter){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.controller=Objects.requireNonNull(controller);this.adventures=Objects.requireNonNull(adventures);this.nextTick=Objects.requireNonNull(nextTick);this.characters=Objects.requireNonNull(characters);this.activeCharacter=Objects.requireNonNull(activeCharacter);this.partyCharacter=Objects.requireNonNull(partyCharacter);locations=new games.dmg.fantasystick.adventure.AdventureLocationPresentation(plugin.getServer());teleports=new AdventureAdminTeleport(plugin,nextTick);}
|
||||
public void execute(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usage(sender);return;}try{switch(args[1]){case"create"->create(sender,args);case"list"->list(sender,args);case"remove"->remove(sender,args);case"edit"->edit(sender,args);case"entrance"->entrance(sender,args);case"room"->room(sender,args);case"boss"->bossCommand(sender,args);case"teleport"->teleport(sender,args);case"enable"->enable(sender,args);case"disable"->disable(sender,args);case"open"->open(sender,args);case"close"->close(sender,args);default->usage(sender);}}catch(IllegalArgumentException invalid){sender.sendMessage("Dungeon command refused: "+invalid.getMessage()+".");}}
|
||||
private void create(CommandSender sender,String[] args){if(args.length<3||!(sender instanceof Player player)){usage(sender);return;}var location=player.getLocation();String name=join(args,2);if(name.isBlank()){usage(sender);return;}directory.create(name,Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Created disabled dungeon "+value.id()+" ("+value.name()+").":"Dungeon creation failed; desired state is unchanged.")));}
|
||||
private void list(CommandSender sender,String[] args){if(args.length!=2){usage(sender);return;}if(directory.snapshot().dungeons().isEmpty()){sender.sendMessage("No dungeons registered.");return;}directory.snapshot().dungeons().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(value->sender.sendMessage(value.id()+" "+value.name()+" at "+locations.format(value.world(),value.entrance().x(),value.entrance().y(),value.entrance().z())+"; "+(value.enabled()?"enabled":"disabled")+"; rooms="+value.rooms().size()+"; maximum="+value.maximumXp()+" XP, "+value.maximumCoins()+" coins."));}
|
||||
public void execute(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usage(sender);return;}try{switch(args[1]){case"create"->create(sender,args);case"list"->list(sender,args);case"mode"->mode(sender,args);case"remove"->remove(sender,args);case"edit"->edit(sender,args);case"entrance"->entrance(sender,args);case"room"->room(sender,args);case"reward"->reward(sender,args);case"boss"->bossCommand(sender,args);case"teleport"->teleport(sender,args);case"enable"->enable(sender,args);case"disable"->disable(sender,args);case"open"->open(sender,args);case"close"->close(sender,args);default->usage(sender);}}catch(IllegalArgumentException invalid){sender.sendMessage("Dungeon command refused: "+invalid.getMessage()+".");}}
|
||||
private void create(CommandSender sender,String[] args){if(args.length<3||!(sender instanceof Player player)){usage(sender);return;}int nameStart=2;var mode=games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;if(args[2].equals("--mode")){if(args.length<5){usage(sender);return;}try{mode=games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){usage(sender);return;}nameStart=4;}else if(args[2].startsWith("--")){usage(sender);return;}String name=join(args,nameStart);if(name.isBlank()){usage(sender);return;}var location=player.getLocation();directory.create(name,Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch(),mode).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Created disabled dungeon "+value.id()+" ("+value.name()+"); mode="+value.participationMode().value()+".":"Dungeon creation failed; desired state is unchanged.")));}
|
||||
private void list(CommandSender sender,String[] args){if(args.length!=2){usage(sender);return;}if(directory.snapshot().dungeons().isEmpty()){sender.sendMessage("No dungeons registered.");return;}directory.snapshot().dungeons().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(value->sender.sendMessage(value.id()+" "+value.name()+" at "+locations.format(value.world(),value.entrance().x(),value.entrance().y(),value.entrance().z())+"; "+(value.enabled()?"enabled":"disabled")+"; mode="+value.participationMode().value()+"; rooms="+value.rooms().size()+"; completion="+(value.rewardConfigured()?value.completionReward().xp()+" XP, "+value.completionReward().coins()+" coins":"not configured")+"."));}
|
||||
private void mode(CommandSender sender,String[] args){if(args.length<3||args.length>4){usage(sender);return;}UUID id=id(args[2]);var current=id==null?null:directory.find(id).orElse(null);if(current==null){sender.sendMessage("Dungeon not found.");return;}if(args.length==3){sender.sendMessage("Dungeon "+id+" mode="+current.participationMode().value()+".");return;}games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode requested;try{requested=games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){usage(sender);return;}directory.setMode(id,current.revision(),requested).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Updated dungeon "+id+" mode="+value.participationMode().value()+" for future acceptances.":"Dungeon mode update refused; the acknowledged definition is unchanged or stale.")));}
|
||||
private void remove(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}if(directory.custodyForDungeon(id).isPresent()){sender.sendMessage("Recover character boss custody before removing this dungeon.");return;}controller.closeOffer(id).thenCompose(ignored->adventures.removeDungeonSite(id)).thenCompose(ignored->directory.remove(id)).whenComplete((removed,failure)->respond(sender,()->sender.sendMessage(failure!=null?"Dungeon removal failed; desired state is retained.":removed.isPresent()?"Removed dungeon "+id+".":"Dungeon not found.")));}
|
||||
private void edit(CommandSender sender,String[] args){if(!(sender instanceof Player player)){usage(sender);return;}if(args.length==3&&args[2].equals("done")){sender.sendMessage(controller.done(player.getUniqueId())?"Dungeon editing ended; the dungeon remains disabled.":"No dungeon is being edited.");return;}if(args.length<4||args.length>5||!args[2].equals("start")||args.length==5&&!args[4].equals("confirm")){usage(sender);return;}UUID id=id(args[3]);if(id==null){usage(sender);return;}controller.start(player,id,args.length==5).whenComplete((status,failure)->respond(sender,()->{if(failure!=null){plugin.getLogger().log(java.util.logging.Level.WARNING,"Dungeon edit transition failed",failure);sender.sendMessage("Dungeon edit transition failed; retained state is authoritative.");return;}sender.sendMessage(switch(status){case STARTED->"Editing dungeon "+id+"; it is disabled.";case CONFIRM_REQUIRED->"Dungeon has offered, accepted or progressed work; repeat with exact confirm to close and expire it.";case BUSY->"Dungeon editing refused: the editor or dungeon already has an editing lease.";case NOT_FOUND->"Dungeon not found.";});}));}
|
||||
private void entrance(CommandSender sender,String[] args){if(args.length!=3||!args[2].equals("set")||!(sender instanceof Player player)){usage(sender);return;}UUID dungeon=editing(player);if(dungeon==null)return;var location=player.getLocation();if(location.getWorld()==null||!directory.find(dungeon).orElseThrow().world().equals(location.getWorld().getUID())){sender.sendMessage("Dungeon entrance must remain in its world.");return;}directory.setEntrance(dungeon,location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->saved(sender,failure,"Updated dungeon entrance."));}
|
||||
private void room(CommandSender sender,String[] args){if(!(sender instanceof Player player)||args.length<3){usage(sender);return;}UUID dungeon=editing(player);if(dungeon==null)return;switch(args[2]){case"create"->roomCreate(sender,player,dungeon,args);case"list"->{if(args.length!=3){usage(sender);return;}var value=directory.find(dungeon).orElseThrow();if(value.rooms().isEmpty())sender.sendMessage("No rooms defined.");else value.rooms().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.displayName()+" "+v.bounds()+"; mobs="+v.mobs().size()+"; effects="+v.effects().size()+"; reward="+v.reward().xp()+" XP, "+v.reward().coins()+" coins; boss="+(v.boss()==null?"none":v.boss().type().name().toLowerCase(Locale.ROOT))));}case"remove"->{if(args.length!=4){usage(sender);return;}UUID room=id(args[3]);if(room==null){usage(sender);return;}directory.removeRoom(dungeon,room).whenComplete((value,failure)->{if(failure==null&&value.isPresent())controller.definitionChanged(dungeon);respond(sender,()->sender.sendMessage(failure!=null?"Room removal refused: it would overlap, disconnect or could not be saved.":value.isPresent()?"Removed room "+room+".":"Room not found."));});}case"mob"->mob(sender,player,dungeon,args);case"effect"->effect(sender,player,dungeon,args);case"reward"->reward(sender,player,dungeon,args);case"boss"->boss(sender,player,dungeon,args);default->usage(sender);}}
|
||||
private void room(CommandSender sender,String[] args){if(!(sender instanceof Player player)||args.length<3){usage(sender);return;}UUID dungeon=editing(player);if(dungeon==null)return;switch(args[2]){case"create"->roomCreate(sender,player,dungeon,args);case"list"->{if(args.length!=3){usage(sender);return;}var value=directory.find(dungeon).orElseThrow();if(value.rooms().isEmpty())sender.sendMessage("No rooms defined.");else value.rooms().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.displayName()+" "+v.bounds()+"; mobs="+v.mobs().size()+"; effects="+v.effects().size()+"; boss="+(v.boss()==null?"none":v.boss().type().name().toLowerCase(Locale.ROOT))));}case"remove"->{if(args.length!=4){usage(sender);return;}UUID room=id(args[3]);if(room==null){usage(sender);return;}directory.removeRoom(dungeon,room).whenComplete((value,failure)->{if(failure==null&&value.isPresent())controller.definitionChanged(dungeon);respond(sender,()->sender.sendMessage(failure!=null?"Room removal refused: it would overlap, disconnect or could not be saved.":value.isPresent()?"Removed room "+room+".":"Room not found."));});}case"mob"->mob(sender,player,dungeon,args);case"effect"->effect(sender,player,dungeon,args);case"boss"->boss(sender,player,dungeon,args);default->usage(sender);}}
|
||||
private void roomCreate(CommandSender sender,Player player,UUID dungeon,String[] args){if(args.length<6){usage(sender);return;}var location=player.getLocation();int x=location.getBlockX(),y=location.getBlockY()-1,z=location.getBlockZ();if(args[3].equals("corner")){if(args.length<7){usage(sender);return;}Integer xs=integer(args[4]),zs=integer(args[5]),height=integer(args[6]);if(xs==null||zs==null||height==null){usage(sender);return;}directory.addCornerRoom(dungeon,join(args,7),x,y,z,xs,zs,height).whenComplete((value,failure)->roomSaved(sender,dungeon,value,failure));return;}if(args[3].equals("radius")){Integer radius=integer(args[4]),height=integer(args[5]);if(radius==null||height==null){usage(sender);return;}directory.addRadiusRoom(dungeon,join(args,6),x,y,z,radius,height).whenComplete((value,failure)->roomSaved(sender,dungeon,value,failure));return;}usage(sender);}
|
||||
private void roomSaved(CommandSender sender,UUID dungeon,DungeonState.Room value,Throwable failure){if(failure==null)controller.definitionChanged(dungeon);String message=failure==null?"Created room "+value.id()+" ("+value.displayName()+").":roomFailure(failure);respond(sender,()->sender.sendMessage(message));}
|
||||
private static String roomFailure(Throwable failure){Throwable cause=failure;while((cause instanceof java.util.concurrent.CompletionException||cause instanceof java.util.concurrent.ExecutionException)&&cause.getCause()!=null)cause=cause.getCause();String detail=cause.getMessage();if(cause instanceof IllegalArgumentException&&"Rooms overlap".equals(detail))return "Room creation refused: it overlaps an existing room.";if(cause instanceof IllegalArgumentException&&detail!=null&&detail.startsWith("Room exceeds configured link distance of ")){String blocks=detail.substring("Room exceeds configured link distance of ".length()).replace(" blocks","");return "Room creation refused: nearest room is farther than the configured "+blocks+"-block link distance.";}if(cause instanceof IllegalArgumentException&&(detail!=null&&(detail.equals("Room dimensions out of range")||detail.equals("Room limit reached"))))return "Room creation refused: dimensions or room limit are out of range.";if(cause instanceof java.io.IOException)return "Room creation failed: persistence is unavailable; desired state is unchanged.";return "Room creation refused: geometry validation failed; desired state is unchanged.";}
|
||||
private void mob(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length>=5&&args[3].equals("add")){var type=entity(args[4]);if(!ordinary(type)){sender.sendMessage("Unsupported living mob type.");return;}var location=player.getLocation();directory.addMob(dungeon,room.id(),type,join(args,5),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->saved(sender,failure,"Added mob slot "+(value==null?"":value.id())+"."));return;}if(args.length==5&&args[3].equals("remove")){UUID id=id(args[4]);if(id==null){usage(sender);return;}directory.removeMob(dungeon,room.id(),id).whenComplete((value,failure)->saved(sender,failure,value!=null&&value.isPresent()?"Removed mob slot "+id+".":"Mob slot not found."));return;}if(args.length==4&&args[3].equals("list")){if(room.mobs().isEmpty())sender.sendMessage("No ordinary mob slots in this room.");else room.mobs().forEach(value->sender.sendMessage(value.id()+" "+value.type().name().toLowerCase(Locale.ROOT)+" "+value.name()));return;}usage(sender);}
|
||||
private void effect(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length>=7&&args[3].equals("add")){DungeonState.EffectTarget target;try{target=DungeonState.EffectTarget.valueOf(args[4].toUpperCase(Locale.ROOT));}catch(IllegalArgumentException invalid){usage(sender);return;}String key=args[5].contains(":")?args[5]:"minecraft:"+args[5];var namespaced=NamespacedKey.fromString(key);Integer level=integer(args[6]);boolean particles=args.length<8||Boolean.parseBoolean(args[7]);if(namespaced==null||PotionEffectType.getByKey(namespaced)==null||level==null||args.length>8||args.length==8&&!Set.of("true","false").contains(args[7])){usage(sender);return;}directory.addEffect(dungeon,room.id(),target,key,level,particles).whenComplete((value,failure)->saved(sender,failure,"Added room effect "+(value==null?"":value.id())+"."));return;}if(args.length==5&&args[3].equals("remove")){UUID id=id(args[4]);if(id==null){usage(sender);return;}directory.removeEffect(dungeon,room.id(),id).whenComplete((value,failure)->saved(sender,failure,value!=null&&value.isPresent()?"Removed room effect "+id+".":"Room effect not found."));return;}usage(sender);}
|
||||
private void reward(CommandSender sender,Player player,UUID dungeon,String[] args){if(args.length!=6||!args[3].equals("set")){usage(sender);return;}var room=standing(sender,player);if(room==null)return;BigInteger xp=whole(args[4]),coins=whole(args[5]);if(xp==null||coins==null){usage(sender);return;}directory.setReward(dungeon,room.id(),xp,coins).whenComplete((value,failure)->saved(sender,failure,"Set room reward to "+xp+" XP and "+coins+" coins."));}
|
||||
private void reward(CommandSender sender,String[] args){if(args.length<3||args.length>5){usage(sender);return;}UUID dungeon=id(args[2]);var current=dungeon==null?null:directory.find(dungeon).orElse(null);if(current==null){sender.sendMessage("Dungeon not found.");return;}if(args.length==3){sender.sendMessage(current.rewardConfigured()?"Dungeon "+dungeon+" completion reward is "+current.completionReward().xp()+" XP and "+current.completionReward().coins()+" coins.":"Dungeon "+dungeon+" completion reward is not configured.");return;}if(current.enabled()||adventures.offerOpen(dungeon)||adventures.active().stream().anyMatch(quest->quest.site().equals(dungeon))){sender.sendMessage("Dungeon reward update requires disabled, closed dungeon state without accepted quests.");return;}if(controller.beingEdited(dungeon)&&(!(sender instanceof Player player)||!controller.editing(player.getUniqueId()).filter(dungeon::equals).isPresent())){sender.sendMessage("Dungeon reward update refused: another administrator owns the editing lease.");return;}BigInteger xp=args.length==5?whole(args[3]):null,coins=args.length==5?whole(args[4]):null;if(xp==null||coins==null){usage(sender);return;}directory.setCompletionReward(dungeon,xp,coins).whenComplete((value,failure)->saved(sender,failure,"Set dungeon completion reward to "+xp+" XP and "+coins+" coins."));}
|
||||
private void boss(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length==5&&args[3].equals("assign-sheet")){assignCharacterBoss(sender,player,dungeon,room,args[4]);return;}if(args.length>=5&&args[3].equals("mob")){var type=entity(args[4]);if(!ordinary(type)){sender.sendMessage("Unsupported living boss type.");return;}var location=player.getLocation();directory.setBoss(dungeon,room.id(),type,join(args,5),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->saved(sender,failure,"Configured native boss "+type.name().toLowerCase(Locale.ROOT)+"."));return;}if(args.length==4&&args[3].equals("clear")){directory.clearBoss(dungeon,room.id()).whenComplete((value,failure)->saved(sender,failure,"Cleared the room boss."));return;}usage(sender);}
|
||||
private void assignCharacterBoss(CommandSender sender,Player player,UUID dungeon,DungeonState.Room room,String rawMultiplier){BigDecimal multiplier;try{if(!rawMultiplier.matches("(?:0|[1-9][0-9]*)(?:\\.[0-9]{1,3})?"))throw new NumberFormatException();multiplier=new BigDecimal(rawMultiplier);}catch(NumberFormatException invalid){usage(sender);return;}var held=player.getInventory().getItemInMainHand();var character=sheets.identify(held).orElse(null);if(character==null||held.getAmount()!=1){sender.sendMessage("Hold exactly one authentic living character sheet in your main hand.");return;}if(physicalCopies(character)!=1){sender.sendMessage("Character boss assignment requires one unambiguous physical sheet.");return;}if(activeCharacter.test(character)){sender.sendMessage("That character has an active equipment session.");return;}if(partyCharacter.test(character)){sender.sendMessage("That character must leave its party before boss assignment.");return;}characters.apply(character).whenComplete((loaded,failure)->{if(failure!=null||loaded==null||loaded.isEmpty()||loaded.orElseThrow().dead()||loaded.orElseThrow().health().signum()<=0){respond(sender,()->sender.sendMessage("Character boss assignment refused: the saved living character could not be verified."));return;}var expected=loaded.orElseThrow();directory.assignCharacterBoss(dungeon,room.id(),expected,multiplier).whenComplete((custody,saveFailure)->{if(saveFailure!=null){respond(sender,()->sender.sendMessage("Character boss assignment failed before sheet custody changed."));return;}characters.apply(character).whenComplete((fresh,readFailure)->respond(sender,()->{if(readFailure!=null||fresh==null||fresh.isEmpty()||!fresh.orElseThrow().equals(expected)||activeCharacter.test(character)||partyCharacter.test(character)){directory.cancelAssignment(dungeon,custody.operation()).whenComplete((ignored,rollbackFailure)->respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the saved character changed.":"Character boss custody was saved but assignment rollback could not be confirmed; recover it before editing.")));return;}var current=player.getInventory().getItemInMainHand();if(!player.isOnline()||physicalCopies(character)!=1||current.getAmount()!=1||sheets.identify(current).filter(character::equals).isEmpty()){directory.cancelAssignment(dungeon,custody.operation()).whenComplete((ignored,rollbackFailure)->respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the held sheet changed.":"Character boss custody was saved but sheet consumption could not be confirmed; recover it before editing.")));return;}player.getInventory().setItemInMainHand(null);sender.sendMessage("Assigned character "+custody.character().name()+" as the dungeon boss at "+custody.multiplier().toPlainString()+"x stats; its sheet is now in durable custody.");}));});});}
|
||||
private void assignCharacterBoss(CommandSender sender,Player player,UUID dungeon,DungeonState.Room room,String rawMultiplier){BigDecimal multiplier;try{if(!rawMultiplier.matches("(?:0|[1-9][0-9]*)(?:\\.[0-9]{1,3})?"))throw new NumberFormatException();multiplier=new BigDecimal(rawMultiplier);}catch(NumberFormatException invalid){usage(sender);return;}var held=player.getInventory().getItemInMainHand();var sheetIdentity=sheets.identity(held).orElse(null);var character=sheetIdentity==null?null:sheetIdentity.character();if(character==null||held.getAmount()!=1){sender.sendMessage("Hold exactly one authentic living character sheet in your main hand.");return;}if(physicalCopies(character)!=1){sender.sendMessage("Character boss assignment requires one unambiguous physical sheet.");return;}if(activeCharacter.test(character)){sender.sendMessage("That character has an active equipment session.");return;}if(partyCharacter.test(character)){sender.sendMessage("That character must leave its party before boss assignment.");return;}characters.apply(character).whenComplete((loaded,failure)->{if(failure!=null||loaded==null||loaded.isEmpty()||loaded.orElseThrow().dead()||loaded.orElseThrow().health().signum()<=0){respond(sender,()->sender.sendMessage("Character boss assignment refused: the saved living character could not be verified."));return;}var expected=loaded.orElseThrow();if(!sheetIdentity.generation().equals(expected.sheetGeneration())||(sheetIdentity.legacy()&&expected.sheetGeneration().signum()!=0)){respond(sender,()->sender.sendMessage("Character boss assignment refused: that sheet generation is no longer authorized."));return;}directory.assignCharacterBoss(dungeon,room.id(),expected,multiplier).whenComplete((custody,saveFailure)->{if(saveFailure!=null){respond(sender,()->sender.sendMessage("Character boss assignment failed before sheet custody changed."));return;}characters.apply(character).whenComplete((fresh,readFailure)->respond(sender,()->{if(readFailure!=null||fresh==null||fresh.isEmpty()||!fresh.orElseThrow().equals(expected)||activeCharacter.test(character)||partyCharacter.test(character)){directory.cancelAssignment(dungeon,custody.operation()).whenComplete((ignored,rollbackFailure)->respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the saved character changed.":"Character boss custody was saved but assignment rollback could not be confirmed; recover it before editing.")));return;}var current=player.getInventory().getItemInMainHand();if(!player.isOnline()||physicalCopies(character)!=1||current.getAmount()!=1||!sheets.authorizes(current,expected)){directory.cancelAssignment(dungeon,custody.operation()).whenComplete((ignored,rollbackFailure)->respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the held sheet changed.":"Character boss custody was saved but sheet consumption could not be confirmed; recover it before editing.")));return;}player.getInventory().setItemInMainHand(null);sender.sendMessage("Assigned character "+custody.character().name()+" as the dungeon boss at "+custody.multiplier().toPlainString()+"x stats; its sheet is now in durable custody.");}));});});}
|
||||
private void bossCommand(CommandSender sender,String[] args){if(args.length!=4||!args[2].equals("recover-sheet")||!(sender instanceof Player player)){usage(sender);return;}UUID dungeon=id(args[3]);if(dungeon==null){usage(sender);return;}recoverCharacterBoss(sender,player,dungeon);}
|
||||
private void recoverCharacterBoss(CommandSender sender,Player player,UUID dungeon){var definition=directory.find(dungeon).orElse(null);var custody=directory.custodyForDungeon(dungeon).orElse(null);if(definition==null||custody==null){sender.sendMessage("Dungeon character boss custody was not found.");return;}var catalog=adventures.snapshot().catalog();boolean offered=catalog!=null&&adventures.offerOpen(dungeon);boolean accepted=adventures.active().stream().anyMatch(quest->quest.site().equals(dungeon));if(definition.enabled()||offered||accepted||controller.beingEdited(dungeon)||controller.editing(player.getUniqueId()).isPresent()||controller.occupied(dungeon)){sender.sendMessage("Recovery requires a disabled, closed, unedited and unoccupied dungeon with no accepted quests.");return;}var physical=plugin.getServer().getOnlinePlayers().stream().flatMap(holder->Arrays.stream(holder.getInventory().getContents()).filter(Objects::nonNull).filter(item->sheets.identify(item).filter(custody.character().id()::equals).isPresent()).map(item->Map.entry(holder,item))).toList();if(physical.size()>1||physical.size()==1&&physical.getFirst().getKey()!=player){sender.sendMessage("Recovery refused because existing physical-sheet custody is ambiguous.");return;}boolean delivered=!physical.isEmpty()||custody.recovery()!=null&&Arrays.stream(player.getInventory().getContents()).anyMatch(item->sheets.hasRecovery(item,custody.recovery().operation()));if(!delivered&&player.getInventory().firstEmpty()<0){sender.sendMessage("One empty inventory slot is required before recovery.");return;}directory.prepareRecovery(dungeon,player.getUniqueId()).whenComplete((prepared,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Character boss recovery could not be prepared; custody is retained.");return;}var operation=prepared.recovery().operation();org.bukkit.inventory.ItemStack sheet=null;for(var item:player.getInventory().getContents())if(sheets.hasRecovery(item,operation)||sheets.identify(item).filter(prepared.character().id()::equals).isPresent()){sheet=item;break;}if(sheet==null){int slot=player.getInventory().firstEmpty();if(slot<0){sender.sendMessage("Character boss recovery is prepared, but one empty inventory slot is required.");return;}sheet=sheets.create(prepared.character());sheets.markRecovery(sheet,operation);player.getInventory().setItem(slot,sheet);}var item=sheet;directory.completeRecovery(dungeon,operation).whenComplete((completed,completeFailure)->respond(sender,()->{if(completeFailure!=null||!completed){sender.sendMessage("Recovery sheet delivery is retained, but durable custody completion must be retried.");return;}sheets.clearRecovery(item);sender.sendMessage("Recovered character sheet for "+prepared.character().name()+" from dungeon "+dungeon+"; configure another boss before enabling it.");}));}));}
|
||||
private void teleport(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}if(!(sender instanceof Player)){sender.sendMessage("Dungeon teleport requires a player.");return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}var dungeon=directory.find(id).orElse(null);if(dungeon==null){sender.sendMessage("Dungeon not found.");return;}teleports.teleport(sender,"Dungeon",target(dungeon),()->directory.find(id).map(DungeonCommands::target));}
|
||||
private static AdventureAdminTeleport.Target target(DungeonState.Dungeon dungeon){var entrance=dungeon.entrance();return new AdventureAdminTeleport.Target(dungeon.id(),dungeon.name(),"dungeon entrance",dungeon.world(),entrance.x(),entrance.y(),entrance.z(),entrance.yaw(),entrance.pitch(),false);}
|
||||
private void enable(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.enable(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Enabled dungeon "+id+" for later adventure catalogs.":"Dungeon enable refused: validate rooms, boss, overlap and persistence.")));}
|
||||
private void enable(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.enable(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Enabled dungeon "+id+" for later adventure catalogs.":"Dungeon enable refused: validate completion reward, rooms, boss, overlap and persistence.")));}
|
||||
private void disable(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.disable(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure!=null?"Dungeon disable failed; retained state is authoritative.":value?"Disabled and closed dungeon "+id+".":"Dungeon not found.")));}
|
||||
private void open(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.open(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Opened dungeon "+id+" in generation "+value.revision()+".":"Dungeon open refused: it is invalid, disabled, defeated or the offer limit was reached.")));}
|
||||
private void close(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.closeOffer(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure!=null?"Dungeon close failed; retained state is authoritative.":value?"Closed dungeon "+id+" for this generation.":"Dungeon was not open.")));}
|
||||
public List<String> complete(CommandSender sender,String[] args){if(!sender.hasPermission(PERMISSION))return List.of();if(args.length==1)return matching(List.of("dungeon"),args[0]);if(args.length==2&&args[0].equals("dungeon"))return matching(sender instanceof Player?List.of("boss","close","create","disable","edit","enable","entrance","list","open","remove","room","teleport"):List.of("close","disable","enable","list","open","remove"),args[1]);if(!args[0].equals("dungeon"))return List.of();if(args.length==3&&args[1].equals("boss"))return matching(List.of("recover-sheet"),args[2]);if(args.length==4&&args[1].equals("boss")&&args[2].equals("recover-sheet"))return matching(directory.snapshot().dungeons().stream().filter(value->recoverable(value.id())).map(value->value.id().toString()).toList(),args[3]);if(args.length==3&&Set.of("close","disable","enable","open","remove","teleport").contains(args[1]))return matching(directory.snapshot().dungeons().stream().map(v->v.id().toString()).toList(),args[2]);if(args.length==4&&Set.of("close","disable","remove").contains(args[1]))return matching(List.of("confirm"),args[3]);if(args.length==3&&args[1].equals("edit"))return matching(List.of("done","start"),args[2]);if(args.length==4&&args[1].equals("edit")&&args[2].equals("start"))return matching(directory.snapshot().dungeons().stream().map(v->v.id().toString()).toList(),args[3]);if(args.length==5&&args[1].equals("edit")&&args[2].equals("start"))return matching(List.of("confirm"),args[4]);if(args.length==3&&args[1].equals("entrance"))return matching(List.of("set"),args[2]);if(args.length==3&&args[1].equals("room"))return matching(List.of("boss","create","effect","list","mob","remove","reward"),args[2]);if(args.length==4&&args[1].equals("room")&&args[2].equals("create"))return matching(List.of("corner","radius"),args[3]);if(args.length>=5&&args.length<=7&&args[1].equals("room")&&args[2].equals("create"))return matching(List.of("1","3","8","128","256"),args[args.length-1]);if(args.length==4&&args[1].equals("room")&&args[2].equals("remove")&&sender instanceof Player player){var id=controller.editing(player.getUniqueId()).orElse(null);return id==null?List.of():matching(directory.find(id).map(DungeonState.Dungeon::rooms).orElse(List.of()).stream().map(v->v.id().toString()).toList(),args[3]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("mob"))return matching(List.of("add","list","remove"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("mob")&&args[3].equals("add"))return matching(entityNames(),args[4]);if(args.length==5&&args[1].equals("room")&&args[2].equals("mob")&&args[3].equals("remove")&&sender instanceof Player player){var room=controller.standingRoom(player).orElse(null);return room==null?List.of():matching(room.mobs().stream().map(v->v.id().toString()).toList(),args[4]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("effect"))return matching(List.of("add","remove"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("both","mobs","players"),args[4]);if(args.length==6&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(effectNames(),args[5]);if(args.length==7&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("1","2","64","256"),args[6]);if(args.length==8&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("false","true"),args[7]);if(args.length==5&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("remove")&&sender instanceof Player player){var room=controller.standingRoom(player).orElse(null);return room==null?List.of():matching(room.effects().stream().map(v->v.id().toString()).toList(),args[4]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("reward"))return matching(List.of("set"),args[3]);if((args.length==5||args.length==6)&&args[1].equals("room")&&args[2].equals("reward")&&args[3].equals("set"))return matching(List.of("0","10","100","2147483647"),args[args.length-1]);if(args.length==4&&args[1].equals("room")&&args[2].equals("boss"))return matching(List.of("assign-sheet","clear","mob"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("boss")&&args[3].equals("mob"))return matching(entityNames(),args[4]);if(args.length==5&&args[1].equals("room")&&args[2].equals("boss")&&args[3].equals("assign-sheet"))return matching(List.of("0.1","1","1.5","10","100"),args[4]);return List.of();}
|
||||
public List<String> complete(CommandSender sender,String[] args){if(!sender.hasPermission(PERMISSION))return List.of();if(args.length==1)return matching(List.of("dungeon"),args[0]);if(args.length==2&&args[0].equals("dungeon"))return matching(sender instanceof Player?List.of("boss","close","create","disable","edit","enable","entrance","list","mode","open","remove","reward","room","teleport"):List.of("close","disable","enable","list","mode","open","remove","reward"),args[1]);if(!args[0].equals("dungeon"))return List.of();if(args.length==3&&args[1].equals("boss"))return matching(List.of("recover-sheet"),args[2]);if(args.length==4&&args[1].equals("boss")&&args[2].equals("recover-sheet"))return matching(directory.snapshot().dungeons().stream().filter(value->recoverable(value.id())).map(value->value.id().toString()).toList(),args[3]);if(args.length==3&&args[1].equals("create"))return matching(List.of("--mode"),args[2]);if(args.length==4&&args[1].equals("create")&&args[2].equals("--mode"))return matching(modeValues(),args[3]);if(args.length==3&&Set.of("close","disable","enable","mode","open","remove","reward","teleport").contains(args[1]))return matching(directory.snapshot().dungeons().stream().map(v->v.id().toString()).toList(),args[2]);if(args.length==4&&args[1].equals("mode"))return matching(modeValues(),args[3]);if((args.length==4||args.length==5)&&args[1].equals("reward")){UUID dungeon=id(args[2]);var current=dungeon==null?null:directory.find(dungeon).orElse(null);if(current==null)return List.of();var values=new ArrayList<>(List.of("0","10","100","2147483647"));values.add((args.length==4?current.completionReward().xp():current.completionReward().coins()).toString());return matching(values,args[args.length-1]);}if(args.length==4&&Set.of("close","disable","remove").contains(args[1]))return matching(List.of("confirm"),args[3]);if(args.length==3&&args[1].equals("edit"))return matching(List.of("done","start"),args[2]);if(args.length==4&&args[1].equals("edit")&&args[2].equals("start"))return matching(directory.snapshot().dungeons().stream().map(v->v.id().toString()).toList(),args[3]);if(args.length==5&&args[1].equals("edit")&&args[2].equals("start"))return matching(List.of("confirm"),args[4]);if(args.length==3&&args[1].equals("entrance"))return matching(List.of("set"),args[2]);if(args.length==3&&args[1].equals("room"))return matching(List.of("boss","create","effect","list","mob","remove"),args[2]);if(args.length==4&&args[1].equals("room")&&args[2].equals("create"))return matching(List.of("corner","radius"),args[3]);if(args.length>=5&&args.length<=7&&args[1].equals("room")&&args[2].equals("create"))return matching(List.of("1","3","8","128","256"),args[args.length-1]);if(args.length==4&&args[1].equals("room")&&args[2].equals("remove")&&sender instanceof Player player){var id=controller.editing(player.getUniqueId()).orElse(null);return id==null?List.of():matching(directory.find(id).map(DungeonState.Dungeon::rooms).orElse(List.of()).stream().map(v->v.id().toString()).toList(),args[3]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("mob"))return matching(List.of("add","list","remove"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("mob")&&args[3].equals("add"))return matching(entityNames(),args[4]);if(args.length==5&&args[1].equals("room")&&args[2].equals("mob")&&args[3].equals("remove")&&sender instanceof Player player){var room=controller.standingRoom(player).orElse(null);return room==null?List.of():matching(room.mobs().stream().map(v->v.id().toString()).toList(),args[4]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("effect"))return matching(List.of("add","remove"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("both","mobs","players"),args[4]);if(args.length==6&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(effectNames(),args[5]);if(args.length==7&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("1","2","64","256"),args[6]);if(args.length==8&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("false","true"),args[7]);if(args.length==5&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("remove")&&sender instanceof Player player){var room=controller.standingRoom(player).orElse(null);return room==null?List.of():matching(room.effects().stream().map(v->v.id().toString()).toList(),args[4]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("boss"))return matching(List.of("assign-sheet","clear","mob"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("boss")&&args[3].equals("mob"))return matching(entityNames(),args[4]);if(args.length==5&&args[1].equals("room")&&args[2].equals("boss")&&args[3].equals("assign-sheet"))return matching(List.of("0.1","1","1.5","10","100"),args[4]);return List.of();}
|
||||
private long physicalCopies(UUID character){return plugin.getServer().getOnlinePlayers().stream().flatMap(holder->Arrays.stream(holder.getInventory().getContents())).filter(Objects::nonNull).filter(item->sheets.identify(item).filter(character::equals).isPresent()).mapToLong(org.bukkit.inventory.ItemStack::getAmount).sum();}
|
||||
private boolean recoverable(UUID dungeon){var definition=directory.find(dungeon).orElse(null);var catalog=adventures.snapshot().catalog();return definition!=null&&!definition.enabled()&&directory.custodyForDungeon(dungeon).isPresent()&&!controller.beingEdited(dungeon)&&!controller.occupied(dungeon)&&(catalog==null||!adventures.offerOpen(dungeon))&&adventures.active().stream().noneMatch(quest->quest.site().equals(dungeon));}
|
||||
private UUID editing(Player player){var result=controller.editing(player.getUniqueId()).orElse(null);if(result==null)player.sendMessage("Start editing one dungeon first.");return result;}
|
||||
@@ -60,5 +61,6 @@ public final class DungeonCommands {
|
||||
private static BigInteger whole(String value){try{return new BigInteger(value);}catch(NumberFormatException invalid){return null;}}
|
||||
private static String join(String[] args,int from){return from>=args.length?"":String.join(" ",Arrays.copyOfRange(args,from,args.length));}
|
||||
private static List<String> matching(Collection<String> values,String prefix){return values.stream().filter(value->value.startsWith(prefix)).distinct().sorted().toList();}
|
||||
private static void usage(CommandSender sender){sender.sendMessage("Usage: /fantasystick dungeon <create|list|remove|edit|entrance|room|boss|enable|disable|open|close|teleport> ...");}
|
||||
private static List<String> modeValues(){return Arrays.stream(games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.values()).map(games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode::value).toList();}
|
||||
private static void usage(CommandSender sender){sender.sendMessage("Usage: /fstickadmin dungeon <create [--mode <mode>] <name...>|mode <dungeon-uuid> [<mode>]|list|remove|edit|entrance|reward|room|boss|enable|disable|open|close|teleport> ...");}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package games.dmg.fantasystick.dungeon;
|
||||
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestDirectory;
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestState;
|
||||
import games.dmg.fantasystick.adventure.FishingController;
|
||||
import games.dmg.fantasystick.adventure.FishingState;
|
||||
import games.dmg.fantasystick.ability.*;
|
||||
import games.dmg.fantasystick.passive.PassiveRegistry;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
@@ -30,18 +32,22 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
|
||||
private record Edit(UUID dungeon,Map<UUID,Color> colors){}
|
||||
private static final String ENEMY="dungeon_enemy",PROJECTILE="dungeon_projectile";
|
||||
private record AttemptKey(UUID dungeon,UUID room){}
|
||||
private record PendingKill(UUID dungeon,UUID room,UUID generation,UUID slot){}
|
||||
private static final class Attempt {final UUID dungeon,room,generation;final Map<UUID,UUID> entities=new HashMap<>();final Set<UUID> represented=new HashSet<>();final Map<UUID,Double> threat=new HashMap<>();final Map<String,Long> cooldowns=new HashMap<>();final Map<String,Long> effects=new HashMap<>();UUID boss,target;long lastCombat=System.nanoTime();Attempt(UUID dungeon,UUID room,UUID generation){this.dungeon=dungeon;this.room=room;this.generation=generation;}}
|
||||
private record Context(UUID character,UUID party,Set<UUID> members){}
|
||||
private record OwnedEffect(UUID dungeon,UUID player,PotionEffectType type,int amplifier,PotionEffect prior){}
|
||||
private final Plugin plugin;private final DungeonDirectory dungeons;private final AdventureQuestDirectory adventures;private final EquipmentSessions sessions;private final PartyRoster parties;private final CharacterStorage characters;private final ProgressionRules progression;private final Consumer<Runnable> nextTick;private final java.util.random.RandomGenerator random;private final AbilityRegistry abilities;private final PassiveRegistry passives;private final java.util.function.Supplier<games.dmg.fantasystick.session.OutOfCombatRecovery.Settings> recoverySettings;private final java.util.function.Function<games.dmg.fantasystick.persistence.CharacterRecord,BigInteger> recoveryBonus;private final java.util.function.Supplier<BigInteger> healthStealHealing;private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
|
||||
private final Plugin plugin;private final DungeonDirectory dungeons;private final AdventureQuestDirectory adventures;private final EquipmentSessions sessions;private final PartyRoster parties;private final CharacterStorage characters;private final ProgressionRules progression;private final Consumer<Runnable> nextTick;private final java.util.random.RandomGenerator random;private final AbilityRegistry abilities;private final PassiveRegistry passives;private final java.util.function.Supplier<games.dmg.fantasystick.session.OutOfCombatRecovery.Settings> recoverySettings;private final java.util.function.Function<games.dmg.fantasystick.persistence.CharacterRecord,BigInteger> recoveryBonus;private final java.util.function.Supplier<BigInteger> healthStealHealing;private final games.dmg.fantasystick.presentation.ParticleDelivery particles;private FishingController fishing;
|
||||
private final NamespacedKey typeKey,dungeonKey,roomKey,slotKey,generationKey,bossKey;
|
||||
private final Map<UUID,Edit> playerEdits=new HashMap<>();private final Map<UUID,UUID> dungeonEditors=new HashMap<>();private final Map<AttemptKey,Attempt> attempts=new HashMap<>();private final Set<String> startingRuns=new HashSet<>();private final Set<AttemptKey> completing=new HashSet<>();private final Set<UUID> defeatingDungeons=new HashSet<>();private final Set<CompletableFuture<?>> inFlight=ConcurrentHashMap.newKeySet();private final Map<String,OwnedEffect> ownedEffects=new HashMap<>();private final Set<Entity> relocating=Collections.newSetFromMap(new IdentityHashMap<>());private org.bukkit.scheduler.BukkitTask pulse;private boolean closed;private long ticks=9;
|
||||
private final Map<UUID,Edit> playerEdits=new HashMap<>();private final Map<UUID,UUID> dungeonEditors=new HashMap<>();private final Map<AttemptKey,Attempt> attempts=new HashMap<>();private final Set<String> startingRuns=new HashSet<>();private final Set<AttemptKey> completing=new HashSet<>();private final Set<UUID> defeatingDungeons=new HashSet<>();private final Set<CompletableFuture<?>> inFlight=ConcurrentHashMap.newKeySet();private final Map<String,OwnedEffect> ownedEffects=new HashMap<>();private final Map<UUID,PendingKill> pendingKills=new ConcurrentHashMap<>();private final Set<Entity> relocating=Collections.newSetFromMap(new IdentityHashMap<>());private org.bukkit.scheduler.BukkitTask pulse;private boolean closed;private long ticks=9;
|
||||
public DungeonController(Plugin plugin,DungeonDirectory dungeons,AdventureQuestDirectory adventures,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random){this(plugin,dungeons,adventures,sessions,parties,characters,progression,nextTick,random,new AbilityRegistry(),new PassiveRegistry(),games.dmg.fantasystick.session.OutOfCombatRecovery.Settings::defaults,ignored->BigInteger.ZERO,()->BigInteger.ONE);}
|
||||
public DungeonController(Plugin plugin,DungeonDirectory dungeons,AdventureQuestDirectory adventures,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random,AbilityRegistry abilities,PassiveRegistry passives){this(plugin,dungeons,adventures,sessions,parties,characters,progression,nextTick,random,abilities,passives,games.dmg.fantasystick.session.OutOfCombatRecovery.Settings::defaults,ignored->BigInteger.ZERO,()->BigInteger.ONE);}
|
||||
public DungeonController(Plugin plugin,DungeonDirectory dungeons,AdventureQuestDirectory adventures,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random,AbilityRegistry abilities,PassiveRegistry passives,java.util.function.Supplier<games.dmg.fantasystick.session.OutOfCombatRecovery.Settings> recoverySettings,java.util.function.Function<games.dmg.fantasystick.persistence.CharacterRecord,BigInteger> recoveryBonus,java.util.function.Supplier<BigInteger> healthStealHealing){this(plugin,dungeons,adventures,sessions,parties,characters,progression,nextTick,random,abilities,passives,recoverySettings,recoveryBonus,healthStealHealing,games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());}
|
||||
public DungeonController(Plugin plugin,DungeonDirectory dungeons,AdventureQuestDirectory adventures,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random,AbilityRegistry abilities,PassiveRegistry passives,java.util.function.Supplier<games.dmg.fantasystick.session.OutOfCombatRecovery.Settings> recoverySettings,java.util.function.Function<games.dmg.fantasystick.persistence.CharacterRecord,BigInteger> recoveryBonus,java.util.function.Supplier<BigInteger> healthStealHealing,games.dmg.fantasystick.presentation.ParticleDelivery particles){this.plugin=Objects.requireNonNull(plugin);this.dungeons=Objects.requireNonNull(dungeons);this.adventures=Objects.requireNonNull(adventures);this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.characters=Objects.requireNonNull(characters);this.progression=Objects.requireNonNull(progression);this.nextTick=Objects.requireNonNull(nextTick);this.random=Objects.requireNonNull(random);this.abilities=Objects.requireNonNull(abilities);this.passives=Objects.requireNonNull(passives);this.recoverySettings=Objects.requireNonNull(recoverySettings);this.recoveryBonus=Objects.requireNonNull(recoveryBonus);this.healthStealHealing=Objects.requireNonNull(healthStealHealing);this.particles=Objects.requireNonNull(particles);typeKey=new NamespacedKey(plugin,"managed_npc_type");dungeonKey=new NamespacedKey(plugin,"dungeon_id");roomKey=new NamespacedKey(plugin,"dungeon_room_id");slotKey=new NamespacedKey(plugin,"dungeon_spawn_id");generationKey=new NamespacedKey(plugin,"dungeon_generation_id");bossKey=new NamespacedKey(plugin,"dungeon_boss");}
|
||||
public void registerFishing(FishingController fishing){if(closed||this.fishing!=null)throw new IllegalStateException("Fishing already attached or dungeon controller closed");this.fishing=Objects.requireNonNull(fishing);}
|
||||
public void install(){synchronizeEnabled();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(managed(entity))entity.remove();pulse=plugin.getServer().getScheduler().runTaskTimer(plugin,this::tick,1,1);}
|
||||
public boolean managed(Entity entity){var data=entity.getPersistentDataContainer();if(data==null)return false;var type=data.get(typeKey,PersistentDataType.STRING);return ENEMY.equals(type)||PROJECTILE.equals(type);}
|
||||
public Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin> encounterOrigin(Entity entity){if(!managedEnemy(entity))return Optional.empty();UUID dungeon=id(entity,dungeonKey),room=id(entity,roomKey),generation=id(entity,generationKey);if(dungeon==null||room==null||generation==null)return Optional.empty();var attempt=attempts.get(new AttemptKey(dungeon,room));if(attempt!=null&&!attempt.generation.equals(generation))return Optional.empty();UUID slot=id(entity,slotKey);if(slot==null&&(!Boolean.TRUE.equals(entity.getPersistentDataContainer().get(bossKey,PersistentDataType.BOOLEAN))||dungeons.room(dungeon,room).map(value->value.boss()==null).orElse(true)))return Optional.empty();if(slot!=null&&(dungeons.room(dungeon,room).stream().flatMap(value->value.mobs().stream()).noneMatch(value->value.id().equals(slot))||dungeons.runView(dungeon,generation).map(run->run.defeatedSlots().contains(slot)||run.defeated()).orElse(true)))return Optional.empty();String encounter=slot==null?"boss":slot.toString();UUID source=UUID.nameUUIDFromBytes((dungeon+":"+room+":"+generation+":"+encounter).getBytes(java.nio.charset.StandardCharsets.UTF_8));if(slot!=null&&attempt==null&&!pendingKills.containsKey(source))return Optional.empty();return Optional.of(new games.dmg.fantasystick.progression.ManagedEncounterOrigin(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,source));}
|
||||
public void settleKill(games.dmg.fantasystick.progression.ManagedEncounterOrigin origin){if(origin.kind()!=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON)return;var pending=pendingKills.remove(origin.source());if(pending==null)return;dungeons.defeatSlot(pending.dungeon(),pending.generation(),pending.slot()).whenComplete((run,failure)->schedule(()->{if(failure!=null)return;var room=dungeons.room(pending.dungeon(),pending.room()).orElse(null);var attempt=attempts.get(new AttemptKey(pending.dungeon(),pending.room()));var dungeon=dungeons.find(pending.dungeon()).orElse(null);if(room!=null&&attempt!=null&&dungeon!=null&&room.boss()==null&&room.mobs().stream().allMatch(value->run.defeatedSlots().contains(value.id())))clearRoom(dungeon,room,attempt,false);}));}
|
||||
private void synchronizeEnabled(){for(var site:adventures.snapshot().sites())if(site.type()==AdventureQuestState.SiteType.DUNGEON)adventures.removeDungeonSite(site.id());}
|
||||
public CompletionStage<EditStatus> start(Player player,UUID dungeonId,boolean confirm){if(closed)return CompletableFuture.completedFuture(EditStatus.BUSY);UUID playerId=player.getUniqueId();var dungeon=dungeons.find(dungeonId).orElse(null);if(dungeon==null)return CompletableFuture.completedFuture(EditStatus.NOT_FOUND);synchronized(this){if(playerEdits.containsKey(playerId)||dungeonEditors.containsKey(dungeonId))return CompletableFuture.completedFuture(EditStatus.BUSY);}var catalog=adventures.snapshot().catalog();boolean offered=catalog!=null&&adventures.offerOpen(dungeonId);boolean work=offered||adventures.active().stream().anyMatch(q->q.site().equals(dungeonId))||dungeons.snapshot().runs().stream().anyMatch(run->run.dungeon().equals(dungeonId));if(work&&!confirm)return CompletableFuture.completedFuture(EditStatus.CONFIRM_REQUIRED);CompletionStage<?> transition=settleAwards().thenCompose(ignored->dungeons.disable(dungeonId)).thenCompose(ignored->adventures.closeOffer(dungeonId)).thenCompose(ignored->adventures.removeDungeonSite(dungeonId)).thenCompose(ignored->cleanupOnMain(dungeonId));return transition.thenApply(ignored->{synchronized(this){if(closed||playerEdits.containsKey(playerId)||dungeonEditors.containsKey(dungeonId))return EditStatus.BUSY;var colors=new HashMap<UUID,Color>();for(var room:dungeons.find(dungeonId).orElseThrow().rooms())colors.put(room.id(),randomColor());playerEdits.put(playerId,new Edit(dungeonId,colors));dungeonEditors.put(dungeonId,playerId);return EditStatus.STARTED;}});}
|
||||
public synchronized boolean done(UUID player){var edit=playerEdits.remove(player);if(edit==null)return false;dungeonEditors.remove(edit.dungeon(),player);return true;}
|
||||
@@ -118,9 +124,9 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void bossAccepted(EntityDamageByEntityEvent event){if(!(event.getDamager() instanceof Mob boss)||!managedEnemy(boss)||!(event.getEntity() instanceof Player player)||event.getFinalDamage()<=0)return;var room=dungeons.room(id(boss,dungeonKey),id(boss,roomKey)).orElse(null);var custody=room==null||room.boss()==null?null:room.boss().character();if(custody==null)return;if(custody.character().passives().contains("stick_knockback")&&passives.find("stick_knockback").isPresent()){double radians=Math.toRadians(boss.getLocation().getYaw()),x=Math.sin(radians),z=-Math.cos(radians);if(Double.isFinite(x)&&Double.isFinite(z))player.knockback(1,x,z);}}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void bossKill(org.bukkit.event.entity.PlayerDeathEvent event){if(!(event.getEntity().getLastDamageCause() instanceof EntityDamageByEntityEvent damage)||!(damage.getDamager() instanceof Mob boss)||!managedEnemy(boss))return;var room=dungeons.room(id(boss,dungeonKey),id(boss,roomKey)).orElse(null);var custody=room==null||room.boss()==null?null:room.boss().character();if(custody==null||!custody.character().passives().contains("health_steal")||passives.find("health_steal").isEmpty()||eligible(event.getPlayer(),id(boss,dungeonKey),id(boss,roomKey))==null)return;healBoss(boss,Math.min(Double.MAX_VALUE,healthStealHealing.get().doubleValue()));}
|
||||
public void accept(AbilityReport report){if(report.type()==AbilityReport.Type.DAMAGE)return;var active=sessions.activeCharacter(report.sourceCharacter()).filter(sessions::current).orElse(null);if(active==null)return;var player=plugin.getServer().getPlayer(active.player());if(player==null)return;double value=report.amount().doubleValue();if(!Double.isFinite(value)||value<=0)return;for(var entry:attempts.entrySet())if(eligible(player,entry.getKey().dungeon(),entry.getKey().room())!=null)entry.getValue().threat.merge(player.getUniqueId(),value*.5d,Double::sum);}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void death(EntityDeathEvent event){if(!managedEnemy(event.getEntity()))return;UUID dungeonId=id(event.getEntity(),dungeonKey),roomId=id(event.getEntity(),roomKey),generation=id(event.getEntity(),generationKey);if(dungeonId==null||roomId==null||generation==null)return;var dungeon=dungeons.find(dungeonId).orElse(null);var room=dungeons.room(dungeonId,roomId).orElse(null);var attempt=attempts.get(new AttemptKey(dungeonId,roomId));if(dungeon==null||room==null||attempt==null)return;boolean boss=Boolean.TRUE.equals(event.getEntity().getPersistentDataContainer().get(bossKey,PersistentDataType.BOOLEAN));if(boss){attempt.boss=null;defeatingDungeons.add(dungeonId);cleanupDungeon(dungeonId);clearRoom(dungeon,room,attempt,true);return;}UUID slot=id(event.getEntity(),slotKey);attempt.entities.remove(event.getEntity().getUniqueId());if(slot==null)return;dungeons.defeatSlot(dungeonId,generation,slot).whenComplete((run,failure)->schedule(()->{if(failure!=null)return;if(room.boss()==null&&room.mobs().stream().allMatch(value->run.defeatedSlots().contains(value.id())))clearRoom(dungeon,room,attempt,false);}));}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void death(EntityDeathEvent event){if(!managedEnemy(event.getEntity()))return;UUID dungeonId=id(event.getEntity(),dungeonKey),roomId=id(event.getEntity(),roomKey),generation=id(event.getEntity(),generationKey);if(dungeonId==null||roomId==null||generation==null)return;var dungeon=dungeons.find(dungeonId).orElse(null);var room=dungeons.room(dungeonId,roomId).orElse(null);var attempt=attempts.get(new AttemptKey(dungeonId,roomId));if(dungeon==null||room==null||attempt==null)return;boolean boss=Boolean.TRUE.equals(event.getEntity().getPersistentDataContainer().get(bossKey,PersistentDataType.BOOLEAN));if(boss){attempt.boss=null;defeatingDungeons.add(dungeonId);cleanupDungeon(dungeonId);clearRoom(dungeon,room,attempt,true);return;}UUID slot=id(event.getEntity(),slotKey);attempt.entities.remove(event.getEntity().getUniqueId());if(slot==null)return;encounterOrigin(event.getEntity()).ifPresent(origin->pendingKills.putIfAbsent(origin.source(),new PendingKill(dungeonId,roomId,generation,slot)));}
|
||||
private void clearRoom(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,boolean boss){var key=new AttemptKey(dungeon.id(),room.id());if(!completing.add(key))return;var quests=attempt.represented.stream().map(adventures::findQuest).flatMap(Optional::stream).toList();var awards=quests.stream().map(quest->awardQuest(dungeon,room,attempt.generation,quest,boss)).toArray(CompletableFuture[]::new);var completion=CompletableFuture.allOf(awards).thenCompose(ignored->dungeons.markCleared(dungeon.id(),attempt.generation,room.id())).thenCompose(run->boss?dungeons.defeat(dungeon.id(),attempt.generation):CompletableFuture.completedFuture(run)).thenCompose(run->boss?adventures.expireOfferQuests(dungeon.id()).thenApply(ignored->run):CompletableFuture.completedFuture(run)).whenComplete((run,failure)->schedule(()->{completing.remove(key);if(boss)defeatingDungeons.remove(dungeon.id());if(failure!=null){plugin.getLogger().log(java.util.logging.Level.WARNING,"Dungeon room completion failed",failure);return;}cleanupAttempt(key);if(boss)cleanupDungeon(dungeon.id());}));inFlight.add(completion);completion.whenComplete((ignored,failure)->inFlight.remove(completion));}
|
||||
private CompletableFuture<Void> awardQuest(DungeonState.Dungeon dungeon,DungeonState.Room room,UUID generation,AdventureQuestState.Quest quest,boolean completeQuest){var run=dungeons.runView(dungeon.id(),generation).orElse(null);if(run==null||run.unitClaims().contains(new DungeonState.UnitClaim(room.id(),quest.id())))return CompletableFuture.completedFuture(null);var recipients=recipients(dungeon,room,quest,run);if(recipients.isEmpty())return CompletableFuture.completedFuture(null);var unit=new RewardAllocator.Unit<UUID>(quest.unit(),recipients);var xp=RewardAllocator.allocate(room.reward().xp(),List.of(unit),random);var coins=RewardAllocator.allocate(room.reward().coins(),List.of(unit),random);var rewards=new LinkedHashMap<UUID,AdventureQuestDirectory.Reward>();for(UUID character:recipients)rewards.put(character,new AdventureQuestDirectory.Reward(xp.get(character),coins.get(character)));UUID source=UUID.nameUUIDFromBytes((generation+":"+dungeon.id()+":"+room.id()+":"+quest.id()).getBytes(StandardCharsets.UTF_8));return adventures.bank(quest.id(),source,rewards).thenCompose(banked->{if(!banked)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon reward banking refused"));return completeQuest?adventures.complete(quest.id(),Set.copyOf(recipients)):CompletableFuture.completedFuture(true);}).thenCompose(completed->{if(!completed)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon quest completion refused"));return dungeons.clearRoom(dungeon.id(),generation,room.id(),quest.id(),Set.copyOf(recipients));}).thenApply(ignored->null);}
|
||||
private CompletableFuture<Void> awardQuest(DungeonState.Dungeon dungeon,DungeonState.Room room,UUID generation,AdventureQuestState.Quest quest,boolean completeQuest){var run=dungeons.runView(dungeon.id(),generation).orElse(null);if(run==null||run.unitClaims().contains(new DungeonState.UnitClaim(room.id(),quest.id())))return CompletableFuture.completedFuture(null);var recipients=recipients(dungeon,room,quest,run);if(recipients.isEmpty())return CompletableFuture.completedFuture(null);if(!completeQuest)return dungeons.clearRoom(dungeon.id(),generation,room.id(),quest.id(),Set.copyOf(recipients)).thenApply(ignored->null);if(fishing==null)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon trophy persistence unavailable"));var unit=new RewardAllocator.Unit<UUID>(quest.unit(),recipients);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(UUID character:recipients)rewards.put(character,new FishingState.Reward(xp.get(character),coins.get(character)));UUID carrier=quest.unitType()==AdventureQuestState.UnitType.SOLO?quest.unit():recipients.stream().map(parties::partyOfCharacter).flatMap(Optional::stream).filter(value->value.id().equals(quest.unit())).map(PartyRoster.Party::leaderCharacter).findFirst().orElse(null);if(carrier==null)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon trophy carrier unavailable"));UUID trophy=UUID.nameUUIDFromBytes(("dungeon:"+quest.id()).getBytes(StandardCharsets.UTF_8));var plan=new FishingState.Trophy(trophy,FishingState.TrophyType.DUNGEON,carrier,quest.catalog(),dungeon.id(),quest.id(),generation,"Trophy of "+dungeon.name(),dungeon.name(),rewards);return fishing.earnTrophy(plan).thenCompose(earned->{if(!earned)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon trophy persistence refused"));return dungeons.clearRoom(dungeon.id(),generation,room.id(),quest.id(),Set.copyOf(recipients));}).thenApply(ignored->(Void)null).toCompletableFuture();}
|
||||
private List<UUID> recipients(DungeonState.Dungeon dungeon,DungeonState.Room room,AdventureQuestState.Quest quest,DungeonState.Run run){var result=new ArrayList<UUID>();for(UUID character:quest.snapshot()){if(run.characterClaims().contains(new DungeonState.CharacterClaim(room.id(),character)))continue;var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);if(active==null)continue;var player=plugin.getServer().getPlayer(active.player());if(player==null||!inside(player.getLocation(),dungeon,room))continue;if(quest.unitType()==AdventureQuestState.UnitType.PARTY){var party=parties.partyOf(player.getUniqueId()).orElse(null);if(party==null||!party.id().equals(quest.unit())||!party.characters().contains(character))continue;}result.add(character);}return result.stream().distinct().sorted().toList();}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void target(EntityTargetLivingEntityEvent event){if(managedEnemy(event.getEntity())&&event.getTarget()!=null){UUID dungeon=id(event.getEntity(),dungeonKey);UUID room=id(event.getEntity(),roomKey);var attempt=dungeon==null||room==null?null:attempts.get(new AttemptKey(dungeon,room));if(!(event.getTarget() instanceof Player player)||dungeon==null||room==null||eligible(player,dungeon,room)==null||attempt!=null&&attempt.target!=null&&!attempt.target.equals(player.getUniqueId()))event.setCancelled(true);}}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void projectile(ProjectileLaunchEvent event){if(event.getEntity().getShooter() instanceof Entity shooter&&managedEnemy(shooter)){var data=event.getEntity().getPersistentDataContainer();data.set(typeKey,PersistentDataType.STRING,PROJECTILE);copy(data,dungeonKey,shooter);copy(data,roomKey,shooter);copy(data,generationKey,shooter);}}
|
||||
|
||||
@@ -23,7 +23,9 @@ public final class DungeonDirectory implements AutoCloseable,AdventureResetParti
|
||||
public Optional<DungeonState.CharacterBoss> custodyForDungeon(UUID dungeon){return find(dungeon).stream().flatMap(value->value.rooms().stream()).map(DungeonState.Room::boss).filter(Objects::nonNull).map(DungeonState.Boss::character).filter(Objects::nonNull).findFirst();}
|
||||
public Optional<DungeonState.Room> room(UUID dungeon,UUID room){return find(dungeon).flatMap(value->value.rooms().stream().filter(v->v.id().equals(room)).findFirst());}
|
||||
public Optional<DungeonState.Room> roomAt(UUID dungeon,int x,int y,int z){return find(dungeon).flatMap(value->{var matches=value.rooms().stream().filter(room->room.bounds().contains(x,y,z)).toList();return matches.size()==1?Optional.of(matches.getFirst()):Optional.empty();});}
|
||||
public CompletableFuture<DungeonState.Dungeon> create(String name,UUID world,double x,double y,double z,float yaw,float pitch){var value=new DungeonState.Dungeon(UUID.randomUUID(),name,world,new DungeonState.Position(x,y,z,yaw,pitch),false,UUID.randomUUID(),List.of());return mutate(state->{var values=new ArrayList<>(state.dungeons());values.add(value);return result(new DungeonState(values,state.runs()),value);});}
|
||||
public CompletableFuture<DungeonState.Dungeon> create(String name,UUID world,double x,double y,double z,float yaw,float pitch){return create(name,world,x,y,z,yaw,pitch,AdventureQuestState.ParticipationMode.PARTY_AVAILABLE);}
|
||||
public CompletableFuture<DungeonState.Dungeon> create(String name,UUID world,double x,double y,double z,float yaw,float pitch,AdventureQuestState.ParticipationMode mode){var value=new DungeonState.Dungeon(UUID.randomUUID(),name,world,new DungeonState.Position(x,y,z,yaw,pitch),false,UUID.randomUUID(),List.of(),mode,DungeonState.Reward.empty(),false);return mutate(state->{var values=new ArrayList<>(state.dungeons());values.add(value);return result(new DungeonState(values,state.runs()),value);});}
|
||||
public CompletableFuture<DungeonState.Dungeon> setMode(UUID id,UUID expectedRevision,AdventureQuestState.ParticipationMode mode){Objects.requireNonNull(expectedRevision);Objects.requireNonNull(mode);return mutate(state->{var current=find(state,id).orElseThrow(()->new IllegalArgumentException("Dungeon not found"));if(!current.revision().equals(expectedRevision))throw new IllegalArgumentException("Dungeon changed");if(current.participationMode()==mode)return result(state,current);var changed=new DungeonState.Dungeon(current.id(),current.name(),current.world(),current.entrance(),current.enabled(),UUID.randomUUID(),current.rooms(),mode,current.completionReward(),current.rewardConfigured());return result(replace(state,changed),changed);});}
|
||||
public CompletableFuture<Optional<DungeonState.Dungeon>> remove(UUID id){return mutate(state->{var found=find(state,id);if(found.isEmpty())return result(state,Optional.empty());if(found.orElseThrow().rooms().stream().map(DungeonState.Room::boss).filter(Objects::nonNull).anyMatch(boss->boss.character()!=null))throw new IllegalArgumentException("Recover character boss custody first");return result(new DungeonState(state.dungeons().stream().filter(v->!v.id().equals(id)).toList(),state.runs().stream().filter(v->!v.dungeon().equals(id)).toList()),found);});}
|
||||
public CompletableFuture<DungeonState.Dungeon> setEntrance(UUID id,double x,double y,double z,float yaw,float pitch){return change(id,dungeon->definition(dungeon,dungeon.rooms(),new DungeonState.Position(x,y,z,yaw,pitch),false));}
|
||||
public CompletableFuture<DungeonState.Room> addCornerRoom(UUID id,String name,int x,int y,int z,int xSize,int zSize,int height){if(xSize==0||Math.abs((long)xSize)>256||zSize==0||Math.abs((long)zSize)>256||height<1||height>128)return CompletableFuture.failedFuture(new IllegalArgumentException("Room dimensions out of range"));int maxX=x+(xSize>0?xSize-1:0),minX=x+(xSize<0?xSize+1:0),maxZ=z+(zSize>0?zSize-1:0),minZ=z+(zSize<0?zSize+1:0);return addRoom(id,name,new DungeonState.Bounds(Math.min(x,minX),y,Math.min(z,minZ),Math.max(x,maxX),y+height-1,Math.max(z,maxZ)));}
|
||||
@@ -34,7 +36,7 @@ public final class DungeonDirectory implements AutoCloseable,AdventureResetParti
|
||||
public CompletableFuture<Optional<DungeonState.Spawn>> removeMob(UUID id,UUID roomId,UUID spawn){var found=room(id,roomId).flatMap(value->value.mobs().stream().filter(v->v.id().equals(spawn)).findFirst());if(found.isEmpty())return CompletableFuture.completedFuture(Optional.empty());return updateRoom(id,roomId,room->new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs().stream().filter(v->!v.id().equals(spawn)).toList(),room.effects(),room.reward(),room.boss()),found);}
|
||||
public CompletableFuture<DungeonState.Effect> addEffect(UUID id,UUID roomId,DungeonState.EffectTarget target,String effect,int level,boolean particles){var value=new DungeonState.Effect(UUID.randomUUID(),target,effect,level,particles);return updateRoom(id,roomId,room->{var effects=new ArrayList<>(room.effects());if(effects.size()>=32)throw new IllegalArgumentException("Effect limit reached");effects.add(value);return new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),effects,room.reward(),room.boss());},value);}
|
||||
public CompletableFuture<Optional<DungeonState.Effect>> removeEffect(UUID id,UUID roomId,UUID effect){var found=room(id,roomId).flatMap(value->value.effects().stream().filter(v->v.id().equals(effect)).findFirst());if(found.isEmpty())return CompletableFuture.completedFuture(Optional.empty());return updateRoom(id,roomId,room->new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects().stream().filter(v->!v.id().equals(effect)).toList(),room.reward(),room.boss()),found);}
|
||||
public CompletableFuture<DungeonState.Reward> setReward(UUID id,UUID roomId,BigInteger xp,BigInteger coins){var value=new DungeonState.Reward(xp,coins);return updateRoom(id,roomId,room->new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects(),value,room.boss()),value);}
|
||||
public CompletableFuture<DungeonState.Reward> setCompletionReward(UUID id,BigInteger xp,BigInteger coins){var value=new DungeonState.Reward(xp,coins);return mutate(state->{var dungeon=editable(state,id);var changed=new DungeonState.Dungeon(dungeon.id(),dungeon.name(),dungeon.world(),dungeon.entrance(),false,UUID.randomUUID(),dungeon.rooms(),dungeon.participationMode(),value,true);return result(replace(state,changed),value);});}
|
||||
public CompletableFuture<DungeonState.Boss> setBoss(UUID id,UUID roomId,EntityType type,String name,double x,double y,double z,float yaw,float pitch){var value=new DungeonState.Boss(type,name,new DungeonState.Position(x,y,z,yaw,pitch));return updateRoom(id,roomId,room->{if(room.boss()!=null&&room.boss().character()!=null)throw new IllegalArgumentException("Recover character boss custody first");return new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects(),room.reward(),value);},value);}
|
||||
public CompletableFuture<Boolean> clearBoss(UUID id,UUID roomId){return updateRoom(id,roomId,room->{if(room.boss()!=null&&room.boss().character()!=null)throw new IllegalArgumentException("Recover character boss custody first");return new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects(),room.reward(),null);},true);}
|
||||
public CompletableFuture<DungeonState.CharacterBoss> assignCharacterBoss(UUID id,UUID roomId,CharacterRecord character,BigDecimal multiplier){Objects.requireNonNull(character);var template=new DungeonState.CharacterBoss(UUID.randomUUID(),character,multiplier,null);return mutate(state->{var dungeon=editable(state,id);if(state.dungeons().stream().flatMap(v->v.rooms().stream()).map(DungeonState.Room::boss).filter(Objects::nonNull).map(DungeonState.Boss::character).filter(Objects::nonNull).anyMatch(custody->custody.character().id().equals(character.id())))throw new IllegalArgumentException("Character already assigned");var room=dungeon.rooms().stream().filter(v->v.id().equals(roomId)).findFirst().orElseThrow(()->new IllegalArgumentException("Room not found"));if(room.boss()==null||room.boss().character()!=null)throw new IllegalArgumentException("Configure one native boss room first");var value=new DungeonState.CharacterBoss(template.operation(),character,template.multiplier(),room.boss().type(),room.boss().name(),null);var boss=new DungeonState.Boss(EntityType.VINDICATOR,character.name(),room.boss().position(),value);var updated=new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects(),room.reward(),boss);var rooms=dungeon.rooms().stream().map(v->v.id().equals(roomId)?updated:v).toList();return result(replace(state,definition(dungeon,rooms,dungeon.entrance(),false)),value);});}
|
||||
@@ -57,9 +59,9 @@ public final class DungeonDirectory implements AutoCloseable,AdventureResetParti
|
||||
private static DungeonState.Dungeon editable(DungeonState state,UUID id){var value=find(state,id).orElseThrow(()->new IllegalArgumentException("Dungeon not found"));if(value.enabled())throw new IllegalArgumentException("Dungeon must be disabled");return value;}
|
||||
private static Optional<DungeonState.Dungeon> find(DungeonState state,UUID id){return state.dungeons().stream().filter(v->v.id().equals(id)).findFirst();}
|
||||
private static DungeonState replace(DungeonState state,DungeonState.Dungeon dungeon){var values=state.dungeons().stream().map(v->v.id().equals(dungeon.id())?dungeon:v).toList();return new DungeonState(values,state.runs());}
|
||||
private static DungeonState.Dungeon definition(DungeonState.Dungeon original,List<DungeonState.Room> rooms,DungeonState.Position entrance,boolean enabled){return new DungeonState.Dungeon(original.id(),original.name(),original.world(),entrance,enabled,UUID.randomUUID(),rooms);}
|
||||
private static DungeonState.Dungeon definition(DungeonState.Dungeon original,List<DungeonState.Room> rooms,DungeonState.Position entrance,boolean enabled){return new DungeonState.Dungeon(original.id(),original.name(),original.world(),entrance,enabled,UUID.randomUUID(),rooms,original.participationMode(),original.completionReward(),original.rewardConfigured());}
|
||||
public boolean eligibleForFutureOffer(UUID id){var dungeon=find(id).orElse(null);if(dungeon==null||!dungeon.enabled())return false;try{validate(dungeon);return true;}catch(IllegalArgumentException invalid){return false;}}
|
||||
private void validate(DungeonState.Dungeon dungeon){if(overlapping(dungeon.rooms()))throw new IllegalArgumentException("Dungeon rooms overlap");if(dungeon.rooms().stream().map(DungeonState.Room::boss).filter(Objects::nonNull).map(DungeonState.Boss::character).filter(Objects::nonNull).anyMatch(value->value.recovery()!=null))throw new IllegalArgumentException("Character recovery is pending");if(dungeon.rooms().isEmpty()||!connected(dungeon.rooms(),linkDistance()))throw new IllegalArgumentException("Dungeon rooms are incomplete");if(dungeon.rooms().stream().filter(v->v.boss()!=null).count()!=1)throw new IllegalArgumentException("Dungeon needs exactly one boss");}
|
||||
private void validate(DungeonState.Dungeon dungeon){if(!dungeon.rewardConfigured())throw new IllegalArgumentException("Dungeon completion reward is not configured");if(overlapping(dungeon.rooms()))throw new IllegalArgumentException("Dungeon rooms overlap");if(dungeon.rooms().stream().map(DungeonState.Room::boss).filter(Objects::nonNull).map(DungeonState.Boss::character).filter(Objects::nonNull).anyMatch(value->value.recovery()!=null))throw new IllegalArgumentException("Character recovery is pending");if(dungeon.rooms().isEmpty()||!connected(dungeon.rooms(),linkDistance()))throw new IllegalArgumentException("Dungeon rooms are incomplete");if(dungeon.rooms().stream().filter(v->v.boss()!=null).count()!=1)throw new IllegalArgumentException("Dungeon needs exactly one boss");}
|
||||
private int linkDistance(){int value=roomLinkDistance.getAsInt();if(value<1||value>256)throw new IllegalArgumentException("Invalid dungeon room link distance");return value;}
|
||||
private static boolean overlapping(List<DungeonState.Room> rooms){for(int left=0;left<rooms.size();left++)for(int right=left+1;right<rooms.size();right++)if(rooms.get(left).bounds().overlaps(rooms.get(right).bounds()))return true;return false;}
|
||||
static boolean connected(List<DungeonState.Room> rooms,int maximum){if(rooms.size()<2)return true;var reached=new HashSet<UUID>();var queue=new ArrayDeque<DungeonState.Room>();queue.add(rooms.getFirst());reached.add(rooms.getFirst().id());while(!queue.isEmpty()){var current=queue.remove();for(var room:rooms)if(!reached.contains(room.id())&¤t.bounds().withinDistance(room.bounds(),maximum)){reached.add(room.id());queue.add(room);}}return reached.size()==rooms.size();}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package games.dmg.fantasystick.dungeon;
|
||||
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestState;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.*;
|
||||
@@ -45,10 +46,13 @@ public record DungeonState(List<Dungeon> dungeons,List<Run> runs) {
|
||||
public Room {Objects.requireNonNull(id);name=validName(name);Objects.requireNonNull(bounds);mobs=List.copyOf(mobs);effects=List.copyOf(effects);Objects.requireNonNull(reward);unique(mobs.stream().map(Spawn::id).toList());unique(effects.stream().map(Effect::id).toList());if(mobs.size()>256||effects.size()>32)throw new IllegalArgumentException("Room limit exceeded");}
|
||||
public String displayName(){return name.isBlank()?"Room":name;}
|
||||
}
|
||||
public record Dungeon(UUID id,String name,UUID world,Position entrance,boolean enabled,UUID revision,List<Room> rooms) {
|
||||
public Dungeon {Objects.requireNonNull(id);name=validName(name);if(name.isBlank())throw new IllegalArgumentException("Dungeon name required");Objects.requireNonNull(world);Objects.requireNonNull(entrance);Objects.requireNonNull(revision);rooms=List.copyOf(rooms);unique(rooms.stream().map(Room::id).toList());if(rooms.size()>128)throw new IllegalArgumentException("Dungeon room limit exceeded");}
|
||||
public BigInteger maximumXp(){return rooms.stream().map(v->v.reward().xp()).reduce(BigInteger.ZERO,BigInteger::add);}
|
||||
public BigInteger maximumCoins(){return rooms.stream().map(v->v.reward().coins()).reduce(BigInteger.ZERO,BigInteger::add);}
|
||||
public record Dungeon(UUID id,String name,UUID world,Position entrance,boolean enabled,UUID revision,List<Room> rooms,AdventureQuestState.ParticipationMode participationMode,Reward completionReward,boolean rewardConfigured) {
|
||||
public Dungeon {Objects.requireNonNull(id);name=validName(name);if(name.isBlank())throw new IllegalArgumentException("Dungeon name required");Objects.requireNonNull(world);Objects.requireNonNull(entrance);Objects.requireNonNull(revision);rooms=List.copyOf(rooms);Objects.requireNonNull(participationMode);Objects.requireNonNull(completionReward);unique(rooms.stream().map(Room::id).toList());if(rooms.size()>128)throw new IllegalArgumentException("Dungeon room limit exceeded");}
|
||||
public Dungeon(UUID id,String name,UUID world,Position entrance,boolean enabled,UUID revision,List<Room> rooms,AdventureQuestState.ParticipationMode mode){this(id,name,world,entrance,enabled,revision,rooms,mode,legacyReward(rooms),true);}
|
||||
public Dungeon(UUID id,String name,UUID world,Position entrance,boolean enabled,UUID revision,List<Room> rooms){this(id,name,world,entrance,enabled,revision,rooms,AdventureQuestState.ParticipationMode.PARTY_AVAILABLE);}
|
||||
public BigInteger maximumXp(){return completionReward.xp();}
|
||||
public BigInteger maximumCoins(){return completionReward.coins();}
|
||||
private static Reward legacyReward(List<Room> rooms){return rooms.stream().filter(room->room.boss()!=null).map(Room::reward).findFirst().orElse(Reward.empty());}
|
||||
}
|
||||
public record UnitClaim(UUID room,UUID quest) {public UnitClaim {Objects.requireNonNull(room);Objects.requireNonNull(quest);}}
|
||||
public record CharacterClaim(UUID room,UUID character) {public CharacterClaim {Objects.requireNonNull(room);Objects.requireNonNull(character);}}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.Location;
|
||||
|
||||
/** Stable feature-owned character-book outcomes; event delivery never authorizes or rolls back an open. */
|
||||
public final class BookEventBridge {
|
||||
public record Request(UUID actionId, EventInput.Context context, String actionKind, String viewKind,
|
||||
int offset, BigInteger requestedRevision) {
|
||||
public Request {
|
||||
Objects.requireNonNull(actionId); Objects.requireNonNull(context); Objects.requireNonNull(actionKind);
|
||||
Objects.requireNonNull(viewKind); Objects.requireNonNull(requestedRevision);
|
||||
if (offset < -1) throw new IllegalArgumentException("Invalid book offset");
|
||||
}
|
||||
}
|
||||
|
||||
private final Consumer<EventInput> sink;
|
||||
private final Clock clock;
|
||||
|
||||
public BookEventBridge(Consumer<EventInput> sink, Clock clock) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
}
|
||||
|
||||
public Request request(ControlInput input, CharacterRecord current, int offset) {
|
||||
Objects.requireNonNull(input); Objects.requireNonNull(current);
|
||||
var request = new Request(UUID.randomUUID(), context(input, current.name()),
|
||||
offset == 0 ? "character_book_open" : "character_book_navigate", "active", offset,
|
||||
current.revision());
|
||||
emit("action.observed", request, "observed", null, null, null, null);
|
||||
return request;
|
||||
}
|
||||
|
||||
public void refused(Request request, String reason) {
|
||||
emit("action.resolved", request, "refused", reason, null, null, null);
|
||||
}
|
||||
|
||||
public void refused(org.bukkit.entity.Player player, games.dmg.fantasystick.session.EquipmentSessions.Active active,
|
||||
CharacterRecord current, String actionKind, String reason) {
|
||||
Objects.requireNonNull(player); Objects.requireNonNull(active); Objects.requireNonNull(current);
|
||||
var request=new Request(UUID.randomUUID(),context(player,active,current.name()),actionKind,"active",-1,current.revision());
|
||||
emit("action.observed",request,"observed",null,null,null,null);
|
||||
refused(request,reason);
|
||||
}
|
||||
|
||||
public void failed(Request request, String reason) {
|
||||
emit("action.resolved", request, "failed", reason, null, null, null);
|
||||
}
|
||||
|
||||
public void opened(Request request, CharacterRecord loaded, int presentedPages, int presentedVolumes) {
|
||||
Objects.requireNonNull(loaded);
|
||||
emit("action.resolved", request, "accepted", "acknowledged", loaded.revision(), presentedPages,
|
||||
presentedVolumes);
|
||||
emit(request.offset() == 0 ? "character_book.opened" : "character_book.navigated", request,
|
||||
"accepted", "acknowledged", loaded.revision(), presentedPages, presentedVolumes);
|
||||
}
|
||||
|
||||
private void emit(String type, Request request, String outcome, String reason, BigInteger loadedRevision,
|
||||
Integer pages, Integer volumes) {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("action_kind", request.actionKind()); data.put("view_kind", request.viewKind());
|
||||
data.put("section", "character_inspection"); data.put("control_kind", "book"); data.put("control_slot", "0");
|
||||
data.put("offset", request.offset()<0?"unknown":Integer.toString(request.offset()));
|
||||
data.put("requested_revision", request.requestedRevision().toString()); data.put("outcome", outcome);
|
||||
if (reason != null) data.put(type.startsWith("character_book.") ? "native_open" : "reason", reason);
|
||||
if (loadedRevision != null) data.put("loaded_revision", loadedRevision.toString());
|
||||
if (pages != null) data.put("presented_pages", pages);
|
||||
if (volumes != null) data.put("presented_volumes", volumes);
|
||||
try {
|
||||
sink.accept(new EventInput(id(request.actionId(), type), request.actionId(), clock.instant(), type,
|
||||
request.context(), Map.copyOf(data)));
|
||||
} catch (RuntimeException ignored) {
|
||||
// Observability cannot change book authorization, rendering or native opening.
|
||||
}
|
||||
}
|
||||
|
||||
private EventInput.Context context(ControlInput input, String characterName) {
|
||||
return context(input.player(),input.session(),characterName);
|
||||
}
|
||||
|
||||
private EventInput.Context context(org.bukkit.entity.Player player,
|
||||
games.dmg.fantasystick.session.EquipmentSessions.Active active,
|
||||
String characterName) {
|
||||
return new EventInput.Context(player.getUniqueId(), player.getName(), active.character(),
|
||||
characterName, active.session(), location(player.getLocation()));
|
||||
}
|
||||
|
||||
private EventInput.Location location(Location location) {
|
||||
return new EventInput.Location(location.getWorld().getUID(), location.getWorld().getName(), location.getX(),
|
||||
location.getY(), location.getZ(), clock.instant(), "exact");
|
||||
}
|
||||
|
||||
private static UUID id(UUID action, String type) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:book:" + action + ":" + type)
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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,163 @@
|
||||
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<>());
|
||||
|
||||
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);}
|
||||
@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,458 @@
|
||||
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 data.put("actor_kind", "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,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));}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package games.dmg.fantasystick.items;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
@@ -17,6 +18,7 @@ public final class CharacterSheets {
|
||||
public static final NamespacedKey MODEL = new NamespacedKey("fantasystick", "character_sheet");
|
||||
private static final NamespacedKey VERSION = new NamespacedKey("fantasystick", "sheet_version");
|
||||
private static final NamespacedKey CHARACTER = new NamespacedKey("fantasystick", "character_id");
|
||||
private static final NamespacedKey GENERATION = new NamespacedKey("fantasystick", "sheet_generation");
|
||||
private static final NamespacedKey RECOVERY = new NamespacedKey("fantasystick", "boss_recovery_operation");
|
||||
private static final List<Component> INSTRUCTION = List.of(Component.text("Character sheet:"),
|
||||
Component.text("Place in off hand"), Component.text("to enter fantasy world."));
|
||||
@@ -28,6 +30,7 @@ public final class CharacterSheets {
|
||||
var data = meta.getPersistentDataContainer();
|
||||
data.set(VERSION, PersistentDataType.INTEGER, 1);
|
||||
data.set(CHARACTER, PersistentDataType.STRING, character.id().toString());
|
||||
data.set(GENERATION, PersistentDataType.STRING, character.sheetGeneration().toString());
|
||||
meta.setItemModel(MODEL);
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
@@ -65,6 +68,27 @@ public final class CharacterSheets {
|
||||
return true;
|
||||
}
|
||||
|
||||
public record Identity(UUID character, BigInteger generation, boolean legacy) { }
|
||||
|
||||
public Optional<Identity> identity(ItemStack item) {
|
||||
var character = identify(item);
|
||||
if (character.isEmpty()) return Optional.empty();
|
||||
var data = item.getItemMeta().getPersistentDataContainer();
|
||||
if (!data.has(GENERATION, PersistentDataType.STRING))
|
||||
return Optional.of(new Identity(character.orElseThrow(), BigInteger.ZERO, true));
|
||||
try {
|
||||
var generation = new BigInteger(data.get(GENERATION, PersistentDataType.STRING));
|
||||
if (generation.signum() < 0) return Optional.empty();
|
||||
return Optional.of(new Identity(character.orElseThrow(), generation, false));
|
||||
} catch (RuntimeException invalid) { return Optional.empty(); }
|
||||
}
|
||||
|
||||
public boolean authorizes(ItemStack item, CharacterRecord record) {
|
||||
return identity(item).filter(value -> value.character().equals(record.id())
|
||||
&& value.generation().equals(record.sheetGeneration())
|
||||
&& (!value.legacy() || record.sheetGeneration().signum() == 0)).isPresent();
|
||||
}
|
||||
|
||||
/** A reference, not authorization to activate: the caller must load saved state and claim the session. */
|
||||
public Optional<UUID> identify(ItemStack item) {
|
||||
if (item == null || item.getType() != Material.PAPER || !item.hasItemMeta()) return Optional.empty();
|
||||
|
||||
@@ -145,7 +145,7 @@ public final class SessionControls {
|
||||
|
||||
private boolean validKind(Material material, int slot, PersistentDataContainer data) {
|
||||
String kind = data.get(KIND, PersistentDataType.STRING);
|
||||
if (slot == 0) return kind.equals("book") && (material == Material.BOOK || material == Material.COMPASS) && !data.has(ABILITY);
|
||||
if (slot == 0) return kind.equals("book") && (material == Material.BOOK || material == Material.COMPASS || material == Material.FISHING_ROD) && !data.has(ABILITY);
|
||||
if (slot == 1) return kind.equals("basic_attack") && material == Material.STICK && !data.has(ABILITY);
|
||||
if (kind.equals("empty")) return material == Material.GRAY_DYE && !data.has(ABILITY);
|
||||
if (!data.has(ABILITY, PersistentDataType.STRING)) return false;
|
||||
|
||||
@@ -12,11 +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;
|
||||
@@ -74,6 +77,8 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
private final java.util.concurrent.ConcurrentLinkedQueue<UUID> retries = new java.util.concurrent.ConcurrentLinkedQueue<>();
|
||||
private org.bukkit.scheduler.BukkitTask retryTask;
|
||||
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 boolean closed;
|
||||
|
||||
private final class PendingAward {
|
||||
@@ -84,7 +89,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
final CombatRewardRules.KillPosition restricted;
|
||||
final CombatRewardRules rewardRules;
|
||||
BigInteger expectedRevision;
|
||||
boolean running;
|
||||
boolean running;final CompletableFuture<Void> settled=new CompletableFuture<>();
|
||||
PendingAward(UUID id, UUID character, BigInteger experience, BigInteger coins,
|
||||
CombatRewardRules.KillPosition restricted, CombatRewardRules rewardRules) {
|
||||
this.id = id; this.character = character; this.experience = experience;
|
||||
@@ -131,6 +136,11 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
excluded = Objects.requireNonNull(predicate);
|
||||
}
|
||||
|
||||
public void attachManagedOrigins(java.util.function.Function<LivingEntity,java.util.Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin>> classifier){
|
||||
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 install(org.bukkit.plugin.Plugin plugin) {
|
||||
if (closed || retryTask != null) throw new IllegalStateException("Combat experience is already installed or closed");
|
||||
retryTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::retryPending, 20, 20);
|
||||
@@ -185,8 +195,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 xp=games.dmg.fantasystick.progression.RewardAllocator.allocate(capturedRewards.pool(CombatRewardRules.Target.FANTASY_CHARACTER,attribution.victimLevel()),units,replaySafeRandom);var coins=games.dmg.fantasystick.progression.RewardAllocator.allocate(attribution.victimCoins(),units,replaySafeRandom);return xp.keySet().stream().sorted().map(character->new games.dmg.fantasystick.session.DownedEpisode.Reward(character,xp.get(character),coins.get(character))).toList();
|
||||
}
|
||||
|
||||
/** Apply only the shares frozen in the winning durable final-death claim. */
|
||||
public CompletionStage<Void> settleDownedDefeat(games.dmg.fantasystick.session.DownedEpisode episode){
|
||||
if(episode.finalRewards().isEmpty())return CompletableFuture.completedStage(null);var capturedRewards=rewards;var settlements=new ArrayList<CompletableFuture<Void>>();for(var reward:episode.finalRewards()){UUID receipt=UUID.nameUUIDFromBytes(("downed:"+episode.id()+":"+reward.character()).getBytes(java.nio.charset.StandardCharsets.UTF_8));var proposed=new PendingAward(receipt,reward.character(),reward.experience(),reward.coins(),null,capturedRewards);var existing=pending.putIfAbsent(receipt,proposed);var operation=existing==null?proposed:existing;settlements.add(operation.settled);attempt(operation);}return CompletableFuture.allOf(settlements.toArray(CompletableFuture[]::new));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void mobDeath(EntityDeathEvent event) {
|
||||
@@ -204,27 +231,31 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
private void award(LivingEntity target, CombatRewardRules.Target kind, int level, UUID defeatedCharacter,
|
||||
BigInteger defeatedCoins) {
|
||||
Instant now = clock.instant();
|
||||
java.util.Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin> managed;try{managed=managedOrigins.apply(target);}catch(RuntimeException invalid){managed=java.util.Optional.empty();}
|
||||
var direct = new ArrayList<>(contributions.consumeDirect(target.getUniqueId(), now));
|
||||
if (defeatedCharacter != null) direct.remove(defeatedCharacter);
|
||||
if (direct.isEmpty()) return;
|
||||
if (direct.isEmpty()) {managed.ifPresent(this::settled);return;}
|
||||
var units = rewardUnits(direct);
|
||||
BigInteger coinPool = defeatedCharacter == null
|
||||
? coinRules.get().mob(coinTarget(target), random) : defeatedCoins;
|
||||
if (units.isEmpty()) return; // The defeat transition has already persisted any PvP balance loss.
|
||||
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 coins = games.dmg.fantasystick.progression.RewardAllocator.allocate(coinPool, units, random);
|
||||
CombatRewardRules.KillPosition restricted = kind == CombatRewardRules.Target.PASSIVE
|
||||
CombatRewardRules.KillPosition restricted = managed.isPresent()||kind == CombatRewardRules.Target.PASSIVE
|
||||
|| kind == CombatRewardRules.Target.FANTASY_CHARACTER ? null : position(target.getLocation());
|
||||
UUID targetReward = defeatedCharacter == null ? target.getUniqueId() : defeatedCharacter;
|
||||
String authority=managed.map(value->value.kind()+":"+value.source()).orElse("ordinary");
|
||||
var settlements=new ArrayList<CompletableFuture<Void>>();
|
||||
for (UUID character : xp.keySet()) {
|
||||
UUID receipt = UUID.nameUUIDFromBytes((targetReward + ":" + character + ":" + now).getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
var operation = new PendingAward(receipt, character, xp.get(character), coins.get(character), restricted,
|
||||
capturedRewards);
|
||||
pending.put(operation.id, operation);
|
||||
attempt(operation);
|
||||
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 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)));
|
||||
}
|
||||
private void settled(games.dmg.fantasystick.progression.ManagedEncounterOrigin origin){try{managedSettlement.accept(origin);}catch(RuntimeException ignored){}}
|
||||
|
||||
private java.util.List<games.dmg.fantasystick.progression.RewardAllocator.Unit<UUID>> rewardUnits(
|
||||
java.util.List<UUID> direct) {
|
||||
@@ -272,7 +303,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
characters.find(operation.character).whenComplete((record, failure) -> {
|
||||
synchronized (operation) { operation.running = false; }
|
||||
if (failure != null) { fail(operation, failure); return; }
|
||||
if (record.isEmpty() || record.orElseThrow().dead()) { pending.remove(operation.id); return; }
|
||||
if (record.isEmpty() || record.orElseThrow().dead()) { complete(operation); return; }
|
||||
operation.expectedRevision = record.orElseThrow().revision();
|
||||
attempt(operation);
|
||||
});
|
||||
@@ -285,13 +316,14 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
if (award.status() == CharacterStorage.CombatAwardStatus.STALE && award.record().isPresent()) {
|
||||
operation.expectedRevision = award.record().orElseThrow().revision(); attempt(operation); return;
|
||||
}
|
||||
pending.remove(operation.id);
|
||||
complete(operation);
|
||||
if (award.record().isEmpty() || award.status() == CharacterStorage.CombatAwardStatus.UNAVAILABLE) return;
|
||||
try { nextTick.accept(() -> acknowledge(operation, award)); }
|
||||
catch (RuntimeException rejected) { report(operation.character, rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void complete(PendingAward operation){pending.remove(operation.id,operation);operation.settled.complete(null);}
|
||||
private void fail(PendingAward operation, Throwable failure) {
|
||||
report(operation.character, failure);
|
||||
if (!closed && pending.containsKey(operation.id)) retries.offer(operation.id);
|
||||
|
||||
@@ -20,6 +20,7 @@ public final class PartyRoster implements AutoCloseable {
|
||||
public int maximumSize(){int value=maximumSize.getAsInt();if(value<1)throw new IllegalStateException("Party maximum must be positive");return value;}
|
||||
public Optional<Party> create(UUID player){if(closed)return Optional.empty();if(durable!=null){if(!durable.ready())return Optional.empty();var character=character(player);if(character==null)return Optional.empty();var result=durable.create(character,"Party "+character.toString().substring(0,8)).join();return result.party().flatMap(this::project);}if(closed||!connected(player)||membership.containsKey(player))return Optional.empty();var party=new Party(UUID.randomUUID(),player,List.of(player));membership.put(player,party);return Optional.of(party);}
|
||||
public Optional<Party> partyOf(UUID player){if(durable!=null){if(!durable.ready())return Optional.empty();var character=character(player);return character==null?Optional.empty():durable.partyOf(character).flatMap(this::project);}return Optional.ofNullable(membership.get(player));}
|
||||
public Optional<Party> partyOfCharacter(UUID character){if(durable!=null)return durable.ready()?durable.partyOf(character).flatMap(this::project):Optional.empty();return Optional.ofNullable(membership.get(character));}
|
||||
public Optional<RewardMembership> rewardMembership(UUID player){if(durable!=null){if(!durable.ready())return Optional.empty();var character=character(player);if(character==null)return Optional.empty();return durable.partyOf(character).map(party->new RewardMembership(party.id(),activePlayers(party)));}var party=membership.get(player);return party==null?Optional.empty():Optional.of(new RewardMembership(party.id(),party.members().stream().filter(this::connected).toList()));}
|
||||
public List<Invitation> invitationsFor(UUID player){if(durable!=null){if(!durable.ready())return List.of();var character=character(player);if(character==null)return List.of();return durable.invitationsFor(character).stream().map(value->new Invitation(value.id(),value.party(),new UUID(0,0),player,Instant.MAX)).toList();}expire();return invitations.values().stream().filter(i->i.invitee().equals(player)).sorted(Comparator.comparing(Invitation::expires).thenComparing(Invitation::id)).toList();}
|
||||
public Optional<Invitation> invite(UUID leader,UUID party,UUID invitee){if(closed)return Optional.empty();if(durable!=null){if(!durable.ready())return Optional.empty();var source=character(leader);var target=character(invitee);if(source==null||target==null)return Optional.empty();var result=durable.invite(source,party,target).join();return result.invitation().map(value->new Invitation(value.id(),value.party(),leader,invitee,Instant.MAX));}expire();var group=managedBy(leader,party);if(group==null||group.members().size()>=maximumSize()||!connected(invitee)||membership.containsKey(invitee))return Optional.empty();var existing=invitations.values().stream().filter(i->i.party().equals(party)&&i.invitee().equals(invitee)).findFirst();if(existing.isPresent())return existing;var invitation=new Invitation(UUID.randomUUID(),party,leader,invitee,clock.instant().plusSeconds(60));invitations.put(invitation.id(),invitation);return Optional.of(invitation);}
|
||||
|
||||
@@ -20,21 +20,31 @@ public final class AdventureQuestSettings implements AutoCloseable {
|
||||
CAMP_ENEMIES="adventure-camp-enemies",
|
||||
CAMP_REWARD_XP="adventure-camp-reward-xp", CAMP_REWARD_COINS="adventure-camp-reward-coins",
|
||||
CHAMPION_HEALTH="adventure-champion-health", CHAMPION_DAMAGE="adventure-champion-damage",
|
||||
CHAMPION_REWARD_XP="adventure-champion-reward-xp", CHAMPION_REWARD_COINS="adventure-champion-reward-coins";
|
||||
CHAMPION_REWARD_XP="adventure-champion-reward-xp", CHAMPION_REWARD_COINS="adventure-champion-reward-coins",
|
||||
FISHING_CATCH_XP_MIN="fishing-catch-xp-min", FISHING_CATCH_XP_MAX="fishing-catch-xp-max";
|
||||
private static final Map<String,String> DEFAULTS=Map.ofEntries(
|
||||
Map.entry(DAILY_COUNT,"3"),Map.entry(RADIUS,"16"),Map.entry(RESET_SECONDS,"30"),
|
||||
Map.entry(DUNGEON_ROOM_LINK_DISTANCE,"10"),Map.entry(CAMP_ENEMIES,"8"),
|
||||
Map.entry(CAMP_REWARD_XP,"100"),Map.entry(CAMP_REWARD_COINS,"20"),Map.entry(CHAMPION_HEALTH,"100"),
|
||||
Map.entry(CHAMPION_DAMAGE,"8"),Map.entry(CHAMPION_REWARD_XP,"150"),Map.entry(CHAMPION_REWARD_COINS,"40"));
|
||||
Map.entry(CHAMPION_DAMAGE,"8"),Map.entry(CHAMPION_REWARD_XP,"150"),Map.entry(CHAMPION_REWARD_COINS,"40"),
|
||||
Map.entry(FISHING_CATCH_XP_MIN,"1"),Map.entry(FISHING_CATCH_XP_MAX,"3"));
|
||||
private static final List<String> KEYS=DEFAULTS.keySet().stream().sorted().toList();
|
||||
public record Rules(int dailySiteCount,int radius,int resetSeconds,int campEnemies,
|
||||
BigInteger campRewardXp,BigInteger campRewardCoins,int championHealth,int championDamage,
|
||||
BigInteger championRewardXp,BigInteger championRewardCoins,int dungeonRoomLinkDistance) {
|
||||
BigInteger championRewardXp,BigInteger championRewardCoins,int dungeonRoomLinkDistance,
|
||||
BigInteger fishingCatchXpMin,BigInteger fishingCatchXpMax) {
|
||||
public Rules {Objects.requireNonNull(fishingCatchXpMin);Objects.requireNonNull(fishingCatchXpMax);if(fishingCatchXpMin.signum()<0||fishingCatchXpMax.compareTo(BigInteger.valueOf(Integer.MAX_VALUE))>0||fishingCatchXpMin.compareTo(fishingCatchXpMax)>0)throw new IllegalArgumentException("Invalid fishing catch XP range");}
|
||||
public Rules(int dailySiteCount,int radius,int resetSeconds,int campEnemies,
|
||||
BigInteger campRewardXp,BigInteger campRewardCoins,int championHealth,int championDamage,
|
||||
BigInteger championRewardXp,BigInteger championRewardCoins) {
|
||||
this(dailySiteCount,radius,resetSeconds,campEnemies,campRewardXp,campRewardCoins,championHealth,
|
||||
championDamage,championRewardXp,championRewardCoins,10);
|
||||
championDamage,championRewardXp,championRewardCoins,10,BigInteger.ONE,BigInteger.valueOf(3));
|
||||
}
|
||||
public Rules(int dailySiteCount,int radius,int resetSeconds,int campEnemies,
|
||||
BigInteger campRewardXp,BigInteger campRewardCoins,int championHealth,int championDamage,
|
||||
BigInteger championRewardXp,BigInteger championRewardCoins,int dungeonRoomLinkDistance) {
|
||||
this(dailySiteCount,radius,resetSeconds,campEnemies,campRewardXp,campRewardCoins,championHealth,
|
||||
championDamage,championRewardXp,championRewardCoins,dungeonRoomLinkDistance,BigInteger.ONE,BigInteger.valueOf(3));
|
||||
}
|
||||
}
|
||||
public record Update(String key,String value,Map<String,String> values,long sequence) {
|
||||
@@ -64,7 +74,8 @@ public final class AdventureQuestSettings implements AutoCloseable {
|
||||
whole(values.get(CAMP_REWARD_XP)),whole(values.get(CAMP_REWARD_COINS)),
|
||||
bounded(values.get(CHAMPION_HEALTH),1,2048),bounded(values.get(CHAMPION_DAMAGE),1,2048),
|
||||
whole(values.get(CHAMPION_REWARD_XP)),whole(values.get(CHAMPION_REWARD_COINS)),
|
||||
bounded(values.get(DUNGEON_ROOM_LINK_DISTANCE),1,256));}
|
||||
bounded(values.get(DUNGEON_ROOM_LINK_DISTANCE),1,256),whole(values.getOrDefault(FISHING_CATCH_XP_MIN,DEFAULTS.get(FISHING_CATCH_XP_MIN))),
|
||||
whole(values.getOrDefault(FISHING_CATCH_XP_MAX,DEFAULTS.get(FISHING_CATCH_XP_MAX))));}
|
||||
catch(RuntimeException invalid){throw new IllegalArgumentException("Invalid adventure quest settings",invalid);}
|
||||
}
|
||||
private static int bounded(String raw,int minimum,int maximum){var value=new BigInteger(raw);if(value.compareTo(BigInteger.valueOf(minimum))<0||value.compareTo(BigInteger.valueOf(maximum))>0)throw new IllegalArgumentException();return value.intValueExact();}
|
||||
|
||||
@@ -19,7 +19,9 @@ public record CharacterRecord(
|
||||
Set<String> learned, List<String> loadout, List<String> passives, Map<String, Instant> cooldowns,
|
||||
Map<UUID, CombatRewardRules.KillPosition> restrictedKills,
|
||||
boolean dead, UUID deathId, Instant diedAt,
|
||||
BigInteger coins, boolean coinsInitialized, Set<UUID> rewardReceipts, BigInteger pvpCoinEscrow,CharacterGear gear) {
|
||||
BigInteger coins, boolean coinsInitialized, Set<UUID> rewardReceipts, BigInteger pvpCoinEscrow,
|
||||
CharacterGear gear, BigInteger sheetGeneration) {
|
||||
public CharacterRecord(UUID id,String name,BigInteger revision,Map<Stat,BigInteger> stats,int level,BigInteger experience,BigInteger unspentPoints,BigDecimal health,Set<String> learned,List<String> loadout,List<String> passives,Map<String,Instant> cooldowns,Map<UUID,CombatRewardRules.KillPosition> restrictedKills,boolean dead,UUID deathId,Instant diedAt,BigInteger coins,boolean coinsInitialized,Set<UUID> rewardReceipts,BigInteger pvpCoinEscrow,CharacterGear gear){this(id,name,revision,stats,level,experience,unspentPoints,health,learned,loadout,passives,cooldowns,restrictedKills,dead,deathId,diedAt,coins,coinsInitialized,rewardReceipts,pvpCoinEscrow,gear,BigInteger.ZERO);}
|
||||
public CharacterRecord(UUID id,String name,BigInteger revision,Map<Stat,BigInteger> stats,int level,BigInteger experience,BigInteger unspentPoints,BigDecimal health,Set<String> learned,List<String> loadout,List<String> passives,Map<String,Instant> cooldowns,Map<UUID,CombatRewardRules.KillPosition> restrictedKills,boolean dead,UUID deathId,Instant diedAt,BigInteger coins,boolean coinsInitialized,Set<UUID> rewardReceipts,BigInteger pvpCoinEscrow){this(id,name,revision,stats,level,experience,unspentPoints,health,learned,loadout,passives,cooldowns,restrictedKills,dead,deathId,diedAt,coins,coinsInitialized,rewardReceipts,pvpCoinEscrow,CharacterGear.none());}
|
||||
public CharacterRecord(UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
|
||||
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
|
||||
@@ -55,6 +57,16 @@ public record CharacterRecord(
|
||||
cooldowns, restrictedKills, dead, deathId, diedAt, BigInteger.ZERO, true, Set.of());
|
||||
}
|
||||
|
||||
public CharacterRecord(UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
|
||||
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
|
||||
Set<String> learned, List<String> loadout, List<String> passives, Map<String, Instant> cooldowns,
|
||||
Map<UUID, CombatRewardRules.KillPosition> restrictedKills,
|
||||
boolean dead, UUID deathId, Instant diedAt, BigInteger sheetGeneration) {
|
||||
this(id, name, revision, stats, level, experience, unspentPoints, health, learned, loadout, passives,
|
||||
cooldowns, restrictedKills, dead, deathId, diedAt, BigInteger.ZERO, true, Set.of(), BigInteger.ZERO,
|
||||
CharacterGear.none(), sheetGeneration);
|
||||
}
|
||||
|
||||
public CharacterRecord {
|
||||
Objects.requireNonNull(id);
|
||||
Objects.requireNonNull(name);
|
||||
@@ -65,6 +77,7 @@ public record CharacterRecord(
|
||||
Objects.requireNonNull(coins);
|
||||
Objects.requireNonNull(pvpCoinEscrow);
|
||||
Objects.requireNonNull(gear);
|
||||
Objects.requireNonNull(sheetGeneration);
|
||||
stats = Map.copyOf(stats);
|
||||
learned = Set.copyOf(learned);
|
||||
loadout = List.copyOf(loadout);
|
||||
@@ -85,7 +98,8 @@ public record CharacterRecord(
|
||||
|| cooldowns.keySet().stream().anyMatch(String::isBlank)
|
||||
|| restrictedKills.size() > 1024
|
||||
|| restrictedKills.entrySet().stream().anyMatch(entry -> !entry.getKey().equals(entry.getValue().world()))
|
||||
|| coins.signum() < 0 || pvpCoinEscrow.signum() < 0 || !coinsInitialized && coins.signum() != 0
|
||||
|| coins.signum() < 0 || pvpCoinEscrow.signum() < 0 || sheetGeneration.signum() < 0
|
||||
|| !coinsInitialized && coins.signum() != 0
|
||||
|| rewardReceipts.size() > 4096 || !dead && pvpCoinEscrow.signum() != 0
|
||||
|| (deathId == null) != (diedAt == null) || dead && deathId == null) {
|
||||
throw new IllegalArgumentException("Invalid character record");
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
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.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.math.BigInteger;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Durable fail-closed authority for one explicitly confirmed saved-sheet recovery. */
|
||||
final class CharacterRecoveryStore {
|
||||
private static final int MAX_FILE_BYTES = 16_384;
|
||||
enum Phase { PREPARED, COMMITTED, DELIVERED }
|
||||
record Operation(UUID operation, UUID character, UUID administrator, UUID recipient,
|
||||
BigInteger expectedRevision, BigInteger expectedGeneration,
|
||||
BigInteger replacementGeneration, boolean revive, UUID death, Phase phase) {
|
||||
Operation {
|
||||
Objects.requireNonNull(operation); Objects.requireNonNull(character);
|
||||
Objects.requireNonNull(administrator); Objects.requireNonNull(recipient);
|
||||
Objects.requireNonNull(expectedRevision); Objects.requireNonNull(expectedGeneration);
|
||||
Objects.requireNonNull(replacementGeneration); Objects.requireNonNull(phase);
|
||||
if (expectedRevision.signum() < 0 || expectedGeneration.signum() < 0
|
||||
|| !replacementGeneration.equals(expectedGeneration.add(BigInteger.ONE))
|
||||
|| revive != (death != null)) throw new IllegalArgumentException("Invalid recovery operation");
|
||||
}
|
||||
Operation phase(Phase value) {
|
||||
return new Operation(operation, character, administrator, recipient, expectedRevision,
|
||||
expectedGeneration, replacementGeneration, revive, death, value);
|
||||
}
|
||||
}
|
||||
|
||||
private final Path directory;
|
||||
CharacterRecoveryStore(Path pluginDataDirectory) {
|
||||
directory = Objects.requireNonNull(pluginDataDirectory).resolve("character-recoveries").toAbsolutePath();
|
||||
}
|
||||
|
||||
Optional<Operation> find(UUID character) throws IOException {
|
||||
if (Files.notExists(directory, LinkOption.NOFOLLOW_LINKS)) return Optional.empty();
|
||||
safeDirectory();
|
||||
Path path = file(character);
|
||||
if (Files.notExists(path, LinkOption.NOFOLLOW_LINKS)) return Optional.empty();
|
||||
if (Files.isSymbolicLink(path) || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)
|
||||
|| Files.size(path) > MAX_FILE_BYTES) throw new IOException("Recovery record is unsafe");
|
||||
return Optional.of(decode(Files.readString(path, StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
/** Mutation predicate: corrupt or unreadable proof is unresolved and therefore blocks. */
|
||||
boolean blocks(UUID character) {
|
||||
try { return find(character).map(operation -> operation.phase() != Phase.DELIVERED).orElse(false); }
|
||||
catch (IOException unsafe) { return true; }
|
||||
}
|
||||
|
||||
void prepare(Operation operation) throws IOException {
|
||||
withLock(() -> {
|
||||
var existing = find(operation.character());
|
||||
if (existing.isPresent() && existing.orElseThrow().phase() != Phase.DELIVERED)
|
||||
throw new IOException("Character recovery is unresolved");
|
||||
write(operation);
|
||||
});
|
||||
}
|
||||
|
||||
void advance(UUID character, UUID operation, Phase expected, Phase replacement) throws IOException {
|
||||
withLock(() -> {
|
||||
var current = find(character).orElseThrow(() -> new IOException("Recovery operation missing"));
|
||||
if (!current.operation().equals(operation) || current.phase() != expected)
|
||||
throw new IOException("Recovery operation changed");
|
||||
write(current.phase(replacement));
|
||||
});
|
||||
}
|
||||
|
||||
void cancelPrepared(UUID character, UUID operation) throws IOException {
|
||||
withLock(() -> {
|
||||
var current = find(character);
|
||||
if (current.isEmpty()) return;
|
||||
if (!current.orElseThrow().operation().equals(operation)
|
||||
|| current.orElseThrow().phase() != Phase.PREPARED)
|
||||
throw new IOException("Recovery operation cannot be cancelled");
|
||||
Files.delete(file(character));
|
||||
DurableFiles.forceDirectory(directory);
|
||||
});
|
||||
}
|
||||
|
||||
private void write(Operation operation) throws IOException {
|
||||
safeDirectory();
|
||||
byte[] bytes = encode(operation).getBytes(StandardCharsets.UTF_8);
|
||||
Path temporary = Files.createTempFile(directory, ".recovery-", ".tmp");
|
||||
try {
|
||||
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(operation.character()), StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING);
|
||||
DurableFiles.forceDirectory(directory);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
}
|
||||
|
||||
private void withLock(IoAction action) throws IOException {
|
||||
safeDirectory();
|
||||
Path lockPath = directory.resolve(".recoveries.lock");
|
||||
try (var channel = FileChannel.open(lockPath, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
|
||||
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
|
||||
if (lock == null) throw new IOException("Recovery store busy");
|
||||
action.run();
|
||||
} catch (OverlappingFileLockException failure) { throw new IOException("Recovery store busy", failure); }
|
||||
}
|
||||
|
||||
private void safeDirectory() throws IOException {
|
||||
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS))
|
||||
throw new IOException("Recovery directory is unsafe");
|
||||
} else Files.createDirectories(directory);
|
||||
}
|
||||
|
||||
private Path file(UUID character) { return directory.resolve(Objects.requireNonNull(character) + ".yml"); }
|
||||
|
||||
private static String encode(Operation value) {
|
||||
return "schema-version: 1\n"
|
||||
+ "operation: " + value.operation() + "\n"
|
||||
+ "character: " + value.character() + "\n"
|
||||
+ "administrator: " + value.administrator() + "\n"
|
||||
+ "recipient: " + value.recipient() + "\n"
|
||||
+ "expected-revision: " + value.expectedRevision() + "\n"
|
||||
+ "expected-generation: " + value.expectedGeneration() + "\n"
|
||||
+ "replacement-generation: " + value.replacementGeneration() + "\n"
|
||||
+ "revive: " + value.revive() + "\n"
|
||||
+ "death: " + (value.death() == null ? "" : value.death()) + "\n"
|
||||
+ "phase: " + value.phase() + "\n";
|
||||
}
|
||||
|
||||
private static Operation decode(String text) throws IOException {
|
||||
var values = new LinkedHashMap<String, String>();
|
||||
for (String line : text.split("\\n", -1)) {
|
||||
if (line.isEmpty()) continue;
|
||||
int split = line.indexOf(": ");
|
||||
if (split < 1 || values.putIfAbsent(line.substring(0, split), line.substring(split + 2)) != null)
|
||||
throw new IOException("Invalid recovery record");
|
||||
}
|
||||
try {
|
||||
if (!values.keySet().equals(java.util.Set.of("schema-version", "operation", "character",
|
||||
"administrator", "recipient", "expected-revision", "expected-generation",
|
||||
"replacement-generation", "revive", "death", "phase"))
|
||||
|| !values.get("schema-version").equals("1")) throw new IllegalArgumentException();
|
||||
boolean revive = switch (values.get("revive")) { case "true" -> true; case "false" -> false;
|
||||
default -> throw new IllegalArgumentException(); };
|
||||
String death = values.get("death");
|
||||
return new Operation(UUID.fromString(values.get("operation")), UUID.fromString(values.get("character")),
|
||||
UUID.fromString(values.get("administrator")), UUID.fromString(values.get("recipient")),
|
||||
new BigInteger(values.get("expected-revision")), new BigInteger(values.get("expected-generation")),
|
||||
new BigInteger(values.get("replacement-generation")), revive,
|
||||
death.isEmpty() ? null : UUID.fromString(death), Phase.valueOf(values.get("phase")));
|
||||
} catch (RuntimeException invalid) { throw new IOException("Invalid recovery record"); }
|
||||
}
|
||||
|
||||
@FunctionalInterface private interface IoAction { void run() throws IOException; }
|
||||
}
|
||||
@@ -26,6 +26,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
private final Clock clock;
|
||||
private final java.util.function.Supplier<games.dmg.fantasystick.progression.CoinRules> coinRules;
|
||||
private final java.util.random.RandomGenerator coinRandom;
|
||||
private final CharacterRecoveryStore recoveries;
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-character-persistence"));
|
||||
|
||||
@@ -41,6 +42,8 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.coinRules = Objects.requireNonNull(coinRules);
|
||||
this.coinRandom = Objects.requireNonNull(coinRandom);
|
||||
this.recoveries = new CharacterRecoveryStore(repository.pluginDataDirectory());
|
||||
repository.attachMutationUnavailable(recoveries::blocks);
|
||||
}
|
||||
|
||||
public void attachUnavailableCharacter(java.util.function.Predicate<UUID> predicate){repository.attachUnavailable(predicate);}
|
||||
@@ -205,35 +208,174 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
return new CraftCleanup(removed, retained);
|
||||
}
|
||||
|
||||
public record Catalog(List<CharacterRecord> records, int unreadable) {
|
||||
public Catalog { records = List.copyOf(records); }
|
||||
public boolean complete() { return unreadable == 0; }
|
||||
}
|
||||
|
||||
public CompletableFuture<Catalog> catalog() {
|
||||
return submit(() -> {
|
||||
var catalog = repository.catalog();
|
||||
return new Catalog(catalog.records(), catalog.unreadable());
|
||||
});
|
||||
}
|
||||
|
||||
/** Pure authoritative read for administration; unlike gameplay loading, this never performs legacy migration. */
|
||||
public CompletableFuture<Optional<CharacterRecord>> inspect(UUID id) {
|
||||
return submit(() -> repository.load(Objects.requireNonNull(id)));
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<CharacterRecord>> find(UUID id) {
|
||||
return submit(() -> load(id));
|
||||
}
|
||||
|
||||
public enum RecoveryStatus { APPLIED, UNAVAILABLE, STALE, BLOCKED }
|
||||
public record RecoveryRequest(UUID operation, UUID character, UUID administrator, UUID recipient,
|
||||
BigInteger expectedRevision, BigInteger expectedGeneration,
|
||||
boolean revive, UUID death, java.math.BigDecimal nativeLimit) {
|
||||
public RecoveryRequest {
|
||||
Objects.requireNonNull(operation); Objects.requireNonNull(character);
|
||||
Objects.requireNonNull(administrator); Objects.requireNonNull(recipient);
|
||||
Objects.requireNonNull(expectedRevision); Objects.requireNonNull(expectedGeneration);
|
||||
Objects.requireNonNull(nativeLimit);
|
||||
if (expectedRevision.signum() < 0 || expectedGeneration.signum() < 0
|
||||
|| nativeLimit.signum() <= 0 || revive != (death != null))
|
||||
throw new IllegalArgumentException("Invalid character recovery request");
|
||||
}
|
||||
}
|
||||
public record RecoveryCommit(RecoveryStatus status, Optional<CharacterRecord> record, UUID operation) { }
|
||||
|
||||
/** Durable generation/revival commit; native delivery happens only after APPLIED acknowledgment. */
|
||||
public CompletableFuture<RecoveryCommit> beginRecovery(RecoveryRequest request) {
|
||||
Objects.requireNonNull(request);
|
||||
return submit(() -> {
|
||||
var found = repository.load(request.character());
|
||||
if (found.isEmpty()) return new RecoveryCommit(RecoveryStatus.UNAVAILABLE, Optional.empty(), request.operation());
|
||||
var old = found.orElseThrow();
|
||||
reconcileRecovery(old);
|
||||
if (!old.revision().equals(request.expectedRevision())
|
||||
|| !old.sheetGeneration().equals(request.expectedGeneration()))
|
||||
return new RecoveryCommit(RecoveryStatus.STALE, Optional.of(old), request.operation());
|
||||
if (request.revive()) {
|
||||
if (!old.dead() || !Objects.equals(old.deathId(), request.death()))
|
||||
return new RecoveryCommit(RecoveryStatus.STALE, Optional.of(old), request.operation());
|
||||
} else if (old.dead()) return new RecoveryCommit(RecoveryStatus.STALE, Optional.of(old), request.operation());
|
||||
var existing = recoveries.find(old.id());
|
||||
if (existing.isPresent() && existing.orElseThrow().phase() != CharacterRecoveryStore.Phase.DELIVERED)
|
||||
return new RecoveryCommit(RecoveryStatus.BLOCKED, Optional.of(old), request.operation());
|
||||
BigInteger nextGeneration = old.sheetGeneration().add(BigInteger.ONE);
|
||||
var operation = new CharacterRecoveryStore.Operation(request.operation(), old.id(),
|
||||
request.administrator(), request.recipient(), old.revision(), old.sheetGeneration(),
|
||||
nextGeneration, request.revive(), request.death(), CharacterRecoveryStore.Phase.PREPARED);
|
||||
recoveries.prepare(operation);
|
||||
var health = request.revive() ? old.maximumHealth().min(request.nativeLimit()) : old.health();
|
||||
var replacement = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
old.level(), old.experience(), old.unspentPoints(), health, old.learned(), old.loadout(), old.passives(),
|
||||
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(),
|
||||
old.coinsInitialized(), old.rewardReceipts(), request.revive() ? BigInteger.ZERO : old.pvpCoinEscrow(),
|
||||
old.gear(), nextGeneration);
|
||||
try { repository.replaceForRecovery(replacement, old.revision()); }
|
||||
catch (IOException failure) {
|
||||
try { recoveries.cancelPrepared(old.id(), request.operation()); }
|
||||
catch (IOException retained) { failure.addSuppressed(retained); }
|
||||
throw failure;
|
||||
}
|
||||
recoveries.advance(old.id(), request.operation(), CharacterRecoveryStore.Phase.PREPARED,
|
||||
CharacterRecoveryStore.Phase.COMMITTED);
|
||||
return new RecoveryCommit(RecoveryStatus.APPLIED, Optional.of(replacement), request.operation());
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Boolean> completeRecovery(UUID character, UUID operation) {
|
||||
Objects.requireNonNull(character); Objects.requireNonNull(operation);
|
||||
return submit(() -> {
|
||||
var record = repository.load(character).orElseThrow(() -> new IOException("Recovered character missing"));
|
||||
var proof = recoveries.find(character).orElseThrow(() -> new IOException("Recovery operation missing"));
|
||||
if (!proof.operation().equals(operation) || proof.phase() != CharacterRecoveryStore.Phase.COMMITTED
|
||||
|| !replacementMatches(record, proof))
|
||||
throw new IOException("Recovery completion proof conflicts with saved character");
|
||||
recoveries.advance(character, operation, CharacterRecoveryStore.Phase.COMMITTED,
|
||||
CharacterRecoveryStore.Phase.DELIVERED);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** Persist a legacy character's one-time configured migration roll before exposing it. */
|
||||
private Optional<CharacterRecord> load(UUID id) throws IOException {
|
||||
var found = repository.load(id);
|
||||
if (found.isEmpty() || found.orElseThrow().coinsInitialized()) return found;
|
||||
if (found.isEmpty()) {
|
||||
if (recoveries.find(id).filter(operation -> operation.phase() != CharacterRecoveryStore.Phase.DELIVERED).isPresent())
|
||||
throw new IOException("Recovery proof references a missing character");
|
||||
return found;
|
||||
}
|
||||
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(),
|
||||
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());
|
||||
coinRules.get().starting(coinRandom), true, old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
|
||||
repository.replace(migrated, old.revision());
|
||||
return Optional.of(migrated);
|
||||
}
|
||||
|
||||
private void reconcileRecovery(CharacterRecord record) throws IOException {
|
||||
var found = recoveries.find(record.id());
|
||||
if (found.isEmpty()) return;
|
||||
var operation = found.orElseThrow();
|
||||
if (operation.phase() == CharacterRecoveryStore.Phase.PREPARED) {
|
||||
if (expectedMatches(record, operation)) {
|
||||
recoveries.cancelPrepared(record.id(), operation.operation());
|
||||
return;
|
||||
}
|
||||
if (replacementMatches(record, operation)) {
|
||||
recoveries.advance(record.id(), operation.operation(), CharacterRecoveryStore.Phase.PREPARED,
|
||||
CharacterRecoveryStore.Phase.COMMITTED);
|
||||
throw new IOException("Recovery committed before acknowledgment; custody is unresolved");
|
||||
}
|
||||
throw new IOException("Prepared recovery proof conflicts with saved character");
|
||||
}
|
||||
if (operation.phase() == CharacterRecoveryStore.Phase.COMMITTED) {
|
||||
if (!replacementMatches(record, operation))
|
||||
throw new IOException("Committed recovery proof conflicts with saved character");
|
||||
throw new IOException("Character has unresolved committed recovery custody");
|
||||
}
|
||||
if (!record.sheetGeneration().equals(operation.replacementGeneration())
|
||||
|| record.revision().compareTo(operation.expectedRevision().add(BigInteger.ONE)) < 0)
|
||||
throw new IOException("Delivered recovery proof conflicts with saved character");
|
||||
}
|
||||
|
||||
private static boolean expectedMatches(CharacterRecord record, CharacterRecoveryStore.Operation operation) {
|
||||
return record.revision().equals(operation.expectedRevision())
|
||||
&& record.sheetGeneration().equals(operation.expectedGeneration())
|
||||
&& (operation.revive() ? record.dead() && Objects.equals(record.deathId(), operation.death()) : !record.dead());
|
||||
}
|
||||
|
||||
private static boolean replacementMatches(CharacterRecord record, CharacterRecoveryStore.Operation operation) {
|
||||
return record.revision().equals(operation.expectedRevision().add(BigInteger.ONE))
|
||||
&& record.sheetGeneration().equals(operation.replacementGeneration())
|
||||
&& !record.dead() && (!operation.revive() || Objects.equals(record.deathId(), operation.death()));
|
||||
}
|
||||
|
||||
/** Persist an accepted native rename against the latest live record; never create a character from item metadata. */
|
||||
public CompletableFuture<Optional<CharacterRecord>> renameCharacter(UUID characterId, String name) {
|
||||
return renameCharacter(characterId, name, null);
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<CharacterRecord>> renameCharacter(
|
||||
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();
|
||||
var old = found.orElseThrow();
|
||||
if (expectedSheetGeneration != null && !old.sheetGeneration().equals(expectedSheetGeneration))
|
||||
return Optional.empty();
|
||||
if (old.name().equals(name)) return Optional.of(old);
|
||||
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.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);
|
||||
});
|
||||
@@ -273,7 +415,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
int gained = Math.max(0, level - old.level());
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
level, experience, old.unspentPoints().add(BigInteger.valueOf(gained)), 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.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(saved, old.revision());
|
||||
return new ExperienceAward(ExperienceAwardStatus.APPLIED, Optional.of(saved), gained);
|
||||
});
|
||||
@@ -309,7 +451,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
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(amount), true, receipts,old.pvpCoinEscrow(),old.gear());
|
||||
old.coins().subtract(amount), true, receipts,old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
|
||||
repository.replace(saved, old.revision());
|
||||
return new CoinSpend(CoinSpendStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -318,12 +460,27 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
public enum CoinRefundStatus { APPLIED, DUPLICATE, UNAVAILABLE }
|
||||
public record CoinRefund(CoinRefundStatus status,Optional<CharacterRecord> record){}
|
||||
/** Compensate a persisted debit exactly once while retaining both audit receipts. */
|
||||
public CompletableFuture<CoinRefund> refundCoins(UUID characterId,UUID debitId,UUID refundId,BigInteger amount){Objects.requireNonNull(characterId);Objects.requireNonNull(debitId);Objects.requireNonNull(refundId);Objects.requireNonNull(amount);if(amount.signum()<0)throw new IllegalArgumentException("Coin refund must not be negative");return submit(()->{var found=repository.load(characterId);if(found.isEmpty())return new CoinRefund(CoinRefundStatus.UNAVAILABLE,Optional.empty());var old=found.orElseThrow();if(old.rewardReceipts().contains(refundId))return new CoinRefund(CoinRefundStatus.DUPLICATE,Optional.of(old));if(!old.rewardReceipts().contains(debitId))return new CoinRefund(CoinRefundStatus.UNAVAILABLE,Optional.of(old));var receipts=new java.util.HashSet<>(old.rewardReceipts());if(receipts.size()>=4096)receipts.remove(receipts.stream().filter(id->!id.equals(debitId)).min(UUID::compareTo).orElseThrow());receipts.add(refundId);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(),old.dead(),old.deathId(),old.diedAt(),old.coins().add(amount),old.coinsInitialized(),receipts,old.pvpCoinEscrow(),old.gear());repository.replace(saved,old.revision());return new CoinRefund(CoinRefundStatus.APPLIED,Optional.of(saved));});}
|
||||
public CompletableFuture<CoinRefund> refundCoins(UUID characterId,UUID debitId,UUID refundId,BigInteger amount){Objects.requireNonNull(characterId);Objects.requireNonNull(debitId);Objects.requireNonNull(refundId);Objects.requireNonNull(amount);if(amount.signum()<0)throw new IllegalArgumentException("Coin refund must not be negative");return submit(()->{var found=repository.load(characterId);if(found.isEmpty())return new CoinRefund(CoinRefundStatus.UNAVAILABLE,Optional.empty());var old=found.orElseThrow();if(old.rewardReceipts().contains(refundId))return new CoinRefund(CoinRefundStatus.DUPLICATE,Optional.of(old));if(!old.rewardReceipts().contains(debitId))return new CoinRefund(CoinRefundStatus.UNAVAILABLE,Optional.of(old));var receipts=new java.util.HashSet<>(old.rewardReceipts());if(receipts.size()>=4096)receipts.remove(receipts.stream().filter(id->!id.equals(debitId)).min(UUID::compareTo).orElseThrow());receipts.add(refundId);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(),old.dead(),old.deathId(),old.diedAt(),old.coins().add(amount),old.coinsInitialized(),receipts,old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());repository.replace(saved,old.revision());return new CoinRefund(CoinRefundStatus.APPLIED,Optional.of(saved));});}
|
||||
|
||||
public enum GearPurchaseStatus { APPLIED,DUPLICATE,INSUFFICIENT,NOT_UPGRADE,STALE,UNAVAILABLE }
|
||||
public record GearPurchase(GearPurchaseStatus status,Optional<CharacterRecord> record){}
|
||||
/** 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));repository.replace(saved,old.revision());return new GearPurchase(GearPurchaseStatus.APPLIED,Optional.of(saved));});}
|
||||
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) { }
|
||||
@@ -378,7 +535,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
level, experience, old.unspentPoints().add(BigInteger.valueOf(gained)), old.health(), old.learned(),
|
||||
old.loadout(), old.passives(), old.cooldowns(), history, false, old.deathId(), old.diedAt(),
|
||||
old.coins().add(eligible ? coinReward : BigInteger.ZERO), true, receipts,old.pvpCoinEscrow(),old.gear());
|
||||
old.coins().add(eligible ? coinReward : BigInteger.ZERO), true, receipts,old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
|
||||
repository.replace(saved, old.revision());
|
||||
return new CombatAward(status, Optional.of(saved), gained);
|
||||
});
|
||||
@@ -418,7 +575,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var passives = new java.util.ArrayList<>(old.passives()); passives.set(slot, passive);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints().subtract(cost), old.health(), old.learned(), old.loadout(), passives,
|
||||
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
|
||||
repository.replace(saved, old.revision());
|
||||
return new PassiveMutation(PassiveMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -446,7 +603,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var passives = old.passives().stream().map(id -> id.equals(passive) ? "" : id).toList();
|
||||
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(), passives,
|
||||
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
|
||||
repository.replace(saved, old.revision());
|
||||
return new PassiveMutation(PassiveMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -462,7 +619,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(() -> {
|
||||
@@ -481,7 +638,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var loadout = new java.util.ArrayList<>(old.loadout()); loadout.set(slot, ability);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints().subtract(cost), old.health(), learned, loadout, old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -501,7 +658,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var loadout = old.loadout().stream().map(assigned -> assigned.equals(ability) ? "" : assigned).toList();
|
||||
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(), learned, loadout, old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -524,7 +681,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
if (loadout.equals(old.loadout())) return new AbilityMutation(AbilityMutationStatus.UNCHANGED, Optional.of(old));
|
||||
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(), loadout, old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -548,7 +705,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var cooldowns = new java.util.HashMap<>(old.cooldowns()); cooldowns.put(ability, readyAt);
|
||||
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(), cooldowns,
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -593,7 +750,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
|
||||
old.level(), old.experience(), old.unspentPoints().subtract(BigInteger.ONE),
|
||||
currentHealth.add(gain), 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.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
|
||||
repository.replace(saved, old.revision());
|
||||
return new StatPurchase(StatPurchaseStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -619,7 +776,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
if (old.health().compareTo(bounded) == 0) return Optional.of(old);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints(), bounded, 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.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
|
||||
repository.replace(saved, old.revision());
|
||||
return Optional.of(saved);
|
||||
});
|
||||
@@ -643,6 +800,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());return Optional.of(dead);});}
|
||||
|
||||
private CompletableFuture<Optional<CharacterRecord>> death(UUID characterId, BigInteger expectedRevision, boolean loseCoins) {
|
||||
Objects.requireNonNull(characterId);
|
||||
Instant diedAt = clock.instant();
|
||||
@@ -656,7 +816,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
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());
|
||||
loseCoins ? old.coins() : BigInteger.ZERO,old.gear(),old.sheetGeneration());
|
||||
repository.replace(dead, old.revision());
|
||||
return Optional.of(dead);
|
||||
});
|
||||
@@ -693,7 +853,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var health = nativeLimit == null ? old.maximumHealth() : old.maximumHealth().min(nativeLimit);
|
||||
var restored = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
old.level(), old.experience(), old.unspentPoints(), health, old.learned(), old.loadout(), old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),BigInteger.ZERO,old.gear());
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),BigInteger.ZERO,old.gear(),old.sheetGeneration());
|
||||
repository.replace(restored, old.revision());
|
||||
return Optional.of(restored);
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ public final class CharacterYaml {
|
||||
root.put("character-id", record.id().toString());
|
||||
root.put("name", record.name());
|
||||
root.put("revision", record.revision().toString());
|
||||
root.put("sheet-generation", record.sheetGeneration().toString());
|
||||
Map<String, String> stats = new LinkedHashMap<>();
|
||||
for (Stat stat : Stat.values()) stats.put(stat.name(), record.stats().get(stat).toString());
|
||||
root.put("stats", stats);
|
||||
@@ -108,7 +109,8 @@ public final class CharacterYaml {
|
||||
root.containsKey("reward-receipts") ? strings(root.get("reward-receipts")).stream()
|
||||
.map(UUID::fromString).collect(java.util.stream.Collectors.toUnmodifiableSet()) : java.util.Set.of(),
|
||||
root.containsKey("pvp-coin-escrow") ? number(root.get("pvp-coin-escrow")) : BigInteger.ZERO,
|
||||
root.containsKey("gear")?gear(root.get("gear")):CharacterGear.none());
|
||||
root.containsKey("gear")?gear(root.get("gear")):CharacterGear.none(),
|
||||
root.containsKey("sheet-generation") ? number(root.get("sheet-generation")) : BigInteger.ZERO);
|
||||
}
|
||||
|
||||
private static CharacterGear gear(Object value){var gear=object(value);return new CharacterGear(GearTier.valueOf(text(gear.get("chestplate"))),GearTier.valueOf(text(gear.get("leggings"))),GearTier.valueOf(text(gear.get("boots"))));}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);}
|
||||
}
|
||||
@@ -15,16 +15,16 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Atomic single-file durable authority for daily adventure quests. */
|
||||
public final class YamlAdventureQuestRepository {
|
||||
private final Path file;
|
||||
private final Path file;private volatile boolean legacyDungeonRewardTerms;
|
||||
public YamlAdventureQuestRepository(Path data){file=data.toAbsolutePath().resolve("adventure-quests.yml");}
|
||||
public AdventureQuestState load()throws IOException{
|
||||
if(Files.notExists(file))return AdventureQuestState.empty();
|
||||
try{
|
||||
Object decoded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));var root=map(decoded);if(number(root.get("schema-version"))!=1)throw new IllegalArgumentException();
|
||||
var sites=new ArrayList<AdventureQuestState.Site>();for(Object item:list(root.get("sites"))){var r=map(item);AdventureQuestState.Arena arena=null;if(r.containsKey("arena-shape")){var shape=AdventureQuestState.ArenaShape.valueOf(text(r.get("arena-shape")));int x=number(r.get("arena-x")),z=number(r.get("arena-z"));arena=new AdventureQuestState.Arena(shape,x,z);}sites.add(new AdventureQuestState.Site(uuid(r,"id"),AdventureQuestState.SiteType.valueOf(text(r.get("type"))),text(r.get("name")),uuid(r,"world"),decimal(r,"x"),decimal(r,"y"),decimal(r,"z"),arena));}
|
||||
Object decoded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));var root=map(decoded);int schema=number(root.get("schema-version"));if(schema!=1&&schema!=2)throw new IllegalArgumentException();legacyDungeonRewardTerms=schema==1;
|
||||
var sites=new ArrayList<AdventureQuestState.Site>();for(Object item:list(root.get("sites"))){var r=map(item);AdventureQuestState.Arena arena=null;if(r.containsKey("arena-shape")){var shape=AdventureQuestState.ArenaShape.valueOf(text(r.get("arena-shape")));int x=number(r.get("arena-x")),z=number(r.get("arena-z"));arena=new AdventureQuestState.Arena(shape,x,z);}AdventureQuestState.FishingDefinition fishing=null;if(r.get("fishing")!=null){var f=map(r.get("fishing"));fishing=new AdventureQuestState.FishingDefinition(uuid(f,"item-id"),text(f.get("item-name")),new BigInteger(text(f.get("required"))),new java.math.BigDecimal(text(f.get("chance-percent"))),new BigInteger(text(f.get("reward-xp"))),new BigInteger(text(f.get("reward-coins"))),number(f.get("radius")));}sites.add(new AdventureQuestState.Site(uuid(r,"id"),AdventureQuestState.SiteType.valueOf(text(r.get("type"))),text(r.get("name")),uuid(r,"world"),decimal(r,"x"),decimal(r,"y"),decimal(r,"z"),arena,r.containsKey("participation-mode")?AdventureQuestState.ParticipationMode.parse(text(r.get("participation-mode"))):AdventureQuestState.ParticipationMode.PARTY_AVAILABLE,fishing));}
|
||||
var givers=new ArrayList<AdventureQuestState.Giver>();for(Object item:list(root.get("givers"))){var r=map(item);givers.add(new AdventureQuestState.Giver(uuid(r,"id"),text(r.get("name")),uuid(r,"world"),decimal(r,"x"),decimal(r,"y"),decimal(r,"z"),(float)decimal(r,"yaw"),(float)decimal(r,"pitch")));}
|
||||
AdventureQuestState.Catalog catalog=null;if(root.get("catalog")!=null){var r=map(root.get("catalog"));catalog=new AdventureQuestState.Catalog(LocalDate.parse(text(r.get("day"))),uuid(r,"revision"),Instant.parse(text(r.get("created-at"))),list(r.get("sites")).stream().map(value->UUID.fromString(text(value))).toList(),r.containsKey("closed")?list(r.get("closed")).stream().map(value->UUID.fromString(text(value))).collect(java.util.stream.Collectors.toSet()):Set.of());}
|
||||
var quests=new ArrayList<AdventureQuestState.Quest>();for(Object item:list(root.get("quests"))){var r=map(item);quests.add(new AdventureQuestState.Quest(uuid(r,"id"),uuid(r,"catalog"),uuid(r,"site"),AdventureQuestState.UnitType.valueOf(text(r.get("unit-type"))),uuid(r,"unit"),list(r.get("snapshot")).stream().map(value->UUID.fromString(text(value))).collect(java.util.stream.Collectors.toSet()),Instant.parse(text(r.get("expires-at"))),new BigInteger(text(r.get("reward-xp"))),new BigInteger(text(r.get("reward-coins")))));}
|
||||
var quests=new ArrayList<AdventureQuestState.Quest>();for(Object item:list(root.get("quests"))){var r=map(item);quests.add(new AdventureQuestState.Quest(uuid(r,"id"),uuid(r,"catalog"),uuid(r,"site"),AdventureQuestState.UnitType.valueOf(text(r.get("unit-type"))),uuid(r,"unit"),list(r.get("snapshot")).stream().map(value->UUID.fromString(text(value))).collect(java.util.stream.Collectors.toSet()),Instant.parse(text(r.get("expires-at"))),new BigInteger(text(r.get("reward-xp"))),new BigInteger(text(r.get("reward-coins"))),r.containsKey("participation-mode")?AdventureQuestState.ParticipationMode.parse(text(r.get("participation-mode"))):AdventureQuestState.ParticipationMode.PARTY_AVAILABLE,r.get("fishing")==null?null:fishingTerms(map(r.get("fishing")))));}
|
||||
var unitClaims=new HashSet<AdventureQuestState.UnitClaim>();for(Object item:list(root.get("unit-claims"))){var r=map(item);unitClaims.add(new AdventureQuestState.UnitClaim(uuid(r,"catalog"),uuid(r,"site"),AdventureQuestState.UnitType.valueOf(text(r.get("unit-type"))),uuid(r,"unit")));}
|
||||
var characterClaims=new HashSet<AdventureQuestState.CharacterClaim>();for(Object item:list(root.get("character-claims"))){var r=map(item);characterClaims.add(new AdventureQuestState.CharacterClaim(uuid(r,"catalog"),uuid(r,"site"),uuid(r,"character")));}
|
||||
var rosters=new LinkedHashMap<UUID,List<EntityType>>();for(Object item:list(root.get("rosters"))){var r=map(item);rosters.put(uuid(r,"site"),list(r.get("entities")).stream().map(value->EntityType.valueOf(text(value))).toList());}
|
||||
@@ -44,23 +44,26 @@ public final class YamlAdventureQuestRepository {
|
||||
return new AdventureQuestState(sites,givers,catalog,quests,unitClaims,characterClaims,rosters,tracking,rewards,reset);
|
||||
}catch(RuntimeException invalid){throw new IOException("Invalid adventure quest file",invalid);}
|
||||
}
|
||||
public boolean requiresDungeonRewardMigration(){return legacyDungeonRewardTerms;}
|
||||
public void save(AdventureQuestState state)throws IOException{
|
||||
Objects.requireNonNull(state);var root=new LinkedHashMap<String,Object>();root.put("schema-version",1);
|
||||
Objects.requireNonNull(state);var root=new LinkedHashMap<String,Object>();root.put("schema-version",2);
|
||||
root.put("sites",state.sites().stream().sorted(Comparator.comparing(AdventureQuestState.Site::id)).map(YamlAdventureQuestRepository::siteRow).toList());
|
||||
root.put("givers",state.givers().stream().sorted(Comparator.comparing(AdventureQuestState.Giver::id)).map(v->row("id",v.id(),"name",v.name(),"world",v.world(),"x",v.x(),"y",v.y(),"z",v.z(),"yaw",v.yaw(),"pitch",v.pitch())).toList());
|
||||
if(state.catalog()!=null)root.put("catalog",row("day",state.catalog().day(),"revision",state.catalog().revision(),"created-at",state.catalog().createdAt(),"sites",state.catalog().sites(),"closed",state.catalog().closed().stream().sorted().toList()));else root.put("catalog",null);
|
||||
root.put("quests",state.quests().stream().sorted(Comparator.comparing(AdventureQuestState.Quest::id)).map(v->row("id",v.id(),"catalog",v.catalog(),"site",v.site(),"unit-type",v.unitType(),"unit",v.unit(),"snapshot",v.snapshot().stream().sorted().toList(),"expires-at",v.expiresAt(),"reward-xp",v.rewardXp(),"reward-coins",v.rewardCoins())).toList());
|
||||
root.put("quests",state.quests().stream().sorted(Comparator.comparing(AdventureQuestState.Quest::id)).map(v->row("id",v.id(),"catalog",v.catalog(),"site",v.site(),"unit-type",v.unitType(),"unit",v.unit(),"snapshot",v.snapshot().stream().sorted().toList(),"expires-at",v.expiresAt(),"reward-xp",v.rewardXp(),"reward-coins",v.rewardCoins(),"participation-mode",v.participationMode().value(),"fishing",v.fishing()==null?null:fishing(v.fishing()))).toList());
|
||||
root.put("unit-claims",state.unitClaims().stream().sorted(Comparator.comparing(v->v.catalog().toString()+v.site()+v.unit())).map(v->row("catalog",v.catalog(),"site",v.site(),"unit-type",v.type(),"unit",v.unit())).toList());
|
||||
root.put("character-claims",state.characterClaims().stream().sorted(Comparator.comparing(v->v.catalog().toString()+v.site()+v.character())).map(v->row("catalog",v.catalog(),"site",v.site(),"character",v.character())).toList());
|
||||
root.put("rosters",state.rosters().entrySet().stream().sorted(Map.Entry.comparingByKey()).map(e->row("site",e.getKey(),"entities",e.getValue())).toList());
|
||||
root.put("tracking",state.tracking().stream().sorted(Comparator.comparing(v->v.quest().toString()+v.character())).map(v->row("quest",v.quest(),"character",v.character(),"enabled",v.enabled())).toList());
|
||||
root.put("rewards",row("allocations",state.rewards().allocations().stream().sorted(Comparator.comparing(AdventureQuestState.RewardAllocation::id)).map(v->row("id",v.id(),"catalog",v.catalog(),"site",v.site(),"quest",v.quest(),"source",v.source(),"character",v.character(),"xp",v.xp(),"coins",v.coins())).toList(),"operations",state.rewards().operations().stream().sorted(Comparator.comparing(AdventureQuestState.RewardClaim::id)).map(v->row("id",v.id(),"catalog",v.catalog(),"site",v.site(),"character",v.character(),"allocations",v.allocations().stream().sorted().toList(),"xp",v.xp(),"coins",v.coins())).toList(),"receipts",state.rewards().receipts().stream().sorted(Comparator.comparing(v->v.catalog().toString()+v.site()+v.character())).map(v->row("catalog",v.catalog(),"site",v.site(),"character",v.character())).toList()));
|
||||
root.put("reset",state.reset()==null?null:row("operation",state.reset().operation(),"generation",state.reset().generation(),"started-at",state.reset().startedAt()));
|
||||
Files.createDirectories(file.getParent());Path temporary=Files.createTempFile(file.getParent(),"adventure-quests-",".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 adventure persistence unavailable",unsupported);}DurableFiles.forceDirectory(file.getParent());}finally{Files.deleteIfExists(temporary);}
|
||||
Files.createDirectories(file.getParent());Path temporary=Files.createTempFile(file.getParent(),"adventure-quests-",".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 adventure persistence unavailable",unsupported);}DurableFiles.forceDirectory(file.getParent());}finally{Files.deleteIfExists(temporary);}legacyDungeonRewardTerms=false;
|
||||
}
|
||||
private static Map<String,Object> siteRow(AdventureQuestState.Site site){var result=row("id",site.id(),"type",site.type(),"name",site.name(),"world",site.world(),"x",site.x(),"y",site.y(),"z",site.z());if(site.arena()!=null){result.put("arena-shape",encode(site.arena().shape()));result.put("arena-x",site.arena().xBlocks());result.put("arena-z",site.arena().zBlocks());}return result;}
|
||||
private static Map<String,Object> siteRow(AdventureQuestState.Site site){var result=row("id",site.id(),"type",site.type(),"name",site.name(),"world",site.world(),"x",site.x(),"y",site.y(),"z",site.z(),"participation-mode",site.participationMode().value(),"fishing",site.fishing()==null?null:fishing(site.fishing().terms()));if(site.arena()!=null){result.put("arena-shape",encode(site.arena().shape()));result.put("arena-x",site.arena().xBlocks());result.put("arena-z",site.arena().zBlocks());}return result;}
|
||||
private static Map<String,Object> fishing(AdventureQuestState.FishingTerms value){return row("item-id",value.itemId(),"item-name",value.itemName(),"required",value.required(),"chance-percent",value.chancePercent(),"reward-xp",value.rewardXp(),"reward-coins",value.rewardCoins(),"radius",value.radius());}
|
||||
private static AdventureQuestState.FishingTerms fishingTerms(Map<?,?> value){return new AdventureQuestState.FishingTerms(uuid(value,"item-id"),text(value.get("item-name")),new BigInteger(text(value.get("required"))),new java.math.BigDecimal(text(value.get("chance-percent"))),new BigInteger(text(value.get("reward-xp"))),new BigInteger(text(value.get("reward-coins"))),number(value.get("radius")));}
|
||||
private static Map<String,Object> row(Object...pairs){var row=new LinkedHashMap<String,Object>();for(int i=0;i<pairs.length;i+=2)row.put((String)pairs[i],encode(pairs[i+1]));return row;}
|
||||
private static Object encode(Object value){if(value instanceof UUID||value instanceof Enum<?>||value instanceof Instant||value instanceof LocalDate||value instanceof BigInteger||value instanceof Double||value instanceof Float)return value.toString();if(value instanceof Collection<?> values)return values.stream().map(YamlAdventureQuestRepository::encode).toList();return value;}
|
||||
private static Object encode(Object value){if(value instanceof UUID||value instanceof Enum<?>||value instanceof Instant||value instanceof LocalDate||value instanceof BigInteger||value instanceof java.math.BigDecimal||value instanceof Double||value instanceof Float)return value.toString();if(value instanceof Collection<?> values)return values.stream().map(YamlAdventureQuestRepository::encode).toList();return value;}
|
||||
private static Yaml yaml(){var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(64);options.setCodePointLimit(4_194_304);return new Yaml(new SafeConstructor(options));}
|
||||
private static Map<?,?> map(Object value){if(value instanceof Map<?,?> result)return result;throw new IllegalArgumentException();}
|
||||
private static List<?> list(Object value){if(value instanceof List<?> result)return result;throw new IllegalArgumentException();}
|
||||
|
||||
@@ -12,7 +12,9 @@ import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
@@ -23,6 +25,7 @@ public final class YamlCharacterRepository {
|
||||
private final Path directory;
|
||||
private final Replacement replacement;
|
||||
private volatile java.util.function.Predicate<UUID> unavailable = ignored -> false;
|
||||
private volatile java.util.function.Predicate<UUID> mutationUnavailable = ignored -> false;
|
||||
|
||||
@FunctionalInterface
|
||||
interface Replacement { void move(Path source, Path target) throws IOException; }
|
||||
@@ -30,6 +33,10 @@ public final class YamlCharacterRepository {
|
||||
private interface Operation { void run() throws IOException; }
|
||||
|
||||
public void attachUnavailable(java.util.function.Predicate<UUID> predicate){unavailable=Objects.requireNonNull(predicate);}
|
||||
void attachMutationUnavailable(java.util.function.Predicate<UUID> predicate) {
|
||||
mutationUnavailable = Objects.requireNonNull(predicate);
|
||||
}
|
||||
Path pluginDataDirectory() { return directory.getParent(); }
|
||||
|
||||
public YamlCharacterRepository(Path pluginDataDirectory) {
|
||||
this(pluginDataDirectory, (source, target) -> Files.move(source, target,
|
||||
@@ -71,12 +78,26 @@ public final class YamlCharacterRepository {
|
||||
}
|
||||
|
||||
public void replace(CharacterRecord character, BigInteger expectedRevision) throws IOException {
|
||||
replace(character, expectedRevision, true);
|
||||
}
|
||||
|
||||
/** Recovery owns its separately persisted reservation but still honors dungeon custody. */
|
||||
void replaceForRecovery(CharacterRecord character, BigInteger expectedRevision) throws IOException {
|
||||
replace(character, expectedRevision, false);
|
||||
}
|
||||
|
||||
private void replace(CharacterRecord character, BigInteger expectedRevision,
|
||||
boolean enforceMutationAvailability) throws IOException {
|
||||
if (unavailable.test(character.id())) throw new IOException("Character is in dungeon boss custody");
|
||||
if (enforceMutationAvailability && mutationUnavailable.test(character.id()))
|
||||
throw new IOException("Character has unresolved recovery custody");
|
||||
if (!character.revision().equals(expectedRevision.add(BigInteger.ONE))) {
|
||||
throw new IOException("Character update must advance exactly one revision");
|
||||
}
|
||||
withLock(() -> {
|
||||
if (unavailable.test(character.id())) throw new IOException("Character is in dungeon boss custody");
|
||||
if (enforceMutationAvailability && mutationUnavailable.test(character.id()))
|
||||
throw new IOException("Character has unresolved recovery custody");
|
||||
CharacterRecord current = load(character.id()).orElseThrow(() -> new IOException("Character not found"));
|
||||
if (!current.revision().equals(expectedRevision)) throw new IOException("Stale character revision");
|
||||
write(character);
|
||||
@@ -108,6 +129,41 @@ public final class YamlCharacterRepository {
|
||||
return removed[0];
|
||||
}
|
||||
|
||||
public record Catalog(List<CharacterRecord> records, int unreadable) {
|
||||
public Catalog { records = List.copyOf(records); }
|
||||
}
|
||||
|
||||
/** Enumerate only canonical record files without following directory-entry symlinks. */
|
||||
public Catalog catalog() throws IOException {
|
||||
if (Files.notExists(directory, LinkOption.NOFOLLOW_LINKS)) return new Catalog(List.of(), 0);
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS))
|
||||
throw new IOException("Character directory is unavailable");
|
||||
var records = new ArrayList<CharacterRecord>();
|
||||
int unreadable = 0;
|
||||
try (var entries = Files.newDirectoryStream(directory, "*.yml")) {
|
||||
for (var entry : entries) {
|
||||
String filename = entry.getFileName().toString();
|
||||
if (!filename.endsWith(".yml")) continue;
|
||||
UUID id;
|
||||
try {
|
||||
String stem = filename.substring(0, filename.length() - 4);
|
||||
id = UUID.fromString(stem);
|
||||
if (!id.toString().equals(stem)) continue;
|
||||
} catch (IllegalArgumentException invalid) { continue; }
|
||||
if (!Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS)) {
|
||||
unreadable++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
var record = load(id);
|
||||
if (record.isPresent()) records.add(record.orElseThrow());
|
||||
else unreadable++;
|
||||
} catch (IOException failure) { unreadable++; }
|
||||
}
|
||||
}
|
||||
return new Catalog(records, unreadable);
|
||||
}
|
||||
|
||||
public Optional<CharacterRecord> load(UUID id) throws IOException {
|
||||
Path file = file(id);
|
||||
if (Files.notExists(file, LinkOption.NOFOLLOW_LINKS)) return Optional.empty();
|
||||
|
||||
@@ -17,13 +17,13 @@ public final class YamlDungeonRepository {
|
||||
public YamlDungeonRepository(Path data){file=data.toAbsolutePath().resolve("dungeons.yml");}
|
||||
public DungeonState load()throws IOException{
|
||||
if(Files.notExists(file))return DungeonState.empty();
|
||||
try{var root=map(yaml().load(Files.readString(file,StandardCharsets.UTF_8)));if(integer(root.get("schema-version"))!=1)throw new IllegalArgumentException();var dungeons=new ArrayList<DungeonState.Dungeon>();for(var item:list(root.get("dungeons"))){var row=map(item);var rooms=new ArrayList<DungeonState.Room>();for(var roomItem:list(row.get("rooms"))){var room=map(roomItem);var mobs=new ArrayList<DungeonState.Spawn>();for(var mobItem:list(room.get("mobs"))){var mob=map(mobItem);mobs.add(new DungeonState.Spawn(uuid(mob,"id"),EntityType.valueOf(text(mob.get("type"))),text(mob.get("name")),position(map(mob.get("position")))));}var effects=new ArrayList<DungeonState.Effect>();for(var effectItem:list(room.get("effects"))){var effect=map(effectItem);effects.add(new DungeonState.Effect(uuid(effect,"id"),DungeonState.EffectTarget.valueOf(text(effect.get("target"))),text(effect.get("effect")),integer(effect.get("level")),bool(effect.get("particles"))));}DungeonState.Boss boss=null;if(room.get("boss")!=null){var value=map(room.get("boss"));DungeonState.CharacterBoss character=null;if(value.get("character")!=null){var custody=map(value.get("character"));DungeonState.Recovery recovery=null;if(custody.get("recovery")!=null){var pending=map(custody.get("recovery"));recovery=new DungeonState.Recovery(uuid(pending,"operation"),uuid(pending,"administrator"));}character=new DungeonState.CharacterBoss(uuid(custody,"operation"),CharacterYaml.decode(text(custody.get("record"))),new java.math.BigDecimal(text(custody.get("multiplier"))),custody.containsKey("prior-type")?EntityType.valueOf(text(custody.get("prior-type"))):EntityType.HUSK,custody.containsKey("prior-name")?text(custody.get("prior-name")):"",recovery);}boss=new DungeonState.Boss(EntityType.valueOf(text(value.get("type"))),text(value.get("name")),position(map(value.get("position"))),character);}rooms.add(new DungeonState.Room(uuid(room,"id"),text(room.get("name")),bounds(map(room.get("bounds"))),mobs,effects,new DungeonState.Reward(new BigInteger(text(room.get("reward-xp"))),new BigInteger(text(room.get("reward-coins")))),boss));}dungeons.add(new DungeonState.Dungeon(uuid(row,"id"),text(row.get("name")),uuid(row,"world"),position(map(row.get("entrance"))),bool(row.get("enabled")),uuid(row,"revision"),rooms));}
|
||||
try{var root=map(yaml().load(Files.readString(file,StandardCharsets.UTF_8)));int schema=integer(root.get("schema-version"));if(schema!=1&&schema!=2)throw new IllegalArgumentException();var dungeons=new ArrayList<DungeonState.Dungeon>();for(var item:list(root.get("dungeons"))){var row=map(item);var rooms=new ArrayList<DungeonState.Room>();for(var roomItem:list(row.get("rooms"))){var room=map(roomItem);var mobs=new ArrayList<DungeonState.Spawn>();for(var mobItem:list(room.get("mobs"))){var mob=map(mobItem);mobs.add(new DungeonState.Spawn(uuid(mob,"id"),EntityType.valueOf(text(mob.get("type"))),text(mob.get("name")),position(map(mob.get("position")))));}var effects=new ArrayList<DungeonState.Effect>();for(var effectItem:list(room.get("effects"))){var effect=map(effectItem);effects.add(new DungeonState.Effect(uuid(effect,"id"),DungeonState.EffectTarget.valueOf(text(effect.get("target"))),text(effect.get("effect")),integer(effect.get("level")),bool(effect.get("particles"))));}DungeonState.Boss boss=null;if(room.get("boss")!=null){var value=map(room.get("boss"));DungeonState.CharacterBoss character=null;if(value.get("character")!=null){var custody=map(value.get("character"));DungeonState.Recovery recovery=null;if(custody.get("recovery")!=null){var pending=map(custody.get("recovery"));recovery=new DungeonState.Recovery(uuid(pending,"operation"),uuid(pending,"administrator"));}character=new DungeonState.CharacterBoss(uuid(custody,"operation"),CharacterYaml.decode(text(custody.get("record"))),new java.math.BigDecimal(text(custody.get("multiplier"))),custody.containsKey("prior-type")?EntityType.valueOf(text(custody.get("prior-type"))):EntityType.HUSK,custody.containsKey("prior-name")?text(custody.get("prior-name")):"",recovery);}boss=new DungeonState.Boss(EntityType.valueOf(text(value.get("type"))),text(value.get("name")),position(map(value.get("position"))),character);}rooms.add(new DungeonState.Room(uuid(room,"id"),text(room.get("name")),bounds(map(room.get("bounds"))),mobs,effects,new DungeonState.Reward(new BigInteger(text(room.get("reward-xp"))),new BigInteger(text(room.get("reward-coins")))),boss));}var mode=row.containsKey("participation-mode")?games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.parse(text(row.get("participation-mode"))):games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;var completion=schema==2?new DungeonState.Reward(new BigInteger(text(row.get("completion-reward-xp"))),new BigInteger(text(row.get("completion-reward-coins")))):rooms.stream().filter(room->room.boss()!=null).map(DungeonState.Room::reward).findFirst().orElse(DungeonState.Reward.empty());if(schema==1)for(int index=0;index<rooms.size();index++){var room=rooms.get(index);rooms.set(index,new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects(),DungeonState.Reward.empty(),room.boss()));}dungeons.add(new DungeonState.Dungeon(uuid(row,"id"),text(row.get("name")),uuid(row,"world"),position(map(row.get("entrance"))),bool(row.get("enabled")),uuid(row,"revision"),rooms,mode,completion,schema==1||bool(row.get("completion-reward-configured"))));}
|
||||
var runs=new ArrayList<DungeonState.Run>();for(var item:list(root.get("runs"))){var row=map(item);var unitClaims=new HashSet<DungeonState.UnitClaim>();for(var value:list(row.get("unit-claims"))){var claim=map(value);unitClaims.add(new DungeonState.UnitClaim(uuid(claim,"room"),uuid(claim,"quest")));}var characterClaims=new HashSet<DungeonState.CharacterClaim>();for(var value:list(row.get("character-claims"))){var claim=map(value);characterClaims.add(new DungeonState.CharacterClaim(uuid(claim,"room"),uuid(claim,"character")));}runs.add(new DungeonState.Run(uuid(row,"dungeon"),uuid(row,"generation"),uuids(row.get("defeated-slots")),uuids(row.get("cleared-rooms")),unitClaims,characterClaims,bool(row.get("defeated"))));}
|
||||
return new DungeonState(dungeons,runs);
|
||||
}catch(RuntimeException invalid){throw new IOException("Invalid dungeon file",invalid);}
|
||||
}
|
||||
public void save(DungeonState state)throws IOException{Objects.requireNonNull(state);var root=new LinkedHashMap<String,Object>();root.put("schema-version",1);root.put("dungeons",state.dungeons().stream().sorted(Comparator.comparing(DungeonState.Dungeon::id)).map(YamlDungeonRepository::dungeon).toList());root.put("runs",state.runs().stream().sorted(Comparator.comparing(v->v.dungeon().toString()+v.generation())).map(YamlDungeonRepository::run).toList());Files.createDirectories(file.getParent());Path temporary=Files.createTempFile(file.getParent(),"dungeons-",".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 dungeon persistence unavailable",unsupported);}DurableFiles.forceDirectory(file.getParent());}finally{Files.deleteIfExists(temporary);}}
|
||||
private static Map<String,Object> dungeon(DungeonState.Dungeon value){return row("id",value.id(),"name",value.name(),"world",value.world(),"entrance",position(value.entrance()),"enabled",value.enabled(),"revision",value.revision(),"rooms",value.rooms().stream().sorted(Comparator.comparing(DungeonState.Room::id)).map(YamlDungeonRepository::room).toList());}
|
||||
public void save(DungeonState state)throws IOException{Objects.requireNonNull(state);var root=new LinkedHashMap<String,Object>();root.put("schema-version",2);root.put("dungeons",state.dungeons().stream().sorted(Comparator.comparing(DungeonState.Dungeon::id)).map(YamlDungeonRepository::dungeon).toList());root.put("runs",state.runs().stream().sorted(Comparator.comparing(v->v.dungeon().toString()+v.generation())).map(YamlDungeonRepository::run).toList());Files.createDirectories(file.getParent());Path temporary=Files.createTempFile(file.getParent(),"dungeons-",".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 dungeon persistence unavailable",unsupported);}DurableFiles.forceDirectory(file.getParent());}finally{Files.deleteIfExists(temporary);}}
|
||||
private static Map<String,Object> dungeon(DungeonState.Dungeon value){return row("id",value.id(),"name",value.name(),"world",value.world(),"entrance",position(value.entrance()),"enabled",value.enabled(),"revision",value.revision(),"participation-mode",value.participationMode().value(),"completion-reward-xp",value.completionReward().xp(),"completion-reward-coins",value.completionReward().coins(),"completion-reward-configured",value.rewardConfigured(),"rooms",value.rooms().stream().sorted(Comparator.comparing(DungeonState.Room::id)).map(YamlDungeonRepository::room).toList());}
|
||||
private static Map<String,Object> room(DungeonState.Room value){return row("id",value.id(),"name",value.name(),"bounds",bounds(value.bounds()),"mobs",value.mobs().stream().sorted(Comparator.comparing(DungeonState.Spawn::id)).map(v->row("id",v.id(),"type",v.type(),"name",v.name(),"position",position(v.position()))).toList(),"effects",value.effects().stream().sorted(Comparator.comparing(DungeonState.Effect::id)).map(v->row("id",v.id(),"target",v.target(),"effect",v.effect(),"level",v.level(),"particles",v.particles())).toList(),"reward-xp",value.reward().xp(),"reward-coins",value.reward().coins(),"boss",value.boss()==null?null:row("type",value.boss().type(),"name",value.boss().name(),"position",position(value.boss().position()),"character",value.boss().character()==null?null:character(value.boss().character())));}
|
||||
private static Map<String,Object> character(DungeonState.CharacterBoss value){return row("operation",value.operation(),"record",CharacterYaml.encode(value.character()),"multiplier",value.multiplier().toPlainString(),"prior-type",value.priorType(),"prior-name",value.priorName(),"recovery",value.recovery()==null?null:row("operation",value.recovery().operation(),"administrator",value.recovery().administrator()));}
|
||||
private static Map<String,Object> run(DungeonState.Run value){return row("dungeon",value.dungeon(),"generation",value.generation(),"defeated-slots",value.defeatedSlots().stream().sorted().toList(),"cleared-rooms",value.clearedRooms().stream().sorted().toList(),"unit-claims",value.unitClaims().stream().sorted(Comparator.comparing(v->v.room().toString()+v.quest())).map(v->row("room",v.room(),"quest",v.quest())).toList(),"character-claims",value.characterClaims().stream().sorted(Comparator.comparing(v->v.room().toString()+v.character())).map(v->row("room",v.room(),"character",v.character())).toList(),"defeated",value.defeated());}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.adventure.FishingState;
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
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;
|
||||
|
||||
/** Atomic safe-YAML authority for virtual fishing stock and non-expiring earned shares. */
|
||||
public final class YamlFishingRepository {
|
||||
private final Path file;
|
||||
public YamlFishingRepository(Path data){file=data.toAbsolutePath().resolve("fishing.yml");}
|
||||
public FishingState load() throws IOException {
|
||||
if(Files.notExists(file))return FishingState.empty();
|
||||
try{
|
||||
var root=map(yaml().load(Files.readString(file,StandardCharsets.UTF_8)));
|
||||
int schema=number(root.get("schema-version")).intValueExact();if(schema<1||schema>2)throw new IllegalArgumentException();
|
||||
var inventory=new ArrayList<FishingState.ItemStack>();for(Object value:list(root.get("inventory"))){var row=map(value);inventory.add(new FishingState.ItemStack(uuid(row,"character"),uuid(row,"item"),uuid(row,"site"),text(row.get("name")),number(row.get("amount"))));}
|
||||
var trophies=new ArrayList<FishingState.Trophy>();if(schema>=2)for(Object value:list(root.get("trophies"))){var row=map(value);var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(Object rewardValue:list(row.get("rewards"))){var reward=map(rewardValue);if(rewards.put(uuid(reward,"character"),new FishingState.Reward(number(reward.get("xp")),number(reward.get("coins"))))!=null)throw new IllegalArgumentException();}trophies.add(new FishingState.Trophy(uuid(row,"id"),FishingState.TrophyType.valueOf(text(row.get("type"))),uuid(row,"carrier"),uuid(row,"catalog"),uuid(row,"site"),uuid(row,"quest"),uuid(row,"source"),text(row.get("name")),text(row.get("origin")),rewards,FishingState.TrophyPhase.valueOf(text(row.get("phase")))));}
|
||||
var trophyReceipts=schema>=2?uuids(root.get("trophy-receipts")):Set.<UUID>of();
|
||||
var catches=new ArrayList<FishingState.Catch>();for(Object value:list(root.get("catches"))){var row=map(value);catches.add(new FishingState.Catch(uuid(row,"id"),uuid(row,"character"),uuid(row,"quest"),uuid(row,"site"),uuid(row,"item"),text(row.get("item-name")),number(row.get("xp")),bool(row.get("item-awarded"))));}
|
||||
var receipts=uuids(root.get("catch-receipts"));
|
||||
var submissions=new ArrayList<FishingState.Submission>();for(Object value:list(root.get("submissions"))){var row=map(value);var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(Object rewardValue:list(row.get("rewards"))){var reward=map(rewardValue);if(rewards.put(uuid(reward,"character"),new FishingState.Reward(number(reward.get("xp")),number(reward.get("coins"))))!=null)throw new IllegalArgumentException();}submissions.add(new FishingState.Submission(uuid(row,"id"),uuid(row,"quest"),uuid(row,"catalog"),uuid(row,"site"),uuid(row,"submitter"),uuid(row,"item"),number(row.get("quantity")),rewards,FishingState.SubmissionPhase.valueOf(text(row.get("phase")))));}
|
||||
var completions=new HashSet<FishingState.Completion>();for(Object value:list(root.get("completions"))){var row=map(value);completions.add(new FishingState.Completion(uuid(row,"catalog"),uuid(row,"site"),uuid(row,"character")));}
|
||||
var allocations=new ArrayList<FishingState.Allocation>();for(Object value:list(root.get("allocations"))){var row=map(value);allocations.add(new FishingState.Allocation(uuid(row,"id"),uuid(row,"submission"),uuid(row,"catalog"),uuid(row,"site"),uuid(row,"character"),number(row.get("xp")),number(row.get("coins"))));}
|
||||
var claims=new ArrayList<FishingState.Claim>();for(Object value:list(root.get("claims"))){var row=map(value);claims.add(new FishingState.Claim(uuid(row,"id"),uuid(row,"character"),uuids(row.get("allocations")),number(row.get("xp")),number(row.get("coins"))));}
|
||||
return new FishingState(inventory,trophies,trophyReceipts,catches,receipts,submissions,completions,allocations,claims);
|
||||
}catch(RuntimeException invalid){throw new IOException("Invalid fishing file",invalid);}
|
||||
}
|
||||
public void save(FishingState state)throws IOException{
|
||||
Objects.requireNonNull(state);var root=new LinkedHashMap<String,Object>();root.put("schema-version",2);
|
||||
root.put("inventory",state.inventory().stream().sorted(Comparator.comparing(value->value.character().toString()+value.item())).map(value->row("character",value.character(),"item",value.item(),"site",value.site(),"name",value.name(),"amount",value.amount())).toList());
|
||||
root.put("trophies",state.trophies().stream().sorted(Comparator.comparing(FishingState.Trophy::id)).map(value->row("id",value.id(),"type",value.type(),"carrier",value.carrier(),"catalog",value.catalog(),"site",value.site(),"quest",value.quest(),"source",value.source(),"name",value.name(),"origin",value.origin(),"phase",value.phase(),"rewards",value.rewards().entrySet().stream().sorted(Map.Entry.comparingByKey()).map(entry->row("character",entry.getKey(),"xp",entry.getValue().xp(),"coins",entry.getValue().coins())).toList())).toList());
|
||||
root.put("trophy-receipts",state.trophyReceipts().stream().sorted().map(UUID::toString).toList());
|
||||
root.put("catches",state.catches().stream().sorted(Comparator.comparing(FishingState.Catch::id)).map(value->row("id",value.id(),"character",value.character(),"quest",value.quest(),"site",value.site(),"item",value.item(),"item-name",value.itemName(),"xp",value.xp(),"item-awarded",value.itemAwarded())).toList());
|
||||
root.put("catch-receipts",state.catchReceipts().stream().sorted().map(UUID::toString).toList());
|
||||
root.put("submissions",state.submissions().stream().sorted(Comparator.comparing(FishingState.Submission::id)).map(value->row("id",value.id(),"quest",value.quest(),"catalog",value.catalog(),"site",value.site(),"submitter",value.submitter(),"item",value.item(),"quantity",value.quantity(),"phase",value.phase(),"rewards",value.rewards().entrySet().stream().sorted(Map.Entry.comparingByKey()).map(entry->row("character",entry.getKey(),"xp",entry.getValue().xp(),"coins",entry.getValue().coins())).toList())).toList());
|
||||
root.put("completions",state.completions().stream().sorted(Comparator.comparing(value->value.catalog().toString()+value.site()+value.character())).map(value->row("catalog",value.catalog(),"site",value.site(),"character",value.character())).toList());
|
||||
root.put("allocations",state.allocations().stream().sorted(Comparator.comparing(FishingState.Allocation::id)).map(value->row("id",value.id(),"submission",value.submission(),"catalog",value.catalog(),"site",value.site(),"character",value.character(),"xp",value.xp(),"coins",value.coins())).toList());
|
||||
root.put("claims",state.claims().stream().sorted(Comparator.comparing(FishingState.Claim::id)).map(value->row("id",value.id(),"character",value.character(),"allocations",value.allocations().stream().sorted().toList(),"xp",value.xp(),"coins",value.coins())).toList());
|
||||
Files.createDirectories(file.getParent());Path temporary=Files.createTempFile(file.getParent(),"fishing-",".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 fishing persistence unavailable",unsupported);}DurableFiles.forceDirectory(file.getParent());}finally{Files.deleteIfExists(temporary);}
|
||||
}
|
||||
private static Map<String,Object> row(Object...pairs){var result=new LinkedHashMap<String,Object>();for(int index=0;index<pairs.length;index+=2)result.put((String)pairs[index],encode(pairs[index+1]));return result;}
|
||||
private static Object encode(Object value){if(value instanceof UUID||value instanceof Enum<?>||value instanceof BigInteger)return value.toString();if(value instanceof Collection<?> values)return values.stream().map(YamlFishingRepository::encode).toList();return value;}
|
||||
private static Yaml yaml(){var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(64);options.setCodePointLimit(4_194_304);return new Yaml(new SafeConstructor(options));}
|
||||
private static Map<?,?> map(Object value){if(value instanceof Map<?,?> result)return result;throw new IllegalArgumentException();}
|
||||
private static List<?> list(Object value){if(value instanceof List<?> result)return result;throw new IllegalArgumentException();}
|
||||
private static String text(Object value){if(value instanceof String result)return result;throw new IllegalArgumentException();}
|
||||
private static boolean bool(Object value){if(value instanceof Boolean result)return result;throw new IllegalArgumentException();}
|
||||
private static BigInteger number(Object value){return new BigInteger(value.toString());}
|
||||
private static UUID uuid(Map<?,?> row,String key){return UUID.fromString(text(row.get(key)));}
|
||||
private static Set<UUID> uuids(Object value){var result=new HashSet<UUID>();for(Object item:list(value))result.add(UUID.fromString(text(item)));return result;}
|
||||
}
|
||||
@@ -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();}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package games.dmg.fantasystick.progression;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Captured plugin-owned encounter authority for distance-spacing exemption. */
|
||||
public record ManagedEncounterOrigin(Kind kind, UUID source) {
|
||||
public enum Kind { DUNGEON, CHAMPION, CAMP }
|
||||
public ManagedEncounterOrigin { Objects.requireNonNull(kind); Objects.requireNonNull(source); }
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.EquipmentSessionStore;
|
||||
import games.dmg.fantasystick.persistence.RevivalStore;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
/** Explicit one-use preview and fail-closed delivery for saved-character sheet recovery. */
|
||||
public final class AdminCharacterRecovery implements Listener, AutoCloseable {
|
||||
public record Failure(UUID character, UUID recipient, String operation, Throwable cause) { }
|
||||
private record View(UUID nonce, Player administrator, Player recipient, CharacterRecord record,
|
||||
int destination, Inventory inventory) { }
|
||||
private record Prerequisites(java.util.Optional<CharacterRecord> record,
|
||||
java.util.Optional<EquipmentSessionStore.State> receipt,
|
||||
java.util.Optional<RevivalStore.Reservation> revival) { }
|
||||
|
||||
private final CharacterStorage characters;
|
||||
private final EquipmentSessionStore receipts;
|
||||
private final RevivalStore revivals;
|
||||
private final EquipmentSessions sessions;
|
||||
private final Server server;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Failure> failures;
|
||||
private final Map<UUID, Object> pending = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, View> views = new ConcurrentHashMap<>();
|
||||
private final CharacterSheets sheets = new CharacterSheets();
|
||||
private volatile boolean closed;
|
||||
|
||||
public AdminCharacterRecovery(CharacterStorage characters, EquipmentSessionStore receipts,
|
||||
RevivalStore revivals, EquipmentSessions sessions, Server server, Consumer<Runnable> nextTick,
|
||||
Consumer<Failure> failures) {
|
||||
this.characters = Objects.requireNonNull(characters);
|
||||
this.receipts = Objects.requireNonNull(receipts);
|
||||
this.revivals = Objects.requireNonNull(revivals);
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
this.server = Objects.requireNonNull(server);
|
||||
this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
public void open(Player administrator, UUID character, Player recipient) {
|
||||
Objects.requireNonNull(administrator); Objects.requireNonNull(character); Objects.requireNonNull(recipient);
|
||||
if (closed || !current(administrator) || !recipient.isOnline()) {
|
||||
administrator.sendMessage("Character recovery refused: administrator or recipient is unavailable.");
|
||||
return;
|
||||
}
|
||||
UUID administratorId = administrator.getUniqueId();
|
||||
UUID recipientId = recipient.getUniqueId();
|
||||
Object ticket = new Object();
|
||||
views.remove(administratorId);
|
||||
pending.put(administratorId, ticket);
|
||||
characters.inspect(character).whenComplete((record, recordFailure) -> {
|
||||
if (recordFailure != null) {
|
||||
schedule(character, recipientId, () -> finishPreview(administratorId, ticket,
|
||||
administrator, recipient, character, null, recordFailure));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var receiptStage = receipts.find(recipientId);
|
||||
receiptStage.whenComplete((receipt, receiptFailure) -> {
|
||||
if (receiptFailure != null) {
|
||||
schedule(character, recipientId, () -> finishPreview(administratorId, ticket,
|
||||
administrator, recipient, character, null, receiptFailure));
|
||||
return;
|
||||
}
|
||||
revivals.find(character).whenComplete((revival, revivalFailure) ->
|
||||
schedule(character, recipientId, () -> finishPreview(administratorId, ticket,
|
||||
administrator, recipient, character,
|
||||
revivalFailure == null ? new Prerequisites(record, receipt, revival) : null,
|
||||
revivalFailure)));
|
||||
});
|
||||
} catch (RuntimeException rejected) {
|
||||
schedule(character, recipientId, () -> finishPreview(administratorId, ticket,
|
||||
administrator, recipient, character, null, rejected));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void finishPreview(UUID administratorId, Object ticket, Player administrator, Player recipient,
|
||||
UUID character, Prerequisites loaded, Throwable failure) {
|
||||
if (closed || !pending.remove(administratorId, ticket)) return;
|
||||
Player currentRecipient = server.getPlayer(recipient.getUniqueId());
|
||||
if (!current(administrator) || currentRecipient != recipient || !recipient.isOnline()) {
|
||||
administrator.sendMessage("Character recovery refused: administrator or recipient changed.");
|
||||
return;
|
||||
}
|
||||
if (failure != null) {
|
||||
report(character, recipient.getUniqueId(), "preview", failure);
|
||||
administrator.sendMessage("Character recovery preview could not be loaded; no state changed.");
|
||||
return;
|
||||
}
|
||||
var record = loaded.record().orElse(null);
|
||||
if (record == null) {
|
||||
administrator.sendMessage("Character recovery refused: saved character not found.");
|
||||
return;
|
||||
}
|
||||
if (loaded.receipt().map(EquipmentSessionStore.State::pending).orElse(null) != null
|
||||
|| loaded.revival().isPresent()
|
||||
|| !sessions.availableForCharacterRecovery(character, recipient.getUniqueId())) {
|
||||
administrator.sendMessage("Character recovery refused: active or unresolved custody must be resolved first.");
|
||||
return;
|
||||
}
|
||||
int destination = recipient.getInventory().firstEmpty();
|
||||
if (destination < 0 || destination >= 36) {
|
||||
administrator.sendMessage("Character recovery refused: recipient needs one empty main-inventory slot.");
|
||||
return;
|
||||
}
|
||||
show(administrator, recipient, record, destination);
|
||||
}
|
||||
|
||||
private void show(Player administrator, Player recipient, CharacterRecord record, int destination) {
|
||||
var inventory = server.createInventory(null, 9, Component.text("Recover saved character"));
|
||||
inventory.setItem(2, action(record.dead() ? Material.BARRIER : Material.PAPER,
|
||||
record.dead() ? "Living recovery unavailable" : "Recover living character sheet", record, recipient));
|
||||
inventory.setItem(6, action(record.dead() ? Material.TOTEM_OF_UNDYING : Material.BARRIER,
|
||||
record.dead() ? "Revive without Tail and recover sheet" : "No-Tail revival unavailable", record, recipient));
|
||||
var view = new View(UUID.randomUUID(), administrator, recipient, record, destination, inventory);
|
||||
views.put(administrator.getUniqueId(), view);
|
||||
administrator.openInventory(inventory);
|
||||
}
|
||||
|
||||
private static ItemStack action(Material material, String label, CharacterRecord record, Player recipient) {
|
||||
var item = new ItemStack(material);
|
||||
var meta = item.getItemMeta();
|
||||
meta.displayName(Component.text(label));
|
||||
var lines = new java.util.ArrayList<Component>();
|
||||
lines.add(Component.text(record.name()));
|
||||
lines.add(Component.text(record.id().toString()));
|
||||
lines.add(Component.text("Level " + record.level() + " — " + (record.dead() ? "dead" : "living")));
|
||||
if (record.deathId() != null) lines.add(Component.text("Death " + record.deathId()));
|
||||
if (record.diedAt() != null) lines.add(Component.text("Died " + record.diedAt()));
|
||||
lines.add(Component.text("Recipient " + recipient.getName() + " — " + recipient.getUniqueId()));
|
||||
lines.add(Component.text("Confirmation invalidates every older sheet, including copies held by anyone."));
|
||||
meta.lore(lines); item.setItemMeta(meta); return item;
|
||||
}
|
||||
|
||||
@EventHandler public void close(org.bukkit.event.inventory.InventoryCloseEvent event) {
|
||||
if (!(event.getPlayer() instanceof Player administrator)) return;
|
||||
var view = views.get(administrator.getUniqueId());
|
||||
if (view != null && event.getInventory() == view.inventory()) views.remove(administrator.getUniqueId(), view);
|
||||
}
|
||||
|
||||
@EventHandler public void click(InventoryClickEvent event) {
|
||||
if (closed || !(event.getWhoClicked() instanceof Player administrator)) return;
|
||||
var view = views.get(administrator.getUniqueId());
|
||||
if (view == null || event.getInventory() != view.inventory()) return;
|
||||
event.setCancelled(true);
|
||||
int slot = event.getRawSlot();
|
||||
boolean revive = slot == 6;
|
||||
if (!(slot == 2 || slot == 6) || revive != view.record().dead()) return;
|
||||
if (!views.remove(administrator.getUniqueId(), view)) return;
|
||||
administrator.closeInventory();
|
||||
confirm(view, revive);
|
||||
}
|
||||
|
||||
private void confirm(View view, boolean revive) {
|
||||
Player administrator = view.administrator(); Player recipient = view.recipient();
|
||||
UUID recipientId = recipient.getUniqueId();
|
||||
if (!current(administrator) || server.getPlayer(recipient.getUniqueId()) != recipient
|
||||
|| !recipient.isOnline() || !empty(recipient, view.destination())) {
|
||||
administrator.sendMessage("Character recovery confirmation became stale; no state changed.");
|
||||
return;
|
||||
}
|
||||
var custody = sessions.claimCharacterRecovery(view.record().id(), recipientId);
|
||||
if (custody.isEmpty()) {
|
||||
administrator.sendMessage("Character recovery confirmation became stale; no state changed.");
|
||||
return;
|
||||
}
|
||||
var claim = custody.orElseThrow();
|
||||
java.util.concurrent.CompletionStage<java.util.Optional<EquipmentSessionStore.State>> receiptRead;
|
||||
try { receiptRead = receipts.find(recipientId); }
|
||||
catch (RuntimeException rejected) {
|
||||
claim.close();
|
||||
report(view.record().id(), recipientId, "receipt", rejected);
|
||||
administrator.sendMessage("Character recovery confirmation became stale; no state changed.");
|
||||
return;
|
||||
}
|
||||
receiptRead.whenComplete((receipt, failure) -> schedule(
|
||||
view.record().id(), recipientId, () -> {
|
||||
if (failure != null || receipt.map(EquipmentSessionStore.State::pending).orElse(null) != null
|
||||
|| !current(administrator) || server.getPlayer(recipient.getUniqueId()) != recipient
|
||||
|| !recipient.isOnline() || !empty(recipient, view.destination())) {
|
||||
claim.close();
|
||||
if (failure != null) report(view.record().id(), recipient.getUniqueId(), "receipt", failure);
|
||||
administrator.sendMessage("Character recovery confirmation became stale; no state changed.");
|
||||
return;
|
||||
}
|
||||
revivals.find(view.record().id()).whenComplete((revival, revivalFailure) -> schedule(
|
||||
view.record().id(), recipientId, () -> {
|
||||
if (revivalFailure != null || revival.isPresent() || !current(administrator)
|
||||
|| server.getPlayer(recipientId) != recipient || !recipient.isOnline()
|
||||
|| !empty(recipient, view.destination())) {
|
||||
claim.close();
|
||||
if (revivalFailure != null) report(view.record().id(), recipientId, "revival", revivalFailure);
|
||||
administrator.sendMessage("Character recovery confirmation became stale; no state changed.");
|
||||
return;
|
||||
}
|
||||
UUID operation = UUID.randomUUID();
|
||||
var request = new CharacterStorage.RecoveryRequest(operation, view.record().id(),
|
||||
administrator.getUniqueId(), recipientId, view.record().revision(),
|
||||
view.record().sheetGeneration(), revive, revive ? view.record().deathId() : null,
|
||||
NativeHealthLimit.read());
|
||||
characters.beginRecovery(request).whenComplete((commit, commitFailure) -> schedule(
|
||||
view.record().id(), recipientId, () -> deliver(view, claim, commit, commitFailure)));
|
||||
}));
|
||||
}));
|
||||
}
|
||||
|
||||
private void deliver(View view, EquipmentSessions.CharacterRecoveryClaim claim,
|
||||
CharacterStorage.RecoveryCommit commit, Throwable failure) {
|
||||
if (failure != null) {
|
||||
report(view.record().id(), view.recipient().getUniqueId(), "commit", failure);
|
||||
view.administrator().sendMessage("Character recovery failed or is retained for inspection; no retry was performed.");
|
||||
return;
|
||||
}
|
||||
if (commit.status() != CharacterStorage.RecoveryStatus.APPLIED || commit.record().isEmpty()) {
|
||||
claim.close();
|
||||
view.administrator().sendMessage("Character recovery refused: saved state or custody changed ("
|
||||
+ commit.status().name().toLowerCase(java.util.Locale.ROOT) + ").");
|
||||
return;
|
||||
}
|
||||
var record = commit.record().orElseThrow();
|
||||
Player recipient = view.recipient();
|
||||
if (!current(view.administrator()) || server.getPlayer(recipient.getUniqueId()) != recipient || !recipient.isOnline()
|
||||
|| !empty(recipient, view.destination())) {
|
||||
view.administrator().sendMessage("Character recovery committed, but native delivery is uncertain; do not retry blindly.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
recipient.getInventory().setItem(view.destination(), sheets.create(record));
|
||||
if (!sheets.authorizes(recipient.getInventory().getItem(view.destination()), record))
|
||||
throw new IllegalStateException("Native inventory did not retain the recovered sheet");
|
||||
} catch (RuntimeException uncertain) {
|
||||
report(record.id(), recipient.getUniqueId(), "native-delivery", uncertain);
|
||||
view.administrator().sendMessage("Character recovery committed, but native delivery is uncertain; do not retry blindly.");
|
||||
return;
|
||||
}
|
||||
try { recipient.updateInventory(); }
|
||||
catch (RuntimeException syncFailure) { report(record.id(), recipient.getUniqueId(), "inventory-sync", syncFailure); }
|
||||
UUID recipientId = recipient.getUniqueId();
|
||||
characters.completeRecovery(record.id(), commit.operation()).whenComplete((completed, completionFailure) ->
|
||||
schedule(record.id(), recipientId, () -> {
|
||||
if (completionFailure != null || !completed) {
|
||||
if (completionFailure != null) report(record.id(), recipient.getUniqueId(), "completion", completionFailure);
|
||||
view.administrator().sendMessage("Recovery sheet was delivered, but durable completion is retained for inspection.");
|
||||
return;
|
||||
}
|
||||
claim.close();
|
||||
if (current(view.administrator())) view.administrator().sendMessage(
|
||||
(view.record().dead() ? "Revived without a Tail and recovered " : "Recovered ")
|
||||
+ record.name() + " for " + recipient.getName() + ". Older sheets are revoked.");
|
||||
if (recipient.isOnline()) recipient.sendMessage("Recovered character sheet: " + record.name() + ".");
|
||||
}));
|
||||
}
|
||||
|
||||
private static boolean empty(Player player, int slot) {
|
||||
if (slot < 0 || slot >= 36) return false;
|
||||
var item = player.getInventory().getItem(slot);
|
||||
return item == null || item.isEmpty();
|
||||
}
|
||||
|
||||
private static boolean authorized(Player player) {
|
||||
return player.isOnline() && player.hasPermission(EquipmentAdminCommand.PERMISSION);
|
||||
}
|
||||
|
||||
private boolean current(Player player) {
|
||||
return authorized(player) && server.getPlayer(player.getUniqueId()) == player;
|
||||
}
|
||||
|
||||
private void schedule(UUID character, UUID recipient, Runnable task) {
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> { if (!closed) task.run(); }); }
|
||||
catch (RuntimeException rejected) { report(character, recipient, "schedule", rejected); }
|
||||
}
|
||||
|
||||
private void report(UUID character, UUID recipient, String operation, Throwable cause) {
|
||||
try { failures.accept(new Failure(character, recipient, operation, cause)); }
|
||||
catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
closed = true; pending.clear();
|
||||
for (var view : views.values()) view.inventory().clear();
|
||||
views.clear();
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ public final class AdminRevival implements Listener, AutoCloseable {
|
||||
private void confirm(View view,Choice choice){if(!view.current()||!unchanged(view.target,choice.candidate)){view.close();return;}characters.find(choice.record.id()).whenComplete((found,failure)->schedule(()->{if(failure!=null){report(failure);view.admin.sendMessage("Admin revival could not recheck that Tail; no item changed.");view.close();return;}if(!view.current()||found.isEmpty()||!eligible(found.orElseThrow(),choice.candidate.identity)||!unchanged(view.target,choice.candidate)){view.admin.sendMessage("That Tail selection is stale; no item changed.");view.close();return;}reserve(view,choice.candidate,found.orElseThrow());}));}
|
||||
private void reserve(View view,Candidate candidate,CharacterRecord dead){var source=view.target.getInventory().getItem(candidate.slot);int destination=source.getAmount()==1?-1:emptySlot(view.target,candidate.slot);if(source.getAmount()>1&&destination<0){view.admin.sendMessage("Admin revival needs one empty direct inventory slot to preserve copied Tails.");view.close();return;}var reservation=new RevivalStore.Reservation(UUID.randomUUID(),view.admin.getUniqueId(),view.target.getUniqueId(),dead.id(),dead.deathId(),clock.instant().truncatedTo(ChronoUnit.SECONDS),candidate.slot,source.getAmount(),destination,true);reservations.reserve(reservation).whenComplete((saved,failure)->schedule(()->{if(failure!=null){report(failure);view.admin.sendMessage("Admin revival reservation failed; no item changed.");view.close();return;}if(saved.isEmpty()||!view.current()||!unchanged(view.target,candidate)){view.admin.sendMessage("Admin revival became stale after reservation; no item changed.");view.close();return;}deliver(view,dead,reservation);}));}
|
||||
private static int emptySlot(Player target,int source){for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40||slot==source)continue;var item=target.getInventory().getItem(slot);if(item==null||item.isEmpty())return slot;}return-1;}
|
||||
private void deliver(View view,CharacterRecord dead,RevivalStore.Reservation reservation){try{var provisional=new CharacterRecord(dead.id(),dead.name(),dead.revision(),dead.stats(),dead.level(),dead.experience(),dead.unspentPoints(),dead.maximumHealth(),dead.learned(),dead.loadout(),dead.passives(),dead.cooldowns(),dead.restrictedKills(),false,dead.deathId(),dead.diedAt());var sheet=sheets.create(provisional);var source=view.target.getInventory().getItem(reservation.sourceSlot());if(reservation.sourceAmount()==1)view.target.getInventory().setItem(reservation.sourceSlot(),sheet);else{view.target.getInventory().setItem(reservation.destinationSlot(),sheet);var remaining=source.clone();remaining.setAmount(reservation.sourceAmount()-1);view.target.getInventory().setItem(reservation.sourceSlot(),remaining);}view.target.updateInventory();}catch(RuntimeException uncertain){report(uncertain);view.admin.sendMessage("Admin revival delivery is uncertain; retained proof requires recovery.");view.close();return;}characters.restoreDeathRecordAdministratively(reservation.character(),reservation.death(),NativeHealthLimit.read()).whenComplete((restored,failure)->schedule(()->{if(failure!=null||restored.isEmpty()){if(failure!=null)report(failure);view.admin.sendMessage("The Tail changed, but restoration is pending proof-based recovery.");view.close();return;}if(installFinal(view.target,restored.orElseThrow())){reservations.clear(reservation.character(),reservation.id()).exceptionally(error->{report(error);return false;});view.admin.sendMessage("Administratively restored "+restored.orElseThrow().name()+" for "+view.target.getName()+".");view.target.sendMessage("An administrator restored character sheet: "+restored.orElseThrow().name()+".");}else view.admin.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");view.close();}));}
|
||||
private void deliver(View view,CharacterRecord dead,RevivalStore.Reservation reservation){try{var provisional=new CharacterRecord(dead.id(),dead.name(),dead.revision(),dead.stats(),dead.level(),dead.experience(),dead.unspentPoints(),dead.maximumHealth(),dead.learned(),dead.loadout(),dead.passives(),dead.cooldowns(),dead.restrictedKills(),false,dead.deathId(),dead.diedAt(),dead.sheetGeneration());var sheet=sheets.create(provisional);var source=view.target.getInventory().getItem(reservation.sourceSlot());if(reservation.sourceAmount()==1)view.target.getInventory().setItem(reservation.sourceSlot(),sheet);else{view.target.getInventory().setItem(reservation.destinationSlot(),sheet);var remaining=source.clone();remaining.setAmount(reservation.sourceAmount()-1);view.target.getInventory().setItem(reservation.sourceSlot(),remaining);}view.target.updateInventory();}catch(RuntimeException uncertain){report(uncertain);view.admin.sendMessage("Admin revival delivery is uncertain; retained proof requires recovery.");view.close();return;}characters.restoreDeathRecordAdministratively(reservation.character(),reservation.death(),NativeHealthLimit.read()).whenComplete((restored,failure)->schedule(()->{if(failure!=null||restored.isEmpty()){if(failure!=null)report(failure);view.admin.sendMessage("The Tail changed, but restoration is pending proof-based recovery.");view.close();return;}if(installFinal(view.target,restored.orElseThrow())){reservations.clear(reservation.character(),reservation.id()).exceptionally(error->{report(error);return false;});view.admin.sendMessage("Administratively restored "+restored.orElseThrow().name()+" for "+view.target.getName()+".");view.target.sendMessage("An administrator restored character sheet: "+restored.orElseThrow().name()+".");}else view.admin.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");view.close();}));}
|
||||
private boolean installFinal(Player target,CharacterRecord record){int found=-1;for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40)continue;if(sheets.identify(target.getInventory().getItem(slot)).filter(record.id()::equals).isEmpty())continue;if(found>=0)return false;found=slot;}if(found<0)return false;target.getInventory().setItem(found,sheets.create(record));target.updateInventory();return true;}
|
||||
private void schedule(Runnable action){try{nextTick.accept(action);}catch(RuntimeException rejected){report(rejected);}}
|
||||
private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.InventoryHolder;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
/** Native freeze, bounded death decision menu and timeout publication for durable episodes. */
|
||||
public final class DownedController implements Listener,AutoCloseable{
|
||||
public enum RescueStatus{APPLIED,APPLIED_PENDING_RECOVERY,STALE,EXPIRED,FAILED}
|
||||
private record Holder(UUID episode)implements InventoryHolder{public Inventory getInventory(){return null;}}
|
||||
private final Plugin plugin;private final EquipmentSessions sessions;private final DownedDirectory directory;private final Clock clock;private final Consumer<Throwable> failures;private final java.util.Map<UUID,Boolean> gravity=new java.util.HashMap<>();private final java.util.Set<UUID> revertingSlots=new java.util.HashSet<>();private final java.util.Set<UUID> endedProtection=new java.util.HashSet<>();private games.dmg.fantasystick.events.DownedEventBridge events;private games.dmg.fantasystick.shop.PhoenixDownService phoenix;private org.bukkit.scheduler.BukkitTask pulse;private boolean closed;
|
||||
public DownedController(Plugin plugin,EquipmentSessions sessions,DownedDirectory directory,Clock clock,Consumer<Throwable> failures){this.plugin=Objects.requireNonNull(plugin);this.sessions=Objects.requireNonNull(sessions);this.directory=Objects.requireNonNull(directory);this.clock=Objects.requireNonNull(clock);this.failures=Objects.requireNonNull(failures);}
|
||||
public void attachEvents(games.dmg.fantasystick.events.DownedEventBridge events){if(pulse!=null||this.events!=null)throw new IllegalStateException("Downed events already attached or controller installed");this.events=Objects.requireNonNull(events);}
|
||||
public void attachPhoenix(games.dmg.fantasystick.shop.PhoenixDownService phoenix){if(pulse!=null||this.phoenix!=null)throw new IllegalStateException("Phoenix Down already attached or controller installed");this.phoenix=Objects.requireNonNull(phoenix);}
|
||||
public void install(){if(pulse!=null)throw new IllegalStateException("Downed controller already installed");pulse=plugin.getServer().getScheduler().runTaskTimer(plugin,this::pulse,20L,20L);var existing=directory.all();if(events!=null)events.reconcile(existing);for(var episode:existing){if(episode.outcome()==DownedEpisode.Outcome.REVIVED&&episode.protectionUntil()!=null&&!clock.instant().isBefore(episode.protectionUntil()))endedProtection.add(episode.id());if(episode.phase()==DownedEpisode.Phase.PENDING){if(clock.instant().isBefore(episode.expiresAt()))open(episode);else finalizeDeath(episode,"timeout");}else if(episode.outcome()==DownedEpisode.Outcome.FINAL_DEATH&&!episode.published())sessions.finalizeDowned(episode);}}
|
||||
public void open(DownedEpisode episode){if(closed||episode.phase()!=DownedEpisode.Phase.PENDING)return;var player=plugin.getServer().getPlayer(episode.player());if(player==null||!player.isOnline())return;freeze(player);var inventory=plugin.getServer().createInventory(new Holder(episode.id()),27,Component.text("Downed — "+remaining(episode)+"s"));inventory.setItem(11,item(Material.SKELETON_SKULL,"Accept Death",true));var quantity=phoenix==null?java.math.BigInteger.ZERO:phoenix.quantity(episode.character());inventory.setItem(13,quantity.signum()>0?phoenix(quantity):item(Material.GRAY_DYE,"Use Phoenix Down — unavailable",false));inventory.setItem(15,item(Material.GRAY_DYE,"Wait for Revive",false));player.openInventory(inventory);}
|
||||
private static ItemStack item(Material material,String name,boolean action){var item=new ItemStack(material);var meta=item.getItemMeta();meta.displayName(Component.text(name));if(!action)meta.lore(java.util.List.of(Component.text("Waiting does not pause the countdown.")));item.setItemMeta(meta);return item;}
|
||||
private static ItemStack phoenix(java.math.BigInteger quantity){var item=new ItemStack(Material.FEATHER);var meta=item.getItemMeta();meta.displayName(Component.text("Use Phoenix Down — "+quantity+" available"));meta.lore(java.util.List.of(Component.text("Consumes one only if this rescue wins.")));item.setItemMeta(meta);return item;}
|
||||
private long remaining(DownedEpisode episode){var remaining=java.time.Duration.between(clock.instant(),episode.expiresAt());if(remaining.isNegative()||remaining.isZero())return 0;return remaining.getSeconds()+(remaining.getNano()==0?0:1);}
|
||||
private void pulse(){if(closed)return;for(var episode:directory.all()){if(episode.phase()!=DownedEpisode.Phase.PENDING){if(episode.outcome()==DownedEpisode.Outcome.REVIVED&&episode.protectionUntil()!=null&&!clock.instant().isBefore(episode.protectionUntil())&&endedProtection.add(episode.id())&&events!=null)events.protectionEnded(episode);continue;}if(!clock.instant().isBefore(episode.expiresAt()))finalizeDeath(episode,"timeout");else{var player=plugin.getServer().getPlayer(episode.player());if(player!=null&&player.isOnline()){if(player.getOpenInventory().getTopInventory().getHolder() instanceof Holder holder&&holder.episode().equals(episode.id()))player.getOpenInventory().setTitle("Downed — "+remaining(episode)+"s");else open(episode);}}}}
|
||||
public boolean downed(Player player){return episode(player)!=null;}
|
||||
public DownedEpisode episode(Player player){return sessions.downed(player.getUniqueId()).orElse(null);}
|
||||
private void freeze(Player player){gravity.putIfAbsent(player.getUniqueId(),player.hasGravity());player.setGravity(false);player.setFallDistance(0);player.setVelocity(new org.bukkit.util.Vector());if(player.isInsideVehicle())player.leaveVehicle();}
|
||||
private void release(Player player){var original=gravity.remove(player.getUniqueId());if(original!=null)player.setGravity(original);revertingSlots.remove(player.getUniqueId());}
|
||||
public void revive(Player target,Duration protection,Consumer<RescueStatus> result){revive(target,protection,null,result);}
|
||||
public void revive(Player target,Duration protection,DownedEpisode.Decision decision,Consumer<RescueStatus> result){var episode=sessions.downed(target.getUniqueId()).orElse(null);if(episode==null){result.accept(RescueStatus.STALE);return;}var operation=decision==null?UUID.randomUUID():decision.actionId();directory.claim(episode.id(),DownedEpisode.Outcome.REVIVED,operation,decision,protection,java.util.List.of()).whenComplete((claim,failure)->schedule(()->{if(failure!=null){report(failure);result.accept(RescueStatus.FAILED);return;}if(claim.status()==DownedDirectory.ClaimStatus.EXPIRED){result.accept(RescueStatus.EXPIRED);return;}if(claim.status()!=DownedDirectory.ClaimStatus.APPLIED){result.accept(RescueStatus.STALE);return;}var resolved=claim.episode().orElseThrow();if(events!=null&&!"phoenix_down".equals(resolved.decision()==null?null:resolved.decision().source())){events.resolved(resolved);events.abilityResolved(resolved);}sessions.reviveDowned(resolved,applied->{if(!applied){result.accept(RescueStatus.FAILED);return;}release(target);if(events!=null)events.outcomePublished(resolved);directory.publish(resolved.id(),resolved.outcomeOperation()).whenComplete((published,publishFailure)->schedule(()->{if(publishFailure!=null){report(publishFailure);result.accept(RescueStatus.APPLIED_PENDING_RECOVERY);}else{if(events!=null)events.outcomePublished(published);result.accept(RescueStatus.APPLIED);}}));});}));}
|
||||
private void finalizeDeath(DownedEpisode episode,String source){java.util.List<DownedEpisode.Reward> rewards;try{rewards=sessions.finalRewards(episode);}catch(RuntimeException failure){report(failure);return;}var operation=UUID.randomUUID();var decision=new DownedEpisode.Decision(source,"accept_death".equals(source)?episode.player():null,"accept_death".equals(source)?episode.playerName():null,"accept_death".equals(source)?episode.character():null,"accept_death".equals(source)?episode.characterName():null,"accept_death".equals(source)?episode.session():null,operation,null,null,null,null);directory.claim(episode.id(),DownedEpisode.Outcome.FINAL_DEATH,operation,decision,Duration.ZERO,rewards).whenComplete((claim,failure)->schedule(()->{if(failure!=null){report(failure);return;}if(claim.status()==DownedDirectory.ClaimStatus.APPLIED){var resolved=claim.episode().orElseThrow();if(events!=null)events.resolved(resolved);var player=plugin.getServer().getPlayer(episode.player());if(player!=null)release(player);sessions.finalizeDowned(resolved);}}));}
|
||||
@EventHandler(priority=EventPriority.HIGHEST)public void click(org.bukkit.event.inventory.InventoryClickEvent event){if(event.getWhoClicked() instanceof Player actor&&sessions.downed(actor.getUniqueId()).isPresent())event.setCancelled(true);if(!(event.getView().getTopInventory().getHolder() instanceof Holder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player))return;if(event.getRawSlot()==13&&phoenix!=null){phoenix.use(player,holder.episode(),outcome->{if(outcome.status()!=games.dmg.fantasystick.shop.PhoenixDownService.Status.APPLIED&&outcome.status()!=games.dmg.fantasystick.shop.PhoenixDownService.Status.APPLIED_PENDING_RECOVERY)player.sendMessage("Phoenix Down use refused: "+outcome.status().name().toLowerCase(java.util.Locale.ROOT).replace('_',' ')+".");});return;}if(event.getRawSlot()!=11)return;var episode=directory.all().stream().filter(value->value.id().equals(holder.episode())&&value.player().equals(player.getUniqueId())&&value.phase()==DownedEpisode.Phase.PENDING).findFirst().orElse(null);if(episode!=null)finalizeDeath(episode,"accept_death");}
|
||||
@EventHandler(priority=EventPriority.HIGHEST)public void drag(org.bukkit.event.inventory.InventoryDragEvent event){if(event.getWhoClicked() instanceof Player actor&&sessions.downed(actor.getUniqueId()).isPresent()||event.getView().getTopInventory().getHolder() instanceof Holder)event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void closeMenu(org.bukkit.event.inventory.InventoryCloseEvent event){if(!(event.getView().getTopInventory().getHolder() instanceof Holder holder)||closed)return;plugin.getServer().getScheduler().runTask(plugin,()->directory.all().stream().filter(value->value.id().equals(holder.episode())&&value.phase()==DownedEpisode.Phase.PENDING).findFirst().ifPresent(this::open));}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void damage(org.bukkit.event.entity.EntityDamageEvent event){if(!(event.getEntity() instanceof Player player))return;var episode=sessions.downed(player.getUniqueId()).orElse(null);boolean protectedRevive=episode==null&&sessions.downedProtected(player.getUniqueId());if(episode!=null||protectedRevive){event.setCancelled(true);if(events!=null){var authority=episode!=null?episode:directory.all().stream().filter(value->value.player().equals(player.getUniqueId())&&value.outcome()==DownedEpisode.Outcome.REVIVED&&value.protectionUntil()!=null&&clock.instant().isBefore(value.protectionUntil())).max(java.util.Comparator.comparing(DownedEpisode::resolvedAt)).orElse(null);if(authority!=null)events.prevented(authority,episode!=null?"downed_waiting":authority.decision()!=null&&"phoenix_down".equals(authority.decision().source())?"phoenix_down_protection":"revive_protection",Math.max(0,event.getFinalDamage()));}}}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void heal(org.bukkit.event.entity.EntityRegainHealthEvent event){if(event.getEntity() instanceof Player player&&sessions.downed(player.getUniqueId()).isPresent())event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void move(org.bukkit.event.player.PlayerMoveEvent event){var episode=sessions.downed(event.getPlayer().getUniqueId()).orElse(null);if(episode==null||event.getTo()==null)return;if(event.getFrom().getX()!=event.getTo().getX()||event.getFrom().getY()!=event.getTo().getY()||event.getFrom().getZ()!=event.getTo().getZ())event.setTo(event.getFrom());}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void teleport(org.bukkit.event.player.PlayerTeleportEvent event){if(sessions.downed(event.getPlayer().getUniqueId()).isPresent()&&!sessions.downedRelocation(event.getPlayer().getUniqueId()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void velocity(org.bukkit.event.player.PlayerVelocityEvent event){if(sessions.downed(event.getPlayer().getUniqueId()).isPresent())event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void drop(org.bukkit.event.player.PlayerDropItemEvent event){if(sessions.downed(event.getPlayer().getUniqueId()).isPresent())event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void swap(org.bukkit.event.player.PlayerSwapHandItemsEvent event){if(sessions.downed(event.getPlayer().getUniqueId()).isPresent())event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void interact(org.bukkit.event.player.PlayerInteractEvent event){if(sessions.downed(event.getPlayer().getUniqueId()).isPresent())event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void interactEntity(org.bukkit.event.player.PlayerInteractEntityEvent event){if(sessions.downed(event.getPlayer().getUniqueId()).isPresent())event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void attack(org.bukkit.event.entity.EntityDamageByEntityEvent event){if(event.getDamager() instanceof Player player&&sessions.downed(player.getUniqueId()).isPresent())event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void consume(org.bukkit.event.player.PlayerItemConsumeEvent event){if(sessions.downed(event.getPlayer().getUniqueId()).isPresent())event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void hunger(org.bukkit.event.entity.FoodLevelChangeEvent event){if(event.getEntity() instanceof Player player&&sessions.downed(player.getUniqueId()).isPresent())event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void effect(org.bukkit.event.entity.EntityPotionEffectEvent event){if(event.getEntity() instanceof Player player&&sessions.downed(player.getUniqueId()).isPresent())event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void vehicle(org.bukkit.event.vehicle.VehicleEnterEvent event){if(event.getEntered() instanceof Player player&&sessions.downed(player.getUniqueId()).isPresent())event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void flight(org.bukkit.event.player.PlayerToggleFlightEvent event){if(sessions.downed(event.getPlayer().getUniqueId()).isPresent())event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void slot(io.papermc.paper.event.player.PlayerInventorySlotChangeEvent event){var player=event.getPlayer();if(sessions.downed(player.getUniqueId()).isEmpty()||!revertingSlots.add(player.getUniqueId()))return;var old=event.getOldItemStack()==null?null:event.getOldItemStack().clone();schedule(()->{try{if(sessions.downed(player.getUniqueId()).isPresent())player.getInventory().setItem(event.getSlot(),old);}finally{revertingSlots.remove(player.getUniqueId());}});}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void quit(org.bukkit.event.player.PlayerQuitEvent event){release(event.getPlayer());}
|
||||
private void schedule(Runnable action){try{plugin.getServer().getScheduler().runTask(plugin,action);}catch(RuntimeException failure){report(failure);}}private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}
|
||||
@Override public void close(){closed=true;if(pulse!=null)pulse.cancel();pulse=null;for(UUID player:java.util.List.copyOf(gravity.keySet())){var online=plugin.getServer().getPlayer(player);if(online!=null)release(online);}gravity.clear();revertingSlots.clear();endedProtection.clear();}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.time.*;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Serialized forced-atomic authority for durable downed episodes and their one outcome. */
|
||||
public final class DownedDirectory implements AutoCloseable{
|
||||
public enum ClaimStatus{APPLIED,EXPIRED,ALREADY_RESOLVED,NOT_FOUND}
|
||||
public record Claim(ClaimStatus status,Optional<DownedEpisode> episode){}
|
||||
private final Path file;private final Clock clock;private final ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-downed-persistence"));private volatile Map<UUID,DownedEpisode> snapshot;private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
public DownedDirectory(Path data,Clock clock)throws IOException{file=data.toAbsolutePath().resolve("downed.yml");this.clock=Objects.requireNonNull(clock);snapshot=load();}
|
||||
public CompletableFuture<Optional<DownedEpisode>> find(UUID character){return submit(()->Optional.ofNullable(snapshot.get(character)));}
|
||||
public List<DownedEpisode> all(){return List.copyOf(snapshot.values());}
|
||||
public CompletableFuture<DownedEpisode> begin(UUID character,UUID player,UUID session,UUID world,double x,double y,double z,float yaw,float pitch,Duration timeout,DownedEpisode.Attribution attribution){return begin(character,player,session,world,x,y,z,yaw,pitch,world,x,y,z,yaw,pitch,null,null,null,timeout,attribution);}
|
||||
public CompletableFuture<DownedEpisode> begin(UUID character,UUID player,UUID session,UUID world,double x,double y,double z,float yaw,float pitch,UUID entryWorld,double entryX,double entryY,double entryZ,float entryYaw,float entryPitch,Duration timeout,DownedEpisode.Attribution attribution){return begin(character,player,session,world,x,y,z,yaw,pitch,entryWorld,entryX,entryY,entryZ,entryYaw,entryPitch,null,null,null,timeout,attribution);}
|
||||
public CompletableFuture<DownedEpisode> begin(UUID character,UUID player,UUID session,UUID world,double x,double y,double z,float yaw,float pitch,UUID entryWorld,double entryX,double entryY,double entryZ,float entryYaw,float entryPitch,java.math.BigInteger characterRevision,Duration timeout,DownedEpisode.Attribution attribution){return begin(character,player,session,world,x,y,z,yaw,pitch,entryWorld,entryX,entryY,entryZ,entryYaw,entryPitch,null,null,characterRevision,timeout,attribution);}
|
||||
public CompletableFuture<DownedEpisode> begin(UUID character,UUID player,UUID session,UUID world,double x,double y,double z,float yaw,float pitch,UUID entryWorld,double entryX,double entryY,double entryZ,float entryYaw,float entryPitch,String characterName,String playerName,java.math.BigInteger characterRevision,Duration timeout,DownedEpisode.Attribution attribution){Objects.requireNonNull(timeout);if(timeout.isZero()||timeout.isNegative()||timeout.compareTo(Duration.ofDays(365))>0)throw new IllegalArgumentException("Invalid downed timeout");return submit(()->{var existing=snapshot.get(character);if(existing!=null&&existing.phase()==DownedEpisode.Phase.PENDING)return existing;if(snapshot.values().stream().anyMatch(value->value.player().equals(player)&&value.phase()==DownedEpisode.Phase.PENDING))throw new IOException("Player already has a downed episode");var now=clock.instant().truncatedTo(ChronoUnit.SECONDS);var value=new DownedEpisode(UUID.randomUUID(),character,player,session,world,x,y,z,yaw,pitch,entryWorld,entryX,entryY,entryZ,entryYaw,entryPitch,now,now.plus(timeout),attribution,characterName,playerName,characterRevision,DownedEpisode.Phase.PENDING,null,null,null,null,null,List.of(),false);var next=new LinkedHashMap<>(snapshot);next.put(character,value);save(next);return value;});}
|
||||
public CompletableFuture<Claim> claim(UUID episodeId,DownedEpisode.Outcome outcome,UUID operation,Duration protection){return claim(episodeId,outcome,operation,null,protection,List.of());}
|
||||
public CompletableFuture<Claim> claim(UUID episodeId,DownedEpisode.Outcome outcome,UUID operation,Duration protection,List<DownedEpisode.Reward> rewards){return claim(episodeId,outcome,operation,null,protection,rewards);}
|
||||
public CompletableFuture<Claim> claim(UUID episodeId,DownedEpisode.Outcome outcome,UUID operation,DownedEpisode.Decision decision,Duration protection,List<DownedEpisode.Reward> rewards){Objects.requireNonNull(episodeId);Objects.requireNonNull(outcome);Objects.requireNonNull(operation);Objects.requireNonNull(protection);if(decision!=null&&!operation.equals(decision.actionId()))throw new IllegalArgumentException("Decision action does not match outcome operation");rewards=List.copyOf(rewards);if(protection.isNegative()||protection.compareTo(Duration.ofDays(365))>0||outcome==DownedEpisode.Outcome.REVIVED&&!rewards.isEmpty())throw new IllegalArgumentException("Invalid protection or rewards");var capturedRewards=rewards;return submit(()->{var current=snapshot.values().stream().filter(value->value.id().equals(episodeId)).findFirst().orElse(null);if(current==null)return new Claim(ClaimStatus.NOT_FOUND,Optional.empty());if(current.phase()==DownedEpisode.Phase.RESOLVED)return new Claim(ClaimStatus.ALREADY_RESOLVED,Optional.of(current));var now=clock.instant().truncatedTo(ChronoUnit.SECONDS);if(outcome==DownedEpisode.Outcome.REVIVED&&!now.isBefore(current.expiresAt()))return new Claim(ClaimStatus.EXPIRED,Optional.of(current));var resolvedAt=outcome==DownedEpisode.Outcome.FINAL_DEATH&&!now.isBefore(current.expiresAt())?current.expiresAt():now;var protectionUntil=outcome==DownedEpisode.Outcome.REVIVED?resolvedAt.plus(protection):null;var resolved=new DownedEpisode(current.id(),current.character(),current.player(),current.session(),current.world(),current.x(),current.y(),current.z(),current.yaw(),current.pitch(),current.entryWorld(),current.entryX(),current.entryY(),current.entryZ(),current.entryYaw(),current.entryPitch(),current.downedAt(),current.expiresAt(),current.attribution(),current.characterName(),current.playerName(),current.characterRevision(),DownedEpisode.Phase.RESOLVED,outcome,operation,resolvedAt,protectionUntil,decision,capturedRewards,false);var next=new LinkedHashMap<>(snapshot);next.put(resolved.character(),resolved);save(next);return new Claim(ClaimStatus.APPLIED,Optional.of(resolved));});}
|
||||
public CompletableFuture<DownedEpisode> publish(UUID episodeId,UUID operation){Objects.requireNonNull(episodeId);Objects.requireNonNull(operation);return submit(()->{var current=snapshot.values().stream().filter(value->value.id().equals(episodeId)).findFirst().orElseThrow(()->new IOException("Downed episode not found"));if(current.phase()!=DownedEpisode.Phase.RESOLVED||!operation.equals(current.outcomeOperation()))throw new IOException("Downed outcome operation does not match");if(current.published())return current;var published=new DownedEpisode(current.id(),current.character(),current.player(),current.session(),current.world(),current.x(),current.y(),current.z(),current.yaw(),current.pitch(),current.entryWorld(),current.entryX(),current.entryY(),current.entryZ(),current.entryYaw(),current.entryPitch(),current.downedAt(),current.expiresAt(),current.attribution(),current.characterName(),current.playerName(),current.characterRevision(),current.phase(),current.outcome(),current.outcomeOperation(),current.resolvedAt(),current.protectionUntil(),current.decision(),current.finalRewards(),true);var next=new LinkedHashMap<>(snapshot);next.put(published.character(),published);save(next);return published;});}
|
||||
|
||||
private Map<UUID,DownedEpisode> load()throws IOException{if(Files.notExists(file))return Map.of();try{if(!Files.isRegularFile(file,LinkOption.NOFOLLOW_LINKS)||Files.isSymbolicLink(file)||Files.size(file)>1_048_576)throw new IllegalArgumentException();Object decoded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));if(!(decoded instanceof Map<?,?> root)||!"1".equals(String.valueOf(root.get("schema-version"))))throw new IllegalArgumentException();var result=new LinkedHashMap<UUID,DownedEpisode>();for(Object raw:list(root.get("episodes"))){var row=map(raw);var value=decode(row);if(result.put(value.character(),value)!=null)throw new IllegalArgumentException();}return Map.copyOf(result);}catch(RuntimeException invalid){throw new IOException("Invalid downed state",invalid);}}
|
||||
private void save(Map<UUID,DownedEpisode> state)throws IOException{Files.createDirectories(file.getParent());var root=new LinkedHashMap<String,Object>();root.put("schema-version","1");root.put("episodes",state.values().stream().sorted(Comparator.comparing(DownedEpisode::character)).map(this::encode).toList());Path temporary=Files.createTempFile(file.getParent(),".downed-",".tmp");try{byte[] bytes=yaml().dump(root).getBytes(StandardCharsets.UTF_8);try(var out=FileChannel.open(temporary,StandardOpenOption.WRITE)){var buffer=ByteBuffer.wrap(bytes);while(buffer.hasRemaining())out.write(buffer);out.force(true);}Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);try(var directory=FileChannel.open(file.getParent(),StandardOpenOption.READ)){directory.force(true);}snapshot=Map.copyOf(state);}finally{Files.deleteIfExists(temporary);}}
|
||||
private Map<String,Object> encode(DownedEpisode value){var row=new LinkedHashMap<String,Object>();row.put("id",value.id().toString());row.put("character",value.character().toString());row.put("player",value.player().toString());row.put("session",value.session().toString());row.put("world",value.world().toString());row.put("x",BigDecimal.valueOf(value.x()).toPlainString());row.put("y",BigDecimal.valueOf(value.y()).toPlainString());row.put("z",BigDecimal.valueOf(value.z()).toPlainString());row.put("yaw",Float.toString(value.yaw()));row.put("pitch",Float.toString(value.pitch()));row.put("entry-world",value.entryWorld().toString());row.put("entry-x",BigDecimal.valueOf(value.entryX()).toPlainString());row.put("entry-y",BigDecimal.valueOf(value.entryY()).toPlainString());row.put("entry-z",BigDecimal.valueOf(value.entryZ()).toPlainString());row.put("entry-yaw",Float.toString(value.entryYaw()));row.put("entry-pitch",Float.toString(value.entryPitch()));row.put("downed-at",value.downedAt().toString());row.put("expires-at",value.expiresAt().toString());row.put("attacker",value.attribution().attacker()==null?null:value.attribution().attacker().toString());row.put("cause",value.attribution().cause());row.put("player-defeat",value.attribution().playerDefeat());row.put("contributors",value.attribution().contributors().stream().map(UUID::toString).collect(java.util.stream.Collectors.toCollection(ArrayList::new)));row.put("victim-level",value.attribution().victimLevel());row.put("victim-coins",value.attribution().victimCoins().toString());row.put("character-name",value.characterName());row.put("player-name",value.playerName());row.put("character-revision",value.characterRevision()==null?null:value.characterRevision().toString());row.put("phase",value.phase().name());row.put("outcome",value.outcome()==null?null:value.outcome().name());row.put("outcome-operation",value.outcomeOperation()==null?null:value.outcomeOperation().toString());row.put("resolved-at",value.resolvedAt()==null?null:value.resolvedAt().toString());row.put("protection-until",value.protectionUntil()==null?null:value.protectionUntil().toString());var decision=value.decision();row.put("decision-source",decision==null?null:decision.source());row.put("decision-actor-player",decision==null||decision.actorPlayer()==null?null:decision.actorPlayer().toString());row.put("decision-actor-player-name",decision==null?null:decision.actorPlayerName());row.put("decision-actor-character",decision==null||decision.actorCharacter()==null?null:decision.actorCharacter().toString());row.put("decision-actor-character-name",decision==null?null:decision.actorCharacterName());row.put("decision-actor-session",decision==null||decision.actorSession()==null?null:decision.actorSession().toString());row.put("decision-action",decision==null?null:decision.actionId().toString());row.put("decision-cast-at",decision==null||decision.castAt()==null?null:decision.castAt().toString());row.put("decision-cooldown-until",decision==null||decision.cooldownUntil()==null?null:decision.cooldownUntil().toString());row.put("decision-final-range",decision==null||decision.finalRange()==null?null:Double.toString(decision.finalRange()));row.put("decision-line-of-sight",decision==null?null:decision.lineOfSight());var phoenix=decision==null?null:decision.phoenix();row.put("decision-phoenix-quantity-before",phoenix==null?null:phoenix.quantityBefore().toString());row.put("decision-phoenix-quantity-after",phoenix==null?null:phoenix.quantityAfter().toString());row.put("decision-phoenix-health-before",phoenix==null?null:phoenix.healthBefore().toPlainString());row.put("decision-phoenix-health-after",phoenix==null?null:phoenix.healthAfter().toPlainString());row.put("decision-phoenix-maximum",phoenix==null?null:phoenix.maximum().toPlainString());row.put("decision-phoenix-entry-point",phoenix==null?null:phoenix.entryPoint());row.put("final-rewards",value.finalRewards().stream().map(reward->{var saved=new LinkedHashMap<String,Object>();saved.put("character",reward.character().toString());saved.put("experience",reward.experience().toString());saved.put("coins",reward.coins().toString());return saved;}).collect(java.util.stream.Collectors.toCollection(ArrayList::new)));row.put("published",value.published());return row;}
|
||||
private DownedEpisode decode(Map<?,?> row){
|
||||
String attacker=optional(row,"attacker"),outcome=optional(row,"outcome"),operation=optional(row,"outcome-operation"),resolved=optional(row,"resolved-at"),protection=optional(row,"protection-until"),revision=optional(row,"character-revision"),characterName=optional(row,"character-name"),playerName=optional(row,"player-name"),decisionSource=optional(row,"decision-source"),decisionPlayer=optional(row,"decision-actor-player"),decisionPlayerName=optional(row,"decision-actor-player-name"),decisionCharacter=optional(row,"decision-actor-character"),decisionCharacterName=optional(row,"decision-actor-character-name"),decisionSession=optional(row,"decision-actor-session"),decisionAction=optional(row,"decision-action"),decisionCastAt=optional(row,"decision-cast-at"),decisionCooldown=optional(row,"decision-cooldown-until"),decisionRange=optional(row,"decision-final-range");
|
||||
UUID world=uuid(row,"world"),entryWorld=row.containsKey("entry-world")?uuid(row,"entry-world"):world;
|
||||
double x=Double.parseDouble(text(row,"x")),y=Double.parseDouble(text(row,"y")),z=Double.parseDouble(text(row,"z"));
|
||||
float yaw=Float.parseFloat(text(row,"yaw")),pitch=Float.parseFloat(text(row,"pitch"));
|
||||
var phoenixQuantity=optional(row,"decision-phoenix-quantity-before");var phoenix=phoenixQuantity==null?null:new DownedEpisode.Phoenix(new java.math.BigInteger(phoenixQuantity),new java.math.BigInteger(text(row,"decision-phoenix-quantity-after")),new java.math.BigDecimal(text(row,"decision-phoenix-health-before")),new java.math.BigDecimal(text(row,"decision-phoenix-health-after")),new java.math.BigDecimal(text(row,"decision-phoenix-maximum")),text(row,"decision-phoenix-entry-point"));
|
||||
var decision=decisionSource==null?null:new DownedEpisode.Decision(decisionSource,decisionPlayer==null?null:UUID.fromString(decisionPlayer),decisionPlayerName,decisionCharacter==null?null:UUID.fromString(decisionCharacter),decisionCharacterName,decisionSession==null?null:UUID.fromString(decisionSession),UUID.fromString(decisionAction),decisionCastAt==null?null:Instant.parse(decisionCastAt),decisionCooldown==null?null:Instant.parse(decisionCooldown),decisionRange==null?null:Double.valueOf(decisionRange),row.get("decision-line-of-sight")==null?null:Boolean.valueOf(String.valueOf(row.get("decision-line-of-sight"))),phoenix);
|
||||
return new DownedEpisode(uuid(row,"id"),uuid(row,"character"),uuid(row,"player"),uuid(row,"session"),world,x,y,z,yaw,pitch,entryWorld,row.containsKey("entry-x")?Double.parseDouble(text(row,"entry-x")):x,row.containsKey("entry-y")?Double.parseDouble(text(row,"entry-y")):y,row.containsKey("entry-z")?Double.parseDouble(text(row,"entry-z")):z,row.containsKey("entry-yaw")?Float.parseFloat(text(row,"entry-yaw")):yaw,row.containsKey("entry-pitch")?Float.parseFloat(text(row,"entry-pitch")):pitch,Instant.parse(text(row,"downed-at")),Instant.parse(text(row,"expires-at")),new DownedEpisode.Attribution(attacker==null?null:UUID.fromString(attacker),text(row,"cause"),Boolean.parseBoolean(String.valueOf(row.containsKey("player-defeat")?row.get("player-defeat"):false)),row.containsKey("contributors")?list(row.get("contributors")).stream().map(String::valueOf).map(UUID::fromString).toList():List.of(),row.containsKey("victim-level")?Integer.parseInt(String.valueOf(row.get("victim-level"))):0,row.containsKey("victim-coins")?new java.math.BigInteger(String.valueOf(row.get("victim-coins"))):java.math.BigInteger.ZERO),characterName,playerName,revision==null?null:new java.math.BigInteger(revision),DownedEpisode.Phase.valueOf(text(row,"phase")),outcome==null?null:DownedEpisode.Outcome.valueOf(outcome),operation==null?null:UUID.fromString(operation),resolved==null?null:Instant.parse(resolved),protection==null?null:Instant.parse(protection),decision,list(row.get("final-rewards")).stream().map(DownedDirectory::map).map(reward->new DownedEpisode.Reward(uuid(reward,"character"),new java.math.BigInteger(text(reward,"experience")),new java.math.BigInteger(text(reward,"coins")))).toList(),Boolean.parseBoolean(String.valueOf(row.containsKey("published")?row.get("published"):false)));
|
||||
}
|
||||
private static Yaml yaml(){var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(0);options.setCodePointLimit(1_048_576);return new Yaml(new SafeConstructor(options));}private static List<?> list(Object value){return value==null?List.of():(List<?>)value;}private static Map<?,?> map(Object value){return(Map<?,?>)value;}private static String text(Map<?,?> map,String key){Object value=map.get(key);if(!(value instanceof String text)||text.isBlank())throw new IllegalArgumentException(key);return text;}private static String optional(Map<?,?> map,String key){Object value=map.get(key);return value==null?null:text(map,key);}private static UUID uuid(Map<?,?> map,String key){return UUID.fromString(text(map,key));}
|
||||
private <T>CompletableFuture<T> submit(Callable<T> work){var result=new CompletableFuture<T>();synchronized(this){if(closed){result.completeExceptionally(new IOException("Downed directory closed"));return result;}pending++;}worker.execute(()->{try{result.complete(work.call());}catch(Throwable failure){result.completeExceptionally(failure);}finally{finished();}});return result;}private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Durable identity, anchor, attribution and single resolution for one lethal episode. */
|
||||
public record DownedEpisode(UUID id, UUID character, UUID player, UUID session, UUID world,
|
||||
double x, double y, double z, float yaw, float pitch,
|
||||
UUID entryWorld, double entryX, double entryY, double entryZ,
|
||||
float entryYaw, float entryPitch, Instant downedAt, Instant expiresAt,
|
||||
Attribution attribution, String characterName, String playerName,
|
||||
BigInteger characterRevision, Phase phase,
|
||||
Outcome outcome, UUID outcomeOperation, Instant resolvedAt,
|
||||
Instant protectionUntil, Decision decision, List<Reward> finalRewards,
|
||||
boolean published) {
|
||||
public enum Phase { PENDING, RESOLVED }
|
||||
public enum Outcome { REVIVED, FINAL_DEATH }
|
||||
|
||||
public record Reward(UUID character, BigInteger experience, BigInteger coins) {
|
||||
public Reward {
|
||||
Objects.requireNonNull(character);
|
||||
Objects.requireNonNull(experience);
|
||||
Objects.requireNonNull(coins);
|
||||
if (experience.signum() < 0 || coins.signum() < 0) {
|
||||
throw new IllegalArgumentException("Invalid downed reward");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record Attribution(UUID attacker, String cause, boolean playerDefeat,
|
||||
List<UUID> contributors, int victimLevel, BigInteger victimCoins) {
|
||||
public Attribution(UUID attacker, String cause) {
|
||||
this(attacker, cause, false, List.of(), 0, BigInteger.ZERO);
|
||||
}
|
||||
public Attribution(UUID attacker, String cause, boolean playerDefeat) {
|
||||
this(attacker, cause, playerDefeat, List.of(), 0, BigInteger.ZERO);
|
||||
}
|
||||
public Attribution {
|
||||
if (cause == null || cause.isBlank() || victimLevel < 0 || victimCoins == null
|
||||
|| victimCoins.signum() < 0) throw new IllegalArgumentException("Invalid lethal attribution");
|
||||
contributors = List.copyOf(contributors);
|
||||
}
|
||||
}
|
||||
|
||||
public record Phoenix(BigInteger quantityBefore, BigInteger quantityAfter,
|
||||
java.math.BigDecimal healthBefore, java.math.BigDecimal healthAfter,
|
||||
java.math.BigDecimal maximum, String entryPoint) {
|
||||
public Phoenix {
|
||||
Objects.requireNonNull(quantityBefore);Objects.requireNonNull(quantityAfter);
|
||||
Objects.requireNonNull(healthBefore);Objects.requireNonNull(healthAfter);Objects.requireNonNull(maximum);
|
||||
if(quantityBefore.signum()<=0||!quantityAfter.equals(quantityBefore.subtract(BigInteger.ONE))
|
||||
||healthBefore.signum()<0||maximum.signum()<=0||healthAfter.compareTo(maximum)!=0
|
||||
||healthBefore.compareTo(maximum)>0||!"downed_menu".equals(entryPoint))throw new IllegalArgumentException("Invalid Phoenix Down decision");
|
||||
}
|
||||
}
|
||||
|
||||
/** Durable winning decision context; nullable actor fields represent an automatic system timeout. */
|
||||
public record Decision(String source, UUID actorPlayer, String actorPlayerName,
|
||||
UUID actorCharacter, String actorCharacterName, UUID actorSession,
|
||||
UUID actionId, Instant castAt, Instant cooldownUntil,
|
||||
Double finalRange, Boolean lineOfSight, Phoenix phoenix) {
|
||||
public Decision(String source,UUID actorPlayer,String actorPlayerName,UUID actorCharacter,String actorCharacterName,UUID actorSession,UUID actionId,Instant castAt,Instant cooldownUntil,Double finalRange,Boolean lineOfSight){this(source,actorPlayer,actorPlayerName,actorCharacter,actorCharacterName,actorSession,actionId,castAt,cooldownUntil,finalRange,lineOfSight,null);}
|
||||
public Decision {
|
||||
if (source == null || !List.of("revive", "phoenix_down", "accept_death", "timeout").contains(source)
|
||||
|| actionId == null || (actorPlayer == null) != (actorPlayerName == null)
|
||||
|| (actorCharacter == null) != (actorCharacterName == null)
|
||||
|| (actorCharacter == null) != (actorSession == null)
|
||||
|| finalRange != null && (!Double.isFinite(finalRange) || finalRange < 0)
|
||||
|| "revive".equals(source) && (actorPlayer == null || actorCharacter == null
|
||||
|| castAt == null || cooldownUntil == null || finalRange == null || lineOfSight == null)
|
||||
|| "phoenix_down".equals(source) && (actorPlayer == null || actorCharacter == null || castAt == null || phoenix == null)
|
||||
|| !"phoenix_down".equals(source) && phoenix != null) {
|
||||
throw new IllegalArgumentException("Invalid downed decision");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Backward-compatible construction for callers and legacy records created before event context existed. */
|
||||
public DownedEpisode(UUID id, UUID character, UUID player, UUID session, UUID world,
|
||||
double x, double y, double z, float yaw, float pitch,
|
||||
UUID entryWorld, double entryX, double entryY, double entryZ,
|
||||
float entryYaw, float entryPitch, Instant downedAt, Instant expiresAt,
|
||||
Attribution attribution, Phase phase, Outcome outcome, UUID outcomeOperation,
|
||||
Instant resolvedAt, Instant protectionUntil, List<Reward> finalRewards,
|
||||
boolean published) {
|
||||
this(id, character, player, session, world, x, y, z, yaw, pitch, entryWorld, entryX, entryY,
|
||||
entryZ, entryYaw, entryPitch, downedAt, expiresAt, attribution, null, null, null, phase, outcome,
|
||||
outcomeOperation, resolvedAt, protectionUntil,
|
||||
legacyDecision(expiresAt, outcome, outcomeOperation, resolvedAt), finalRewards, published);
|
||||
}
|
||||
|
||||
private static Decision legacyDecision(Instant expiresAt, Outcome outcome, UUID operation,
|
||||
Instant resolvedAt) {
|
||||
if (outcome == null || operation == null || resolvedAt == null) return null;
|
||||
String source = outcome == Outcome.REVIVED ? "revive"
|
||||
: resolvedAt.equals(expiresAt) ? "timeout" : "accept_death";
|
||||
// Legacy Revive lacks durable caster/cooldown/range evidence, so do not fabricate a decision actor.
|
||||
if (outcome == Outcome.REVIVED) return null;
|
||||
return new Decision(source, null, null, null, null, null, operation, null, null, null, null);
|
||||
}
|
||||
|
||||
public DownedEpisode {
|
||||
Objects.requireNonNull(id);
|
||||
Objects.requireNonNull(character);
|
||||
Objects.requireNonNull(player);
|
||||
Objects.requireNonNull(session);
|
||||
Objects.requireNonNull(world);
|
||||
Objects.requireNonNull(entryWorld);
|
||||
Objects.requireNonNull(downedAt);
|
||||
Objects.requireNonNull(expiresAt);
|
||||
Objects.requireNonNull(attribution);
|
||||
if ((characterName == null) != (playerName == null)) {
|
||||
throw new IllegalArgumentException("Incomplete downed subject names");
|
||||
}
|
||||
Objects.requireNonNull(phase);
|
||||
finalRewards = List.copyOf(finalRewards);
|
||||
if (characterRevision != null && characterRevision.signum() < 0
|
||||
|| !Double.isFinite(x) || !Double.isFinite(y) || !Double.isFinite(z)
|
||||
|| !Float.isFinite(yaw) || !Float.isFinite(pitch)
|
||||
|| !Double.isFinite(entryX) || !Double.isFinite(entryY) || !Double.isFinite(entryZ)
|
||||
|| !Float.isFinite(entryYaw) || !Float.isFinite(entryPitch)
|
||||
|| !expiresAt.isAfter(downedAt)) throw new IllegalArgumentException("Invalid downed episode");
|
||||
boolean resolved = phase == Phase.RESOLVED;
|
||||
if (resolved != (outcome != null && outcomeOperation != null && resolvedAt != null)) {
|
||||
throw new IllegalArgumentException("Invalid downed outcome");
|
||||
}
|
||||
if (decision != null && (!resolved || !decision.actionId().equals(outcomeOperation))) {
|
||||
throw new IllegalArgumentException("Invalid downed decision identity");
|
||||
}
|
||||
if (!resolved && protectionUntil != null || outcome == Outcome.FINAL_DEATH && protectionUntil != null
|
||||
|| protectionUntil != null && protectionUntil.isBefore(resolvedAt)) {
|
||||
throw new IllegalArgumentException("Invalid downed protection");
|
||||
}
|
||||
if ((!resolved || outcome == Outcome.REVIVED) && !finalRewards.isEmpty()) {
|
||||
throw new IllegalArgumentException("Invalid downed rewards");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private final Server server;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Failure> diagnostics;
|
||||
private final java.util.LinkedHashMap<UUID, String> savedCharacterCatalog = new java.util.LinkedHashMap<>();
|
||||
private final java.util.concurrent.ConcurrentHashMap<Object, Object> savedSearches = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
private static final int SAVED_CATALOG_COMPLETION_LIMIT = 256;
|
||||
private static final int SAVED_SEARCH_PAGE_SIZE = 8;
|
||||
private volatile boolean closed;
|
||||
private games.dmg.fantasystick.persistence.AdventureSettings settings;
|
||||
private games.dmg.fantasystick.persistence.FireballSettings fireballSettings;
|
||||
@@ -92,7 +96,15 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private games.dmg.fantasystick.shop.FantasyArmorerDirectory armorers;
|
||||
private games.dmg.fantasystick.shop.FantasyArmorerController armorerController;
|
||||
private games.dmg.fantasystick.shop.ArmorMarket armorMarket;
|
||||
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.persistence.RareSettings rareSettings;
|
||||
private games.dmg.fantasystick.persistence.DownedSettings downedSettings;
|
||||
private games.dmg.fantasystick.events.DownedEventBridge downedEvents;
|
||||
private games.dmg.fantasystick.book.CharacterBookController characterBooks;
|
||||
private games.dmg.fantasystick.persistence.CharacterStorage characters;
|
||||
private AdminCharacterRecovery characterRecovery;
|
||||
|
||||
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics) {
|
||||
@@ -380,9 +392,16 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
characterIssuer = Objects.requireNonNull(issuer);
|
||||
}
|
||||
|
||||
public void attachCharacterBooks(games.dmg.fantasystick.book.CharacterBookController books) {
|
||||
public void attachCharacterBooks(games.dmg.fantasystick.book.CharacterBookController books,
|
||||
games.dmg.fantasystick.persistence.CharacterStorage storage) {
|
||||
if (closed || characterBooks != null) throw new IllegalStateException("Character books already attached or command closed");
|
||||
characterBooks = Objects.requireNonNull(books);
|
||||
characters = Objects.requireNonNull(storage);
|
||||
}
|
||||
|
||||
public void attachCharacterRecovery(AdminCharacterRecovery recovery) {
|
||||
if (closed || characterRecovery != null) throw new IllegalStateException("Character recovery already attached or command closed");
|
||||
characterRecovery = Objects.requireNonNull(recovery);
|
||||
}
|
||||
|
||||
public void attachPartyRegistrars(games.dmg.fantasystick.party.PartyRegistrarDirectory directory,
|
||||
@@ -405,16 +424,25 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
public void attachArmorers(games.dmg.fantasystick.shop.FantasyArmorerDirectory directory,games.dmg.fantasystick.shop.FantasyArmorerController controller){if(closed||armorers!=null)throw new IllegalStateException("Armorers already attached or command closed");armorers=Objects.requireNonNull(directory);armorerController=Objects.requireNonNull(controller);}
|
||||
public void attachArmorMarket(games.dmg.fantasystick.shop.ArmorMarket market){if(closed||armorMarket!=null)throw new IllegalStateException("Armor market already attached or command closed");armorMarket=Objects.requireNonNull(market);}
|
||||
public void attachRareMarket(games.dmg.fantasystick.shop.RareMerchantDirectory directory,games.dmg.fantasystick.shop.RareMerchantController controller,games.dmg.fantasystick.shop.RareMarket market,games.dmg.fantasystick.persistence.RareSettings settings){if(closed||rareMerchants!=null)throw new IllegalStateException("Rare market already attached or command closed");rareMerchants=Objects.requireNonNull(directory);rareMerchantController=Objects.requireNonNull(controller);rareMarket=Objects.requireNonNull(market);rareSettings=Objects.requireNonNull(settings);}
|
||||
public void attachDownedSettings(games.dmg.fantasystick.persistence.DownedSettings settings){if(closed||downedSettings!=null)throw new IllegalStateException("Downed settings already attached or command closed");downedSettings=Objects.requireNonNull(settings);}
|
||||
public void attachDownedEvents(games.dmg.fantasystick.events.DownedEventBridge events){if(closed||downedEvents!=null)throw new IllegalStateException("Downed events already attached or command closed");downedEvents=Objects.requireNonNull(events);}
|
||||
|
||||
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (closed) return true;
|
||||
if (adventureQuestCommands != null && args.length > 0 && args[0].equals("adventure")) {
|
||||
adventureQuestCommands.adventure(sender, args); return true;
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)) {
|
||||
sender.sendMessage("Permission denied: you need fantasystick.admin to administer Fantasy Stick.");
|
||||
return true;
|
||||
}
|
||||
if (args.length == 0) {
|
||||
adminUsage(sender);
|
||||
return true;
|
||||
}
|
||||
if (adventureQuestCommands != null && args[0].equals("adventure")) {
|
||||
if (args.length >= 2 && args[1].equals("reroll")) adventureQuestCommands.adventure(sender, args);
|
||||
else sender.sendMessage("Usage: /fstickadmin adventure reroll [confirm]");
|
||||
return true;
|
||||
}
|
||||
if (adventureQuestCommands != null && args.length > 0 && args[0].equals("adventure-site")) {
|
||||
adventureQuestCommands.site(sender, args); return true;
|
||||
}
|
||||
@@ -422,10 +450,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
adventureQuestCommands.giver(sender, args); return true;
|
||||
}
|
||||
if (args.length > 0 && args[0].equals("character")) {
|
||||
characterView(sender, args); return true;
|
||||
characterCommand(sender, args); return true;
|
||||
}
|
||||
if(dungeonCommands!=null&&args.length>0&&args[0].equals("dungeon")){dungeonCommands.execute(sender,args);return true;}
|
||||
if(args.length>0&&args[0].equals("shop")){shop(sender,args);return true;}
|
||||
if(args.length>0&&args[0].equals("rare-shop")){rareShop(sender,args);return true;}
|
||||
if (args.length > 0 && args[0].equals("training-dummy")) {
|
||||
trainingDummy(sender, args); return true;
|
||||
}
|
||||
@@ -454,7 +483,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
owner = UUID.fromString(args[1]);
|
||||
if (!owner.toString().equalsIgnoreCase(args[1])) throw new IllegalArgumentException("Noncanonical UUID shape");
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Usage: /fantasystick <inspect|recover> <player-uuid> (full UUID required)");
|
||||
sender.sendMessage("Usage: /fstickadmin <inspect|recover> <player-uuid> (full UUID required)");
|
||||
return true;
|
||||
}
|
||||
if (args[0].equals("recover")) {
|
||||
@@ -506,10 +535,50 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
return true;
|
||||
}
|
||||
|
||||
private void characterView(CommandSender sender, String[] args) {
|
||||
public boolean playerAdventure(CommandSender sender, String[] args) {
|
||||
if (closed) return true;
|
||||
if (adventureQuestCommands == null || args.length == 0 || !args[0].equals("adventure")
|
||||
|| args.length >= 2 && args[1].equals("reroll")) {
|
||||
sender.sendMessage("Usage: /fstick adventure [tracking [on|off]]");
|
||||
return true;
|
||||
}
|
||||
adventureQuestCommands.adventure(sender, args);
|
||||
return true;
|
||||
}
|
||||
|
||||
public List<String> completePlayerAdventure(CommandSender sender, String[] args) {
|
||||
if (closed || adventureQuestCommands == null || args.length == 0 || !args[0].equals("adventure"))
|
||||
return List.of();
|
||||
return adventureQuestCommands.complete(sender, args).stream()
|
||||
.filter(value -> !value.equals("reroll"))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static void adminUsage(CommandSender sender) {
|
||||
sender.sendMessage("Usage: /fstickadmin <adventure reroll [confirm]|adventure-site|character view|config|dungeon|experience|give|inspect|party-registrar|quest-giver|rare-shop|recover|revive|shop|training-dummy> ...");
|
||||
}
|
||||
|
||||
private void characterCommand(CommandSender sender, String[] args) {
|
||||
if (args.length >= 2 && args[1].equals("find")) {
|
||||
characterFind(sender, args);
|
||||
return;
|
||||
}
|
||||
if (args.length >= 2 && args[1].equals("show")) {
|
||||
characterShow(sender, args);
|
||||
return;
|
||||
}
|
||||
if (args.length >= 2 && args[1].equals("recover")) {
|
||||
characterRecover(sender, args);
|
||||
return;
|
||||
}
|
||||
if (args.length == 3 && args[1].equals("view")
|
||||
&& !(sender instanceof org.bukkit.entity.Player)) {
|
||||
sender.sendMessage("Character view requires an in-game administrator.");
|
||||
return;
|
||||
}
|
||||
if (characterBooks == null || !(sender instanceof org.bukkit.entity.Player administrator)
|
||||
|| args.length != 3 || !args[1].equals("view") || args[2].isBlank()) {
|
||||
sender.sendMessage("Usage: /fantasystick character view <online-player> (in-game administrator only)");
|
||||
sender.sendMessage("Usage: /fstickadmin character <find <name...>|show <character-uuid>|recover <character-uuid> <online-player>|view <online-player>>");
|
||||
return;
|
||||
}
|
||||
var matches = server.getOnlinePlayers().stream()
|
||||
@@ -523,9 +592,119 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
characterBooks.openReadOnly(administrator, matches.getFirst());
|
||||
}
|
||||
|
||||
private void characterFind(CommandSender sender, String[] args) {
|
||||
if (args.length < 3 || String.join(" ", java.util.Arrays.copyOfRange(args, 2, args.length)).isBlank()) {
|
||||
sender.sendMessage("Usage: /fstickadmin character find <name...>");
|
||||
return;
|
||||
}
|
||||
String query = String.join(" ", java.util.Arrays.copyOfRange(args, 2, args.length));
|
||||
String folded = query.toLowerCase(java.util.Locale.ROOT);
|
||||
Object ticket = new Object();
|
||||
Object requestKey = sender instanceof org.bukkit.entity.Player player
|
||||
? player.getUniqueId() : EquipmentAdminCommand.class;
|
||||
savedSearches.put(requestKey, ticket);
|
||||
characters.catalog().whenComplete((catalog, failure) -> {
|
||||
if (closed || savedSearches.get(requestKey) != ticket) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed || !savedSearches.remove(requestKey, ticket) || !sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
if (failure != null) {
|
||||
sender.sendMessage("Saved character search failed; no records were changed.");
|
||||
return;
|
||||
}
|
||||
rememberSavedCharacters(catalog.records());
|
||||
var matches = catalog.records().stream()
|
||||
.filter(record -> record.name().toLowerCase(java.util.Locale.ROOT).contains(folded))
|
||||
.sorted(java.util.Comparator.comparing(
|
||||
(games.dmg.fantasystick.persistence.CharacterRecord record) ->
|
||||
record.name().toLowerCase(java.util.Locale.ROOT))
|
||||
.thenComparing(record -> record.id().toString()))
|
||||
.toList();
|
||||
if (matches.isEmpty()) {
|
||||
sender.sendMessage(catalog.complete()
|
||||
? "No saved characters match '" + query + "'."
|
||||
: "No readable saved characters match '" + query
|
||||
+ "'; results are incomplete because " + catalog.unreadable() + " record(s) could not be examined.");
|
||||
return;
|
||||
}
|
||||
int pages = (matches.size() + SAVED_SEARCH_PAGE_SIZE - 1) / SAVED_SEARCH_PAGE_SIZE;
|
||||
for (int page = 0; page < pages; page++) {
|
||||
sender.sendMessage("Saved character results " + (page + 1) + "/" + pages
|
||||
+ (catalog.complete() ? "" : " (incomplete: " + catalog.unreadable() + " unreadable)"));
|
||||
int from = page * SAVED_SEARCH_PAGE_SIZE;
|
||||
for (var record : matches.subList(from, Math.min(matches.size(), from + SAVED_SEARCH_PAGE_SIZE))) {
|
||||
boolean active = sessions.activeCharacter(record.id()).filter(sessions::current).isPresent();
|
||||
var result = net.kyori.adventure.text.Component.text(record.name() + " — " + record.id()
|
||||
+ " — level " + record.level() + " — " + (record.dead() ? "dead" : "alive")
|
||||
+ " — " + (active ? "active" : "inactive"))
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.suggestCommand(
|
||||
"/fstickadmin character show " + record.id()));
|
||||
sender.sendMessage(result);
|
||||
}
|
||||
}
|
||||
}); } catch (RuntimeException rejected) {
|
||||
savedSearches.remove(requestKey, ticket);
|
||||
report(null, "character-find-response", rejected);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void characterRecover(CommandSender sender, String[] args) {
|
||||
if (characterRecovery == null || !(sender instanceof org.bukkit.entity.Player administrator)) {
|
||||
sender.sendMessage("Saved character recovery requires an in-game administrator.");
|
||||
return;
|
||||
}
|
||||
UUID character;
|
||||
try {
|
||||
if (args.length != 4) throw new IllegalArgumentException("Invalid shape");
|
||||
character = UUID.fromString(args[2]);
|
||||
if (!character.toString().equals(args[2])) throw new IllegalArgumentException("Noncanonical UUID");
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Usage: /fstickadmin character recover <full-character-uuid> <online-player>");
|
||||
return;
|
||||
}
|
||||
var matches = server.getOnlinePlayers().stream().filter(org.bukkit.entity.Player::isOnline)
|
||||
.filter(player -> player.getName().equals(args[3])).toList();
|
||||
if (matches.size() != 1) {
|
||||
sender.sendMessage("Character recovery refused: recipient must exactly name one online player.");
|
||||
return;
|
||||
}
|
||||
characterRecovery.open(administrator, character, matches.getFirst());
|
||||
}
|
||||
|
||||
private void characterShow(CommandSender sender, String[] args) {
|
||||
if (characterBooks == null || !(sender instanceof org.bukkit.entity.Player administrator)) {
|
||||
sender.sendMessage("Saved character books require an in-game administrator.");
|
||||
return;
|
||||
}
|
||||
UUID character;
|
||||
try {
|
||||
if (args.length != 3) throw new IllegalArgumentException("Invalid shape");
|
||||
character = UUID.fromString(args[2]);
|
||||
if (!character.toString().equals(args[2])) throw new IllegalArgumentException("Noncanonical UUID");
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Usage: /fstickadmin character show <full-character-uuid>");
|
||||
return;
|
||||
}
|
||||
characterBooks.openSavedReadOnly(administrator, character);
|
||||
}
|
||||
|
||||
private synchronized void rememberSavedCharacters(
|
||||
java.util.Collection<games.dmg.fantasystick.persistence.CharacterRecord> records) {
|
||||
for (var record : records.stream().sorted(java.util.Comparator.comparing(value -> value.id().toString())).toList()) {
|
||||
savedCharacterCatalog.put(record.id(), record.name());
|
||||
while (savedCharacterCatalog.size() > SAVED_CATALOG_COMPLETION_LIMIT)
|
||||
savedCharacterCatalog.remove(savedCharacterCatalog.keySet().iterator().next());
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized java.util.Map<UUID, String> savedCharacterSuggestions() {
|
||||
return java.util.Map.copyOf(savedCharacterCatalog);
|
||||
}
|
||||
|
||||
private void experience(CommandSender sender, String[] args) {
|
||||
if (experienceGrant == null || args.length < 4 || !args[1].equals("give")) {
|
||||
sender.sendMessage("Usage: /fantasystick experience give <active-character-name...> <positive-amount>"); return;
|
||||
sender.sendMessage("Usage: /fstickadmin experience give <active-character-name...> <positive-amount>"); return;
|
||||
}
|
||||
BigInteger amount;
|
||||
try { amount = new BigInteger(args[args.length - 1]); }
|
||||
@@ -537,7 +716,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
}
|
||||
String name = String.join(" ", java.util.Arrays.copyOfRange(args, 2, args.length - 1));
|
||||
if (name.isBlank()) {
|
||||
sender.sendMessage("Usage: /fantasystick experience give <active-character-name...> <positive-amount>"); return;
|
||||
sender.sendMessage("Usage: /fstickadmin experience give <active-character-name...> <positive-amount>"); return;
|
||||
}
|
||||
var matches = sessions.activeCharacters().stream().filter(active -> active.record().name().equals(name)).toList();
|
||||
if (matches.isEmpty()) {
|
||||
@@ -571,7 +750,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (!accepted) sender.sendMessage("Experience grant refused: the captured character session changed before persistence. No fantasy XP changed.");
|
||||
}
|
||||
|
||||
private void adminRevive(CommandSender sender,String[] args){if(args.length!=2||args[1].isBlank()||adminRevival==null||!(sender instanceof org.bukkit.entity.Player admin)){sender.sendMessage("Usage: /fantasystick revive <online-player> (an in-game administrator must select the Tail)");return;}var matches=server.getOnlinePlayers().stream().filter(player->player.isOnline()&&player.getName().equalsIgnoreCase(args[1])).toList();if(matches.size()!=1){sender.sendMessage("Admin revival refused: target must name one online player. No item changed.");return;}adminRevival.open(admin,matches.getFirst(),adminReviveSettings.timeout());}
|
||||
private void adminRevive(CommandSender sender,String[] args){if(args.length!=2||args[1].isBlank()||adminRevival==null||!(sender instanceof org.bukkit.entity.Player admin)){sender.sendMessage("Usage: /fstickadmin revive <online-player> (an in-game administrator must select the Tail)");return;}var matches=server.getOnlinePlayers().stream().filter(player->player.isOnline()&&player.getName().equalsIgnoreCase(args[1])).toList();if(matches.size()!=1){sender.sendMessage("Admin revival refused: target must name one online player. No item changed.");return;}adminRevival.open(admin,matches.getFirst(),adminReviveSettings.timeout());}
|
||||
|
||||
private void shop(CommandSender sender,String[] args){
|
||||
if(armorers==null||armorerController==null||args.length<2){shopUsage(sender);return;}
|
||||
@@ -586,10 +765,13 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if(args[1].equals("remove")&&args.length==3){UUID id;try{id=UUID.fromString(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Fantasy Armorer UUID is invalid.");return;}armorers.remove(id).whenComplete((removed,failure)->removeShopResponse(sender,id,removed,failure));return;}
|
||||
shopUsage(sender);
|
||||
}
|
||||
private static void shopUsage(CommandSender sender){sender.sendMessage("Usage: /fantasystick shop <place [name...]|remove <shop-uuid>|list|restock>");}
|
||||
private static void shopUsage(CommandSender sender){sender.sendMessage("Usage: /fstickadmin shop <place [name...]|remove <shop-uuid>|list|restock>");}
|
||||
private void placeShopResponse(org.bukkit.entity.Player player,org.bukkit.Location location,games.dmg.fantasystick.shop.FantasyArmorer saved,Throwable failure){if(failure!=null)report(player.getUniqueId(),"shop-place",failure);if(closed)return;nextTick.accept(()->{if(closed)return;if(failure!=null){if(player.isOnline())player.sendMessage("Fantasy Armorer placement failed; no registration or entity changed.");return;}try{int x=location.getBlockX()>>4,z=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(x,z))armorerController.reconcile(location.getWorld().getChunkAt(x,z));if(player.isOnline())player.sendMessage("Placed Fantasy Armorer "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"shop-entity",entityFailure);armorers.remove(saved.id()).whenComplete((ignored,rollback)->nextTick.accept(()->{armorerController.removeLoaded(saved.id());if(player.isOnline())player.sendMessage("Fantasy Armorer placement failed; no registration or entity changed.");}));}});}
|
||||
private void removeShopResponse(CommandSender sender,UUID id,java.util.Optional<games.dmg.fantasystick.shop.FantasyArmorer> removed,Throwable failure){if(failure!=null)report(null,"shop-remove",failure);if(closed)return;nextTick.accept(()->{if(failure!=null)sender.sendMessage("Fantasy Armorer removal failed; registration and entity are unchanged.");else if(removed.isEmpty())sender.sendMessage("Fantasy Armorer not found.");else try{armorerController.removeLoaded(id);sender.sendMessage("Removed Fantasy Armorer "+id+".");}catch(RuntimeException entityFailure){var value=removed.orElseThrow();report(null,"shop-entity-remove",entityFailure);armorers.restore(value).whenComplete((restored,rollback)->{if(rollback!=null)report(null,"shop-remove-rollback",rollback);if(closed)return;nextTick.accept(()->{if(rollback==null){var world=server.getWorld(value.world());if(world!=null&&world.isChunkLoaded(((int)Math.floor(value.x()))>>4,((int)Math.floor(value.z()))>>4))armorerController.reconcile(world.getChunkAt(((int)Math.floor(value.x()))>>4,((int)Math.floor(value.z()))>>4));sender.sendMessage("Fantasy Armorer removal failed; registration and entity are unchanged.");}else sender.sendMessage("Fantasy Armorer removal rollback failed; inspect diagnostics.");});});}});}
|
||||
|
||||
private void rareShop(CommandSender sender,String[] args){if(rareMerchants==null||rareMerchantController==null||rareMarket==null||args.length<2){rareShopUsage(sender);return;}if(args[1].equals("restock")&&args.length==2){rareMarket.restock().whenComplete((catalog,failure)->{if(failure!=null)report(null,"rare-shop-restock",failure);if(closed)return;nextTick.accept(()->{if(closed||!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Restocked Rare Items catalog "+catalog.revision()+"; next automatic restock remains 00:00 UTC.":"Rare Items restock failed; prior catalog is unchanged.");});});return;}if(args[1].equals("list")&&args.length==2){var values=rareMerchants.all();if(values.isEmpty())sender.sendMessage("No Rare Items merchants are registered.");else for(var value:values)sender.sendMessage(value.id()+" "+(value.name().isBlank()?"Rare Items":value.name())+" @ "+value.world()+" "+value.x()+" "+value.y()+" "+value.z());return;}if(args[1].equals("place")&&sender instanceof org.bukkit.entity.Player player){String name=args.length<3?"":String.join(" ",java.util.Arrays.copyOfRange(args,2,args.length));var location=player.getLocation().clone();try{rareMerchants.place(name,location.getWorld().getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((saved,failure)->{if(failure!=null)report(player.getUniqueId(),"rare-shop-place",failure);if(closed)return;nextTick.accept(()->{if(closed||!player.hasPermission(PERMISSION)||!player.isOnline())return;if(failure!=null){player.sendMessage("Rare Items placement failed; prior state is unchanged.");return;}try{int x=location.getBlockX()>>4,z=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(x,z))rareMerchantController.reconcile(location.getWorld().getChunkAt(x,z));player.sendMessage("Placed Rare Items merchant "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"rare-shop-entity",entityFailure);rareMerchants.remove(saved.id());rareMerchantController.removeLoaded(saved.id());player.sendMessage("Rare Items placement failed; registration rollback was requested.");}});});}catch(IllegalArgumentException invalid){sender.sendMessage("Rare Items merchant name is invalid.");}return;}if(args[1].equals("remove")&&args.length==3){UUID id;try{id=UUID.fromString(args[2]);if(!id.toString().equals(args[2]))throw new IllegalArgumentException();}catch(IllegalArgumentException invalid){sender.sendMessage("Rare Items merchant UUID is invalid.");return;}rareMerchants.remove(id).whenComplete((removed,failure)->{if(failure!=null)report(null,"rare-shop-remove",failure);if(closed)return;nextTick.accept(()->{if(closed||!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Rare Items removal failed; registration is unchanged.");else if(removed.isEmpty())sender.sendMessage("Rare Items merchant not found.");else{rareMerchantController.removeLoaded(id);sender.sendMessage("Removed Rare Items merchant "+id+".");}});});return;}rareShopUsage(sender);}
|
||||
private static void rareShopUsage(CommandSender sender){sender.sendMessage("Usage: /fstickadmin rare-shop <place [name...]|remove <shop-uuid>|list|restock>");}
|
||||
|
||||
private void trainingDummy(CommandSender sender, String[] args) {
|
||||
if (trainingDummies == null || trainingDummyController == null || args.length < 2) {
|
||||
trainingDummyUsage(sender); return;
|
||||
@@ -622,7 +804,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
}
|
||||
|
||||
private static void trainingDummyUsage(CommandSender sender) {
|
||||
sender.sendMessage("Usage: /fantasystick training-dummy <place [name...]|remove <dummy-uuid>|list>");
|
||||
sender.sendMessage("Usage: /fstickadmin training-dummy <place [name...]|remove <dummy-uuid>|list>");
|
||||
}
|
||||
|
||||
private void placeDummyResponse(org.bukkit.entity.Player player, org.bukkit.Location location,
|
||||
@@ -703,16 +885,16 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private void placeRegistrarResponse(org.bukkit.entity.Player player,org.bukkit.Location location,games.dmg.fantasystick.party.PartyRegistrar saved,Throwable failure){if(failure!=null)report(player.getUniqueId(),"party-registrar-place",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure!=null){if(player.isOnline())player.sendMessage("Party Registrar placement failed; no registration or entity changed.");return;}try{int chunkX=location.getBlockX()>>4,chunkZ=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(chunkX,chunkZ))partyRegistrarController.reconcile(location.getWorld().getChunkAt(chunkX,chunkZ));if(player.isOnline())player.sendMessage("Placed Party Registrar "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"party-registrar-entity",entityFailure);partyRegistrars.remove(saved.id()).whenComplete((ignored,rollbackFailure)->{if(rollbackFailure!=null)report(player.getUniqueId(),"party-registrar-rollback",rollbackFailure);if(closed)return;nextTick.accept(()->{partyRegistrarController.removeLoaded(saved.id());if(player.isOnline())player.sendMessage("Party Registrar placement failed; no registration or entity changed.");});});}});}catch(RuntimeException rejected){report(player.getUniqueId(),"party-registrar-response",rejected);}}
|
||||
private void removeRegistrarResponse(CommandSender sender,UUID id,java.util.Optional<games.dmg.fantasystick.party.PartyRegistrar> removed,Throwable failure){if(failure!=null)report(null,"party-registrar-remove",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure!=null)sender.sendMessage("Party Registrar removal failed; registration and entity are unchanged.");else if(removed.isEmpty())sender.sendMessage("Party Registrar not found.");else try{partyRegistrarController.removeLoaded(id);sender.sendMessage("Removed Party Registrar "+id+".");}catch(RuntimeException entityFailure){report(null,"party-registrar-entity",entityFailure);partyRegistrars.restore(removed.orElseThrow()).whenComplete((restored,rollbackFailure)->{if(rollbackFailure!=null)report(null,"party-registrar-rollback",rollbackFailure);if(closed)return;nextTick.accept(()->{if(rollbackFailure==null){var world=server.getWorld(restored.world());int chunkX=((int)Math.floor(restored.x()))>>4,chunkZ=((int)Math.floor(restored.z()))>>4;if(world!=null&&world.isChunkLoaded(chunkX,chunkZ))partyRegistrarController.reconcile(world.getChunkAt(chunkX,chunkZ));}sender.sendMessage("Party Registrar removal failed; registration and entity are unchanged.");});});}});}catch(RuntimeException rejected){report(null,"party-registrar-response",rejected);}}
|
||||
private void partyRegistrar(CommandSender sender,String[] args){
|
||||
if(partyRegistrars==null||partyRegistrarController==null||args.length<2){sender.sendMessage("Usage: /fantasystick party-registrar <place [name...]|remove <registrar-uuid>|list>");return;}
|
||||
if(partyRegistrars==null||partyRegistrarController==null||args.length<2){sender.sendMessage("Usage: /fstickadmin party-registrar <place [name...]|remove <registrar-uuid>|list>");return;}
|
||||
if(args[1].equals("list")&&args.length==2){var values=partyRegistrars.all();if(values.isEmpty())sender.sendMessage("No Party Registrars are registered.");else for(var value:values)sender.sendMessage(value.id()+" "+(value.name().isBlank()?"Party Registrar":value.name())+" @ "+value.world()+" "+value.x()+" "+value.y()+" "+value.z());return;}
|
||||
if(args[1].equals("place")&&sender instanceof org.bukkit.entity.Player player){String name=args.length<3?"":String.join(" ",java.util.Arrays.copyOfRange(args,2,args.length));var location=player.getLocation().clone();java.util.concurrent.CompletableFuture<games.dmg.fantasystick.party.PartyRegistrar> operation;try{operation=partyRegistrars.place(name,location.getWorld().getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch());}catch(IllegalArgumentException invalid){sender.sendMessage("Party Registrar name is invalid.");return;}operation.whenComplete((saved,failure)->placeRegistrarResponse(player,location,saved,failure));return;}
|
||||
if(args[1].equals("remove")&&args.length==3){UUID id;try{id=UUID.fromString(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Party Registrar UUID is invalid.");return;}partyRegistrars.remove(id).whenComplete((removed,failure)->removeRegistrarResponse(sender,id,removed,failure));return;}
|
||||
sender.sendMessage("Usage: /fantasystick party-registrar <place [name...]|remove <registrar-uuid>|list>");
|
||||
sender.sendMessage("Usage: /fstickadmin party-registrar <place [name...]|remove <registrar-uuid>|list>");
|
||||
}
|
||||
|
||||
private void give(CommandSender sender, String[] args) {
|
||||
if (args.length != 2 || args[1].isBlank() || characterIssuer == null) {
|
||||
sender.sendMessage("Usage: /fantasystick give <online-player>"); return;
|
||||
sender.sendMessage("Usage: /fstickadmin give <online-player>"); return;
|
||||
}
|
||||
var matches = server.getOnlinePlayers().stream().filter(player -> player.isOnline()
|
||||
&& player.getName().equalsIgnoreCase(args[1])).toList();
|
||||
@@ -742,6 +924,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.PartySettings.KEY)) { partyConfiguration(sender,args); return; }
|
||||
if (partyEconomySettings != null && games.dmg.fantasystick.persistence.PartyEconomySettings.keys().contains(args[1])) { partyEconomyConfiguration(sender,args); return; }
|
||||
if(gearSettings!=null&&games.dmg.fantasystick.persistence.GearSettings.keys().contains(args[1])){gearConfiguration(sender,args);return;}
|
||||
if(rareSettings!=null&&games.dmg.fantasystick.persistence.RareSettings.keys().contains(args[1])){rareConfiguration(sender,args);return;}
|
||||
if(downedSettings!=null&&games.dmg.fantasystick.persistence.DownedSettings.keys().contains(args[1])){downedConfiguration(sender,args);return;}
|
||||
if (coinSettings != null && games.dmg.fantasystick.persistence.CoinSettings.keys().contains(args[1])) { coinConfiguration(sender,args); return; }
|
||||
if (hardMobXpSettings != null && args[1].equals(games.dmg.fantasystick.persistence.HardMobXpSettings.KEY)) {
|
||||
hardMobXpConfiguration(sender, args); return;
|
||||
@@ -1374,6 +1558,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private void partyEconomyConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+partyEconomySettings.value(key)+" (applies to future party transactions only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PartyEconomySettings.Update> operation;try{operation=partyEconomySettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Party price is invalid; enter a whole number from 0 through 2147483647.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=failure==null&&partyEconomySettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Party price save failed; the effective price is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+partyEconomySettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for future party transactions.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
|
||||
private void gearConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+gearSettings.value(key)+" (applies to the next armor restock only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.GearSettings.Update> operation;try{operation=gearSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Armor market setting is invalid; variance is 0–100, prices are nonnegative, and positive stock minima cannot exceed maxima.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;nextTick.accept(()->{boolean applied=failure==null&&gearSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Armor market setting save failed; effective settings and the current catalog are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+gearSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for the next automatic or administrative restock; the current catalog is unchanged.");});});}
|
||||
private void rareConfiguration(CommandSender sender,String[] args){String key=args[1],before=rareSettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;boolean phoenix=key.contains("phoenix-down");if(args.length==2){if(phoenix&&downedEvents!=null)downedEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+(key.endsWith("-healing-percent")||key.equals(games.dmg.fantasystick.persistence.RareSettings.PHOENIX_PROTECTION)?" (applies to future accepted uses)":" (applies to the next rare restock only)"));return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RareSettings.Update> operation;try{operation=rareSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Rare setting is invalid; percentages are 0–100, values are nonnegative, and stock minima cannot exceed maxima.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;nextTick.accept(()->{boolean applied=failure==null&&rareSettings.publish(saved);if(applied&&phoenix&&downedEvents!=null)downedEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Rare setting save failed; prior effective state is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+rareSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+"; current catalog and accepted uses are unchanged.");});});}
|
||||
private void downedConfiguration(CommandSender sender,String[] args){String key=args[1],before=downedSettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;if(args.length==2){if(downedEvents!=null)downedEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+" (applies to future downed outcomes only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.DownedSettings.Update> operation;try{operation=downedSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Downed setting is invalid; timeout must be positive and protection may be zero, both at most 365 days.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;nextTick.accept(()->{boolean applied=failure==null&&downedSettings.publish(saved);if(applied&&downedEvents!=null)downedEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Downed setting save failed; prior effective state is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+downedSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+"; existing deadlines are unchanged.");});});}
|
||||
|
||||
private void coinConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+coinSettings.value(key)+" (applies to future creation or defeats only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.CoinSettings.Update> operation;try{operation=coinSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Coin setting is invalid; ranges must be ordered whole numbers from 0 through 2147483647.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=failure==null&&coinSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Coin configuration save failed; effective ranges are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+coinSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for future creation or defeats; existing balances and captured rewards are unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
|
||||
@@ -1413,7 +1599,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void partyConfiguration(CommandSender sender,String[] args){if(partySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.PartySettings.KEY+"="+partySettings.value()+" (existing parties are retained)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PartySettings.Update> operation;try{operation=partySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Party maximum is invalid; enter a positive whole number.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-party",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null)applied=partySettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Party configuration save failed; the effective limit and rosters are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+games.dmg.fantasystick.persistence.PartySettings.KEY+"="+partySettings.value()+".");else sender.sendMessage("Saved "+games.dmg.fantasystick.persistence.PartySettings.KEY+"="+saved.value()+"; existing members are retained.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
|
||||
private void roleplayConfiguration(CommandSender sender,String[] args){if(roleplaySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value());return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RoleplaySettings.Update> operation;try{operation=roleplaySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Usage: /fantasystick config roleplay-disabled [true|false]");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-roleplay",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null){applied=roleplaySettings.publish(saved);if(applied&&saved.disabled())sessions.disableRoleplay();}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Roleplay configuration save failed; policy and sessions are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value()+".");else sender.sendMessage("Saved "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+saved.disabled()+(saved.disabled()?"; ordinary exits were requested for active sessions.":"; normal sheet activation is available."));});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
private void roleplayConfiguration(CommandSender sender,String[] args){if(roleplaySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value());return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RoleplaySettings.Update> operation;try{operation=roleplaySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Usage: /fstickadmin config roleplay-disabled [true|false]");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-roleplay",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null){applied=roleplaySettings.publish(saved);if(applied&&saved.disabled())sessions.disableRoleplay();}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Roleplay configuration save failed; policy and sessions are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value()+".");else sender.sendMessage("Saved "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+saved.disabled()+(saved.disabled()?"; ordinary exits were requested for active sessions.":"; normal sheet activation is available."));});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
|
||||
private void switchDelayConfiguration(CommandSender sender,String[] args){if(switchDelaySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY+"="+switchDelaySettings.value()+" (new exits use this delay; existing deadlines are unchanged)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SwitchDelaySettings.Update> operation;try{operation=switchDelaySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Character switch delay is invalid; the effective value is unchanged.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-switch-delay",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure==null)switchDelaySettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Saved "+games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY+"="+saved.value()+"; new exits use it and existing deadlines are unchanged.":"Character switch delay save failed; the effective value is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
|
||||
@@ -1421,7 +1607,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void adventureConfiguration(CommandSender sender, String[] args) {
|
||||
if (args.length == 3 && !args[2].equals("true") && !args[2].equals("false")) {
|
||||
sender.sendMessage("Usage: /fantasystick config adventure-mode [true|false]"); return;
|
||||
sender.sendMessage("Usage: /fstickadmin config adventure-mode [true|false]"); return;
|
||||
}
|
||||
if (settings == null) { sender.sendMessage("Adventure configuration is unavailable."); return; }
|
||||
if (args.length == 2) {
|
||||
@@ -1443,7 +1629,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
}
|
||||
|
||||
private static void configurationUsage(CommandSender sender) {
|
||||
sender.sendMessage("Usage: /fantasystick config <setting> [value]");
|
||||
sender.sendMessage("Usage: /fstickadmin config <setting> [value]");
|
||||
}
|
||||
|
||||
private void report(UUID owner, String operation, Throwable cause) {
|
||||
@@ -1454,20 +1640,37 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
// Return only explicit implemented routes; never fall back to Bukkit's broad default suggestions.
|
||||
@Override public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
|
||||
if (closed) return List.of();
|
||||
if (adventureQuestCommands != null) {
|
||||
if (args.length == 1 && !sender.hasPermission(PERMISSION)) return matching(List.of("adventure"), args[0]);
|
||||
if (args.length > 1) {
|
||||
var adventure = adventureQuestCommands.complete(sender,args); if(!adventure.isEmpty()) return adventure;
|
||||
if(args[0].equals("adventure")||args[0].equals("adventure-site")||args[0].equals("quest-giver"))return List.of();
|
||||
}
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)) return List.of();
|
||||
if (adventureQuestCommands != null && args.length > 1) {
|
||||
if (args[0].equals("adventure")) {
|
||||
if (args.length == 2) return matching(List.of("reroll"), args[1]);
|
||||
if (args.length == 3 && args[1].equals("reroll")) return matching(List.of("confirm"), args[2]);
|
||||
return List.of();
|
||||
}
|
||||
var adventure = adventureQuestCommands.complete(sender,args); if(!adventure.isEmpty()) return adventure;
|
||||
if(args[0].equals("adventure-site")||args[0].equals("quest-giver"))return List.of();
|
||||
}
|
||||
if(dungeonCommands!=null&&args.length>1){var dungeon=dungeonCommands.complete(sender,args);if(!dungeon.isEmpty())return dungeon;if(args[0].equals("dungeon"))return List.of();}
|
||||
if (args.length == 1) return matching(sender instanceof org.bukkit.entity.Player
|
||||
? List.of("adventure", "adventure-site", "character", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "recover", "revive", "shop", "training-dummy")
|
||||
: List.of("adventure-site", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "recover", "shop", "training-dummy"), args[0]);
|
||||
if (args.length == 2 && args[0].equals("character") && sender instanceof org.bukkit.entity.Player)
|
||||
return matching(List.of("view"), args[1]);
|
||||
? List.of("adventure", "adventure-site", "character", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "rare-shop", "recover", "revive", "shop", "training-dummy")
|
||||
: List.of("adventure-site", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "rare-shop", "recover", "shop", "training-dummy"), args[0]);
|
||||
if (args.length == 2 && args[0].equals("character")) {
|
||||
var routes = new java.util.ArrayList<>(List.of("find"));
|
||||
if (sender instanceof org.bukkit.entity.Player) routes.addAll(List.of("recover", "show", "view"));
|
||||
return matching(routes, args[1]);
|
||||
}
|
||||
if (args.length >= 3 && args[0].equals("character") && args[1].equals("find")) {
|
||||
String prefix = String.join(" ", java.util.Arrays.copyOfRange(args, 2, args.length));
|
||||
return matchingIgnoreCase(savedCharacterSuggestions().values(), prefix);
|
||||
}
|
||||
if (args.length == 3 && args[0].equals("character")
|
||||
&& (args[1].equals("show") || args[1].equals("recover"))
|
||||
&& sender instanceof org.bukkit.entity.Player)
|
||||
return matching(savedCharacterSuggestions().keySet().stream().map(UUID::toString).toList(), args[2]);
|
||||
if (args.length == 4 && args[0].equals("character") && args[1].equals("recover")
|
||||
&& sender instanceof org.bukkit.entity.Player)
|
||||
return matching(server.getOnlinePlayers().stream().filter(org.bukkit.entity.Player::isOnline)
|
||||
.map(org.bukkit.entity.Player::getName).toList(), args[3]);
|
||||
if (args.length == 3 && args[0].equals("character") && args[1].equals("view")
|
||||
&& sender instanceof org.bukkit.entity.Player)
|
||||
return matching(server.getOnlinePlayers().stream().filter(org.bukkit.entity.Player::isOnline)
|
||||
@@ -1475,6 +1678,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
.map(org.bukkit.entity.Player::getName).toList(), args[2]);
|
||||
if(args.length==2&&args[0].equals("shop"))return matching(sender instanceof org.bukkit.entity.Player?List.of("list","place","remove","restock"):List.of("list","remove","restock"),args[1]);
|
||||
if(args.length==3&&args[0].equals("shop")&&args[1].equals("remove")&&armorers!=null)return matching(armorers.all().stream().map(value->value.id().toString()).toList(),args[2]);
|
||||
if(args.length==2&&args[0].equals("rare-shop"))return matching(sender instanceof org.bukkit.entity.Player?List.of("list","place","remove","restock"):List.of("list","remove","restock"),args[1]);
|
||||
if(args.length==3&&args[0].equals("rare-shop")&&args[1].equals("remove")&&rareMerchants!=null)return matching(rareMerchants.all().stream().map(value->value.id().toString()).toList(),args[2]);
|
||||
if (args.length == 2 && args[0].equals("training-dummy")) return matching(
|
||||
sender instanceof org.bukkit.entity.Player ? List.of("list", "place", "remove") : List.of("list", "remove"), args[1]);
|
||||
if (args.length == 3 && args[0].equals("training-dummy") && args[1].equals("remove") && trainingDummies != null)
|
||||
@@ -1494,6 +1699,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
var keys = new java.util.ArrayList<String>(); keys.add("adventure-mode");keys.add(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY);keys.add(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY);keys.add(games.dmg.fantasystick.persistence.RoleplaySettings.KEY);keys.add(games.dmg.fantasystick.persistence.PartySettings.KEY);keys.add(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY);keys.add(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY);
|
||||
keys.addAll(games.dmg.fantasystick.persistence.PartyEconomySettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.GearSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.RareSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.DownedSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.CoinSettings.keys());
|
||||
keys.add(games.dmg.fantasystick.persistence.HardMobXpSettings.KEY);
|
||||
keys.addAll(games.dmg.fantasystick.persistence.AdventureQuestSettings.keys());
|
||||
@@ -1529,6 +1736,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if(args[1].equals(games.dmg.fantasystick.persistence.PartySettings.KEY)&&partySettings!=null){String current=partySettings.value();return matching(current.equals(games.dmg.fantasystick.persistence.PartySettings.DEFAULT)?List.of(current):List.of(current,games.dmg.fantasystick.persistence.PartySettings.DEFAULT),args[2]);}
|
||||
if(partyEconomySettings!=null&&games.dmg.fantasystick.persistence.PartyEconomySettings.keys().contains(args[1])){String current=partyEconomySettings.value(args[1]);String defaults=games.dmg.fantasystick.persistence.PartyEconomySettings.defaultValue(args[1]);return matching(current.equals(defaults)?List.of(current):List.of(current,defaults),args[2]);}
|
||||
if(gearSettings!=null&&games.dmg.fantasystick.persistence.GearSettings.keys().contains(args[1])){String current=gearSettings.value(args[1]);String defaults=games.dmg.fantasystick.persistence.GearSettings.defaultValue(args[1]);return matching(current.equals(defaults)?List.of(current):List.of(current,defaults),args[2]);}
|
||||
if(rareSettings!=null&&games.dmg.fantasystick.persistence.RareSettings.keys().contains(args[1])){String current=rareSettings.value(args[1]);String defaults=games.dmg.fantasystick.persistence.RareSettings.defaultValue(args[1]);return matching(current.equals(defaults)?List.of(current):List.of(current,defaults),args[2]);}
|
||||
if(downedSettings!=null&&games.dmg.fantasystick.persistence.DownedSettings.keys().contains(args[1])){String current=downedSettings.value(args[1]);String defaults=games.dmg.fantasystick.persistence.DownedSettings.defaultValue(args[1]);return matching(current.equals(defaults)?List.of(current):List.of(current,defaults),args[2]);}
|
||||
if(coinSettings!=null&&games.dmg.fantasystick.persistence.CoinSettings.keys().contains(args[1])){String current=coinSettings.value(args[1]);String defaults=games.dmg.fantasystick.persistence.CoinSettings.defaultValue(args[1]);return matching(current.equals(defaults)?List.of(current):List.of(current,defaults),args[2]);}
|
||||
if (hardMobXpSettings != null && args[1].equals(games.dmg.fantasystick.persistence.HardMobXpSettings.KEY)) {
|
||||
String current = hardMobXpSettings.value(); String defaults = games.dmg.fantasystick.persistence.HardMobXpSettings.DEFAULT;
|
||||
@@ -1666,5 +1875,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private static List<String> matching(java.util.Collection<String> values, String prefix) {
|
||||
return values.stream().filter(value -> value.startsWith(prefix)).distinct().sorted().toList();
|
||||
}
|
||||
@Override public void close() { closed = true; if (characterIssuer != null) characterIssuer.close(); if(adminRevival!=null)adminRevival.close(); if(experienceGrant!=null)experienceGrant.close(); }
|
||||
|
||||
private static List<String> matchingIgnoreCase(java.util.Collection<String> values, String prefix) {
|
||||
String folded = prefix.toLowerCase(java.util.Locale.ROOT);
|
||||
return values.stream().filter(value -> value.toLowerCase(java.util.Locale.ROOT).startsWith(folded))
|
||||
.distinct().sorted(java.util.Comparator.comparing((String value) -> value.toLowerCase(java.util.Locale.ROOT))
|
||||
.thenComparing(java.util.Comparator.naturalOrder())).toList();
|
||||
}
|
||||
@Override public void close() { closed = true; savedSearches.clear(); if (characterIssuer != null) characterIssuer.close(); if(adminRevival!=null)adminRevival.close(); if(characterRecovery!=null)characterRecovery.close(); if(experienceGrant!=null)experienceGrant.close(); }
|
||||
}
|
||||
@@ -74,6 +74,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
private boolean closed;
|
||||
private Consumer<ControlInput> inputs = ignored -> { };
|
||||
private java.util.function.Predicate<org.bukkit.entity.Entity> managedInteractionTarget = ignored -> false;
|
||||
private java.util.function.BiPredicate<Player,ItemStack> nativeUseControl=(player,item)->false;
|
||||
private record Draw(Player player, ItemStack item, EquipmentSessions.SelectedControl selected) { }
|
||||
private final Map<UUID, Draw> draws = new HashMap<>();
|
||||
|
||||
@@ -104,6 +105,8 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
managedInteractionTarget = Objects.requireNonNull(target);
|
||||
}
|
||||
|
||||
public void attachNativeUseControl(java.util.function.BiPredicate<Player,ItemStack> control){if(closed)throw new IllegalStateException("Events closed");nativeUseControl=Objects.requireNonNull(control);}
|
||||
|
||||
/** The input recipient belongs to this plugin; it does not authorize vanilla item use. */
|
||||
public EquipmentSessionEvents(EquipmentSessions sessions, Consumer<Runnable> nextTick,
|
||||
Consumer<EquipmentSessions.Failure> diagnostics, Consumer<ControlInput> inputs) {
|
||||
@@ -144,7 +147,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void changed(PlayerInventorySlotChangeEvent event) { reconcile(event.getPlayer()); }
|
||||
public void changed(PlayerInventorySlotChangeEvent event){if(sessions.downed(event.getPlayer().getUniqueId()).isEmpty())reconcile(event.getPlayer());}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void join(org.bukkit.event.player.PlayerJoinEvent event) {
|
||||
@@ -169,7 +172,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void swap(org.bukkit.event.player.PlayerSwapHandItemsEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
Player player = event.getPlayer();if(sessions.downed(player.getUniqueId()).isPresent()){event.setCancelled(true);return;}
|
||||
int slot = player.getInventory().getHeldItemSlot();
|
||||
if (reservedExchange(player, slot)) {
|
||||
event.setCancelled(true);
|
||||
@@ -186,6 +189,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void click(InventoryClickEvent event) {
|
||||
if (!(event.getWhoClicked() instanceof Player player)) return;
|
||||
if(sessions.downed(player.getUniqueId()).isPresent()){event.setCancelled(true);return;}
|
||||
if (ownInventory(player, event.getClickedInventory()) && event.getSlot() == 40
|
||||
&& (event.getClick() == ClickType.DROP || event.getClick() == ClickType.CONTROL_DROP)
|
||||
&& (event.getAction() == InventoryAction.DROP_ONE_SLOT || event.getAction() == InventoryAction.DROP_ALL_SLOT)
|
||||
@@ -314,6 +318,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void drag(InventoryDragEvent event) {
|
||||
if (!(event.getWhoClicked() instanceof Player player)) return;
|
||||
if(sessions.downed(player.getUniqueId()).isPresent()){event.setCancelled(true);return;}
|
||||
if (control(event.getOldCursor())) {
|
||||
event.setCancelled(true);
|
||||
if (pickupClaim(event.getOldCursor(), "control_move_") && event.getNewItems().size() == 1) {
|
||||
@@ -338,6 +343,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void drop(org.bukkit.event.player.PlayerDropItemEvent event) {
|
||||
if(sessions.downed(event.getPlayer().getUniqueId()).isPresent()){event.setCancelled(true);return;}
|
||||
if (special(event.getItemDrop().getItemStack())) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@@ -345,8 +351,10 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
// also run for RIGHT_CLICK_AIR / an existing block veto, without changing that veto.
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void use(org.bukkit.event.player.PlayerInteractEvent event) {
|
||||
if(sessions.downed(event.getPlayer().getUniqueId()).isPresent()){event.setUseItemInHand(org.bukkit.event.Event.Result.DENY);return;}
|
||||
boolean allowed = event.useItemInHand() != org.bukkit.event.Event.Result.DENY;
|
||||
if (special(event.getItem()) || active(event.getPlayer()) && forbiddenEquip(event.getItem())) {
|
||||
boolean nativeControl=nativeUseControl.test(event.getPlayer(),event.getItem());
|
||||
if (special(event.getItem()) && (!nativeControl||event.getPlayer().isSneaking()) || active(event.getPlayer()) && forbiddenEquip(event.getItem())) {
|
||||
// Do not veto an unrelated door/chest interaction or undo another plugin's block veto.
|
||||
event.setUseItemInHand(org.bukkit.event.Event.Result.DENY);
|
||||
}
|
||||
@@ -354,7 +362,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
|| event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK)) {
|
||||
var selected = sessions.selectedControl(event.getPlayer(), event.getItem()).orElse(null);
|
||||
if (selected != null && !selected.ability().isEmpty() && doorInteraction(event)) return;
|
||||
if (selected != null && useGesture(selected)) {
|
||||
if (selected != null && useGesture(selected) && (!nativeControl||event.getPlayer().isSneaking())) {
|
||||
org.bukkit.entity.Entity target = selected.ability().equals("hex")
|
||||
|| selected.ability().equals("mending_light")
|
||||
|| selected.ability().equals("guardian_bond")
|
||||
@@ -413,6 +421,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void held(org.bukkit.event.player.PlayerItemHeldEvent event) {
|
||||
if(sessions.downed(event.getPlayer().getUniqueId()).isPresent()){event.setCancelled(true);return;}
|
||||
var draw = draws.get(event.getPlayer().getUniqueId());
|
||||
if (draw != null && draw.player() == event.getPlayer() && event.getPreviousSlot() != event.getNewSlot()) cancelDraw(draw);
|
||||
}
|
||||
@@ -459,7 +468,8 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
if (special(item)) event.setCancelled(true);
|
||||
if (event.getHand() == EquipmentSlot.HAND) {
|
||||
var selected = sessions.selectedControl(player, item).orElse(null);
|
||||
if (selected != null && useGesture(selected)) {
|
||||
boolean nativeControl=nativeUseControl.test(player,item);
|
||||
if (selected != null && useGesture(selected) && (!nativeControl||player.isSneaking())) {
|
||||
org.bukkit.entity.Entity target = selected.ability().equals("inspire") && player.isSneaking()
|
||||
? player : event.getRightClicked();
|
||||
emit(player, item, selected, ControlInput.Gesture.USE, target, 0, 0);
|
||||
|
||||
@@ -38,12 +38,16 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
public record Active(UUID player, UUID session, UUID character) { }
|
||||
public record ActiveCharacter(Active session, CharacterRecord record) { }
|
||||
public record SelectedControl(Active session, int slot, String ability) { }
|
||||
public record DefeatCapture(boolean playerDefeat,List<UUID> contributors,int victimLevel,BigInteger victimCoins){public DefeatCapture{contributors=List.copyOf(contributors);Objects.requireNonNull(victimCoins);}}
|
||||
|
||||
private final CharacterStorage characters;
|
||||
private final EquipmentSessionStore receipts;
|
||||
private Supplier<Duration> switchDelay;
|
||||
private java.util.function.BooleanSupplier roleplayDisabled = () -> false;
|
||||
private java.util.function.Predicate<UUID> pvpDefeat = ignored -> false;
|
||||
private java.util.function.Function<UUID,DefeatCapture> defeatCapture=ignored->new DefeatCapture(false,List.of(),0,BigInteger.ZERO);
|
||||
private java.util.function.Function<DownedEpisode,List<DownedEpisode.Reward>> downedRewardAllocation=ignored->List.of();
|
||||
private games.dmg.fantasystick.events.DownedEventBridge downedEvents;
|
||||
private java.util.function.Function<DownedEpisode,CompletionStage<?>> downedDefeatRewards=ignored->CompletableFuture.completedStage(null);
|
||||
private java.util.function.Predicate<UUID> unavailableCharacter = ignored -> false;
|
||||
private java.util.function.Function<UUID,java.util.concurrent.CompletionStage<?>> partyDefeat =
|
||||
ignored -> java.util.concurrent.CompletableFuture.completedStage(null);
|
||||
@@ -57,9 +61,12 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
private SessionControls controls;
|
||||
private GearItems gear;
|
||||
private final CharacterSheets sheets = new CharacterSheets();
|
||||
private final games.dmg.fantasystick.items.TailBooks tails = new games.dmg.fantasystick.items.TailBooks();
|
||||
private final Map<UUID, Attempt> pending = new HashMap<>();
|
||||
private final Map<UUID, Attempt> owned = new HashMap<>();
|
||||
private final Map<UUID, Attempt> active = new HashMap<>();
|
||||
private final Map<UUID, UUID> characterRecoveries = new HashMap<>();
|
||||
private final java.util.Set<UUID> recoveryRecipients = new java.util.HashSet<>();
|
||||
private final java.util.ArrayDeque<UUID> cooldownRefreshOrder = new java.util.ArrayDeque<>();
|
||||
private final java.util.Set<UUID> cooldownRefreshQueued = new java.util.HashSet<>();
|
||||
// Covers reentrant inventory setters and client sync, not just asynchronous work.
|
||||
@@ -70,6 +77,9 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
private java.util.function.BooleanSupplier adventureMode = () -> false;
|
||||
private AdventureModeLease gameModes = new AdventureModeLease();
|
||||
private SessionVitals vitals;
|
||||
private DownedDirectory downedDirectory;
|
||||
private Supplier<Duration> downedTimeout=()->Duration.ofSeconds(300);
|
||||
private Consumer<DownedEpisode> downedPresentation=ignored->{};
|
||||
private boolean closeSweepFinished;
|
||||
private int persistenceOperations;
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
@@ -78,6 +88,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
final Player player;
|
||||
final UUID owner;
|
||||
UUID character;
|
||||
CharacterSheets.Identity sheetIdentity;
|
||||
CharacterRecord record;
|
||||
SessionClaims.Claim claim;
|
||||
UUID session;
|
||||
@@ -108,6 +119,15 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
Attempt recoveryOwner;
|
||||
org.bukkit.Location entryLocation;
|
||||
boolean defeated;
|
||||
boolean downing;
|
||||
boolean downedRelocation;
|
||||
DownedEpisode downed;
|
||||
DownedEpisode finalDowned;
|
||||
DownedEpisode resumedDowned;
|
||||
DownedEpisode resumedRevive;
|
||||
boolean reviveHealthReconciled;
|
||||
Instant downedProtectionUntil;
|
||||
boolean downedPvpDefeat;
|
||||
boolean deathResolved;
|
||||
boolean gearAcknowledged;
|
||||
boolean bookAttempted;
|
||||
@@ -182,10 +202,12 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
unavailableCharacter = Objects.requireNonNull(predicate);
|
||||
}
|
||||
|
||||
public synchronized void attachPvpDefeat(java.util.function.Predicate<UUID> predicate) {
|
||||
if (!owned.isEmpty()) throw new IllegalStateException("Sessions already started");
|
||||
pvpDefeat = Objects.requireNonNull(predicate);
|
||||
}
|
||||
public synchronized void attachPvpDefeat(java.util.function.Predicate<UUID> predicate){attachDefeatCapture(player->new DefeatCapture(predicate.test(player),List.of(),0,BigInteger.ZERO));}
|
||||
public synchronized void attachDefeatCapture(java.util.function.Function<UUID,DefeatCapture> capture){if(!owned.isEmpty())throw new IllegalStateException("Sessions already started");defeatCapture=Objects.requireNonNull(capture);}
|
||||
public synchronized void attachDownedRewardAllocation(java.util.function.Function<DownedEpisode,List<DownedEpisode.Reward>> allocation){if(!owned.isEmpty())throw new IllegalStateException("Sessions already started");downedRewardAllocation=Objects.requireNonNull(allocation);}
|
||||
public synchronized void attachDownedEvents(games.dmg.fantasystick.events.DownedEventBridge events){if(!owned.isEmpty()||downedEvents!=null)throw new IllegalStateException("Downed events already attached or sessions started");downedEvents=Objects.requireNonNull(events);}
|
||||
public synchronized void attachDownedDefeatRewards(java.util.function.Function<DownedEpisode,CompletionStage<?>> settlement){if(!owned.isEmpty())throw new IllegalStateException("Sessions already started");downedDefeatRewards=Objects.requireNonNull(settlement);}
|
||||
public synchronized List<DownedEpisode.Reward> finalRewards(DownedEpisode episode){return List.copyOf(downedRewardAllocation.apply(Objects.requireNonNull(episode)));}
|
||||
|
||||
public synchronized void attachPartyDefeat(java.util.function.Function<UUID,java.util.concurrent.CompletionStage<?>> mutation) {
|
||||
if (!owned.isEmpty()) throw new IllegalStateException("Sessions already started");
|
||||
@@ -241,6 +263,8 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
});
|
||||
}
|
||||
|
||||
private void persistReturn(Runnable action){try{nextTick.accept(action);}catch(RuntimeException ignored){}}
|
||||
|
||||
private void finishDrain() {
|
||||
if (closeSweepFinished && persistenceOperations == 0 && transitioning.isEmpty()) drained.complete(null);
|
||||
}
|
||||
@@ -250,12 +274,59 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
return attempt == null ? Optional.empty() : Optional.of(new Active(player, attempt.session, attempt.character));
|
||||
}
|
||||
|
||||
/** Server-thread recovery gate; persistence still revalidates revision/generation/death atomically. */
|
||||
public synchronized boolean availableForCharacterRecovery(UUID character, UUID recipient) {
|
||||
Objects.requireNonNull(character); Objects.requireNonNull(recipient);
|
||||
return availableForCharacterRecoveryNow(character, recipient)
|
||||
&& !characterRecoveries.containsKey(character) && !recoveryRecipients.contains(recipient);
|
||||
}
|
||||
|
||||
/** Process-lifetime custody closes the entry race while durable recovery is prepared and delivered. */
|
||||
public synchronized Optional<CharacterRecoveryClaim> claimCharacterRecovery(UUID character, UUID recipient) {
|
||||
Objects.requireNonNull(character); Objects.requireNonNull(recipient);
|
||||
if (!availableForCharacterRecovery(character, recipient)) return Optional.empty();
|
||||
characterRecoveries.put(character, recipient);
|
||||
recoveryRecipients.add(recipient);
|
||||
return Optional.of(new CharacterRecoveryClaim(character, recipient));
|
||||
}
|
||||
|
||||
private boolean availableForCharacterRecoveryNow(UUID character, UUID recipient) {
|
||||
if (closed || unavailableCharacter.test(character) || downedDirectory!=null&&downedDirectory.all().stream().anyMatch(value->value.character().equals(character)&&(value.phase()==DownedEpisode.Phase.PENDING||!value.published())) || active.containsKey(recipient)
|
||||
|| pending.containsKey(recipient) || transitioning.containsKey(recipient)
|
||||
|| owned.containsKey(recipient)) return false;
|
||||
return java.util.stream.Stream.of(active.values(), pending.values(), transitioning.values(), owned.values())
|
||||
.flatMap(java.util.Collection::stream)
|
||||
.noneMatch(attempt -> character.equals(attempt.character));
|
||||
}
|
||||
|
||||
public final class CharacterRecoveryClaim implements AutoCloseable {
|
||||
private final UUID character;
|
||||
private final UUID recipient;
|
||||
private boolean released;
|
||||
private CharacterRecoveryClaim(UUID character, UUID recipient) {
|
||||
this.character = character;
|
||||
this.recipient = recipient;
|
||||
}
|
||||
@Override public void close() {
|
||||
synchronized (EquipmentSessions.this) {
|
||||
if (released) return;
|
||||
released = true;
|
||||
characterRecoveries.remove(character, recipient);
|
||||
recoveryRecipients.remove(recipient);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void attachDowned(DownedDirectory directory,Supplier<Duration> timeout,Consumer<DownedEpisode> presentation){if(closed||downedDirectory!=null)throw new IllegalStateException("Downed state already attached or sessions closed");downedDirectory=Objects.requireNonNull(directory);downedTimeout=Objects.requireNonNull(timeout);downedPresentation=Objects.requireNonNull(presentation);}
|
||||
|
||||
public synchronized Optional<DownedEpisode> downed(UUID player){var attempt=active.get(player);if(attempt!=null&&attempt.downed!=null)return Optional.of(attempt.downed);var directory=downedDirectory;return directory==null?Optional.empty():directory.all().stream().filter(value->value.player().equals(player)&&value.phase()==DownedEpisode.Phase.PENDING).findFirst();}
|
||||
|
||||
/** Server-thread effect authorization: registry identity alone is not live custody. */
|
||||
public synchronized boolean current(Active session) {
|
||||
var attempt = active.get(session.player());
|
||||
return !closed && attempt != null && attempt.session.equals(session.session())
|
||||
&& attempt.character.equals(session.character()) && attempt.player.isOnline()
|
||||
&& sheets.identify(attempt.player.getInventory().getItemInOffHand()).filter(attempt.character::equals).isPresent();
|
||||
return !closed && attempt != null && !attempt.downing && attempt.downed==null && attempt.session.equals(session.session())
|
||||
&& attempt.character.equals(session.character()) && attempt.player.isOnline() && attempt.record != null
|
||||
&& sheets.authorizes(attempt.player.getInventory().getItemInOffHand(), attempt.record);
|
||||
}
|
||||
|
||||
/** Side-effect-free server-thread snapshots for exact active-character command resolution/autocomplete. */
|
||||
@@ -403,7 +474,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
public synchronized Optional<SelectedControl> selectedControl(Player player, ItemStack reported) {
|
||||
var attempt = active.get(player.getUniqueId());
|
||||
if (closed || attempt == null || attempt.player != player || !player.isOnline() || attempt.record == null
|
||||
|| !sheets.identify(player.getInventory().getItemInOffHand()).filter(attempt.character::equals).isPresent()) {
|
||||
|| !sheets.authorizes(player.getInventory().getItemInOffHand(), attempt.record)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
int slot = player.getInventory().getHeldItemSlot();
|
||||
@@ -419,9 +490,15 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
return Optional.of(new SelectedControl(new Active(attempt.owner, attempt.session, attempt.character), slot, ability));
|
||||
}
|
||||
|
||||
public synchronized void enter(Player player) {
|
||||
public synchronized void enter(Player player){enter(player,null);}
|
||||
|
||||
private void enter(Player player,DownedEpisode resumedDowned) {
|
||||
UUID owner = player.getUniqueId();
|
||||
if (closed) return;
|
||||
if (recoveryRecipients.contains(owner)) {
|
||||
notice(owner, "CHARACTER_RECOVERY", "Administrative character recovery is still completing.");
|
||||
return;
|
||||
}
|
||||
if (roleplayDisabled.getAsBoolean()) {
|
||||
notice(owner, "ROLEPLAY_DISABLED", "Fantasy roleplaying is disabled; the character sheet was not activated.");
|
||||
return;
|
||||
@@ -440,16 +517,26 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
notice(owner, "BUSY", "Equipment persistence or recovery is pending; retry after it completes.");
|
||||
return;
|
||||
}
|
||||
var character = sheets.identify(player.getInventory().getItemInOffHand());
|
||||
if (!player.isOnline() || character.isEmpty()) {
|
||||
var identity = sheets.identity(player.getInventory().getItemInOffHand());
|
||||
if (!player.isOnline() || identity.isEmpty()) {
|
||||
notice(owner, "INVALID_SHEET", "Hold a valid live character sheet in your off hand.");
|
||||
return;
|
||||
}
|
||||
if (unavailableCharacter.test(character.orElseThrow())) {
|
||||
notice(owner, "CHARACTER_IN_CUSTODY", "That character sheet is assigned to a dungeon boss and cannot be activated.");
|
||||
var character = identity.orElseThrow().character();
|
||||
if(resumedDowned==null&&downedDirectory!=null)resumedDowned=downedDirectory.all().stream().filter(value->value.player().equals(owner)&&value.character().equals(character)&&value.phase()==DownedEpisode.Phase.PENDING).findFirst().orElse(null);
|
||||
if(resumedDowned!=null&&(!resumedDowned.player().equals(owner)||!resumedDowned.character().equals(character)||resumedDowned.phase()!=DownedEpisode.Phase.PENDING)){notice(owner,"DOWNED_RECOVERY_REQUIRED","Saved downed custody does not match the held sheet.");return;}
|
||||
if (characterRecoveries.containsKey(character)) {
|
||||
notice(owner, "CHARACTER_RECOVERY", "That character has unresolved administrative recovery custody.");
|
||||
return;
|
||||
}
|
||||
var attempt = new Attempt(player, owner, character.orElseThrow());
|
||||
if (resumedDowned==null&&unavailableCharacter.test(character)) {
|
||||
notice(owner, "CHARACTER_IN_CUSTODY", "That character has unresolved external custody and cannot be activated.");
|
||||
return;
|
||||
}
|
||||
var attempt = new Attempt(player, owner, character);
|
||||
attempt.resumedDowned=resumedDowned;
|
||||
if(resumedDowned==null&&downedDirectory!=null)attempt.resumedRevive=downedDirectory.all().stream().filter(value->value.player().equals(owner)&&value.character().equals(character)&&value.phase()==DownedEpisode.Phase.RESOLVED&&value.outcome()==DownedEpisode.Outcome.REVIVED&&!value.published()).max(java.util.Comparator.comparing(DownedEpisode::resolvedAt)).orElse(null);
|
||||
attempt.sheetIdentity = identity.orElseThrow();
|
||||
if (adventureMode.getAsBoolean() && player.getGameMode() != org.bukkit.GameMode.ADVENTURE) {
|
||||
attempt.previousGameMode = player.getGameMode().name();
|
||||
}
|
||||
@@ -500,12 +587,19 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
}
|
||||
if (failure != null) { fail(attempt, failure); return; }
|
||||
var record = loaded.character().orElse(null);
|
||||
if (unavailableCharacter.test(attempt.character)) {
|
||||
refuse(attempt, "CHARACTER_IN_CUSTODY", "That character was assigned to a dungeon boss before activation completed."); return;
|
||||
if (attempt.resumedDowned==null&&unavailableCharacter.test(attempt.character)) {
|
||||
refuse(attempt, "CHARACTER_IN_CUSTODY", "That character entered unresolved external custody before activation completed."); return;
|
||||
}
|
||||
if (record == null || record.dead() || record.health().signum() <= 0) {
|
||||
refuse(attempt, "UNAVAILABLE_CHARACTER", "The saved character is missing, dead or has no health."); return;
|
||||
}
|
||||
if(attempt.resumedRevive!=null&&!attempt.reviveHealthReconciled){attempt.reviveHealthReconciled=true;var maximum=record.maximumHealth().min(NativeHealthLimit.read());if(record.health().compareTo(maximum)<0){characters.saveHealth(record.id(),record.revision(),maximum,maximum).whenComplete((saved,saveFailure)->schedule(attempt,()->loaded(attempt,new Loaded(saved==null?Optional.empty():saved,loaded.state()),saveFailure)));return;}}
|
||||
if(attempt.resumedDowned!=null&&(resolveWorld(attempt.player,attempt.resumedDowned.world())==null||resolveWorld(attempt.player,attempt.resumedDowned.entryWorld())==null)){refuse(attempt,"DOWNED_WORLD_UNAVAILABLE","Saved downed or return world is unavailable; custody remains unresolved.");return;}
|
||||
if (!sheets.authorizes(attempt.player.getInventory().getItemInOffHand(), record)
|
||||
|| attempt.sheetIdentity == null || !attempt.sheetIdentity.equals(
|
||||
sheets.identity(attempt.player.getInventory().getItemInOffHand()).orElse(null))) {
|
||||
refuse(attempt, "STALE_SHEET", "That character sheet generation is no longer authorized."); return;
|
||||
}
|
||||
if (loaded.state().isPresent()) {
|
||||
var state = loaded.state().orElseThrow();
|
||||
if (state.pending() != null) {
|
||||
@@ -530,14 +624,14 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
if (owned.values().stream().anyMatch(other -> attempt.character.equals(other.character))) {
|
||||
refuse(attempt, "CHARACTER_BUSY", "This character has an unresolved equipment session."); return;
|
||||
}
|
||||
attempt.claim = claims.tryClaim(attempt.owner, record).orElse(null);
|
||||
attempt.claim = (attempt.resumedDowned==null?claims.tryClaim(attempt.owner,record):claims.tryClaim(attempt.owner,record,attempt.resumedDowned.session())).orElse(null);
|
||||
if (attempt.claim == null) { refuse(attempt, "CHARACTER_BUSY", "This character or player already has a session."); return; }
|
||||
attempt.session = attempt.claim.sessionId();
|
||||
owned.put(attempt.owner, attempt);
|
||||
controls.create(record, attempt.owner, attempt.session); // Validate all nine controls before preparation/packing.
|
||||
if (vitals != null) attempt.ordinaryVitals = vitals.capture(attempt.player);
|
||||
attempt.saving = true;
|
||||
persist(() -> receipts.prepare(attempt.owner, attempt.session, attempt.character, attempt.previousGameMode, attempt.ordinaryVitals), (saved, error) -> {
|
||||
persist(() -> attempt.resumedDowned==null?receipts.prepare(attempt.owner,attempt.session,attempt.character,attempt.previousGameMode,attempt.ordinaryVitals):receipts.resume(attempt.owner,attempt.session,attempt.character,attempt.previousGameMode,attempt.ordinaryVitals), (saved, error) -> {
|
||||
attempt.saving = false;
|
||||
if (error != null) { attempt.uncertainSave = true; fail(attempt, error); return; }
|
||||
attempt.acknowledged = true;
|
||||
@@ -564,7 +658,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
boolean packed = false;
|
||||
try {
|
||||
var items = controls.create(record, attempt.owner, attempt.session);
|
||||
attempt.entryLocation = attempt.player.getLocation().clone(); // Server thread, before activation.
|
||||
attempt.entryLocation = attempt.resumedDowned==null?attempt.player.getLocation().clone():location(resolveWorld(attempt.player,attempt.resumedDowned.entryWorld()),attempt.resumedDowned.entryX(),attempt.resumedDowned.entryY(),attempt.resumedDowned.entryZ(),attempt.resumedDowned.entryYaw(),attempt.resumedDowned.entryPitch()); // Server thread, before activation.
|
||||
attempt.modeStarted = attempt.previousGameMode != null;
|
||||
if (!gameModes.enter(attempt.player, attempt.session, attempt.previousGameMode)) {
|
||||
attempt.modeStarted = false; // Returned refusal is not an uncertain setter exception.
|
||||
@@ -604,6 +698,9 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
attempt.presentationInstalled = true;
|
||||
try { presentation.activate(attempt.player, attempt.session, record); }
|
||||
catch (RuntimeException | LinkageError error) { withdrawPresentation(attempt); diagnostic(attempt, error); }
|
||||
if(attempt.resumedDowned!=null){attempt.downed=attempt.resumedDowned;attempt.downing=false;attempt.downedRelocation=true;try{var episode=attempt.resumedDowned;var anchor=location(resolveWorld(attempt.player,episode.world()),episode.x(),episode.y(),episode.z(),episode.yaw(),episode.pitch());if(!attempt.player.teleport(anchor))throw new IllegalStateException("Saved downed anchor teleport was refused");attempt.player.setHealth(1);downedPresentation.accept(episode);}finally{attempt.downedRelocation=false;}}
|
||||
else if(downedDirectory!=null)attempt.downedProtectionUntil=downedDirectory.all().stream().filter(value->value.player().equals(attempt.owner)&&value.character().equals(attempt.character)&&value.phase()==DownedEpisode.Phase.RESOLVED&&value.outcome()==DownedEpisode.Outcome.REVIVED&&value.protectionUntil()!=null&&clock.instant().isBefore(value.protectionUntil())).max(java.util.Comparator.comparing(DownedEpisode::resolvedAt)).map(DownedEpisode::protectionUntil).orElse(null);
|
||||
if(attempt.resumedRevive!=null){var recovered=attempt.resumedRevive;if(downedEvents!=null)downedEvents.outcomePublished(recovered);downedDirectory.publish(recovered.id(),recovered.outcomeOperation()).whenComplete((published,publishFailure)->{if(publishFailure!=null)diagnostic(attempt,publishFailure);else if(downedEvents!=null)downedEvents.outcomePublished(published);});}
|
||||
}
|
||||
attempt.player.updateInventory();
|
||||
} catch (RuntimeException error) {
|
||||
@@ -660,6 +757,15 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
public synchronized void defeat(org.bukkit.event.entity.PlayerDeathEvent event) {
|
||||
if (closed || event.isCancelled()) return;
|
||||
UUID owner = event.getEntity().getUniqueId();
|
||||
var downedStore=downedDirectory;var pendingAttempt=active.get(owner);
|
||||
if(downedStore!=null&&pendingAttempt!=null){
|
||||
if(pendingAttempt.downing||pendingAttempt.downed!=null){event.setCancelled(true);event.setReviveHealth(1);return;}
|
||||
pendingAttempt.downing=true;var capturedDefeat=defeatCapture.apply(owner);pendingAttempt.downedPvpDefeat=capturedDefeat.playerDefeat();
|
||||
var location=pendingAttempt.player.getLocation().clone();var causing=event.getDamageSource().getCausingEntity();var damageType=event.getDamageSource().getDamageType();var attribution=new DownedEpisode.Attribution(causing==null?null:causing.getUniqueId(),damageType==null?"unknown":damageType.getKey().asString(),capturedDefeat.playerDefeat(),capturedDefeat.contributors(),capturedDefeat.victimLevel(),capturedDefeat.victimCoins());
|
||||
var work=downedStore.begin(pendingAttempt.character,pendingAttempt.owner,pendingAttempt.session,location.getWorld().getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch(),pendingAttempt.entryLocation.getWorld().getUID(),pendingAttempt.entryLocation.getX(),pendingAttempt.entryLocation.getY(),pendingAttempt.entryLocation.getZ(),pendingAttempt.entryLocation.getYaw(),pendingAttempt.entryLocation.getPitch(),pendingAttempt.record.name(),pendingAttempt.player.getName(),pendingAttempt.record.revision(),downedTimeout.get(),attribution);
|
||||
event.setCancelled(true);event.setReviveHealth(1);
|
||||
work.whenComplete((episode,failure)->persistReturn(()->{synchronized(EquipmentSessions.this){if(failure!=null){diagnostic(pendingAttempt,failure);notice(owner,"DOWNED_UNCERTAIN","Downed state could not be acknowledged; custody remains blocked.");return;}if(active.get(owner)!=pendingAttempt||!pendingAttempt.session.equals(episode.session()))return;pendingAttempt.downing=false;pendingAttempt.downed=episode;if(downedEvents!=null)downedEvents.downed(episode);notice(owner,"DOWNED","§cYou are downed. Accept death or wait for Revive.");try{downedPresentation.accept(episode);}catch(RuntimeException presentationFailure){diagnostic(pendingAttempt,presentationFailure);}}}));return;
|
||||
}
|
||||
var attempt = active.remove(owner);
|
||||
if (attempt == null) {
|
||||
var transition = transitioning.get(owner);
|
||||
@@ -678,7 +784,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
boolean reserved = transitioning.putIfAbsent(attempt.owner, attempt) == null;
|
||||
// Submit first: any subsequent native getter/setter/notice can reenter disable, which
|
||||
// closes CharacterStorage. No dependent character work is submitted after this point.
|
||||
boolean loseCoins = pvpDefeat.test(owner);
|
||||
boolean loseCoins=defeatCapture.apply(owner).playerDefeat();
|
||||
persist(() -> characters.recordDeath(attempt.character, loseCoins), (dead, failure) -> {
|
||||
if (failure != null) { diagnostic(attempt, failure); conversionUncertain(attempt, "Death save failed or is uncertain"); return; }
|
||||
if (dead.isEmpty()) { conversionUncertain(attempt, "Character was missing or already dead; no new Tail authorized"); return; }
|
||||
@@ -733,6 +839,25 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
private static org.bukkit.World resolveWorld(Player player,UUID id){var current=player.getWorld();if(current!=null&&id.equals(current.getUID()))return current;return org.bukkit.Bukkit.getWorld(id);}
|
||||
private static org.bukkit.Location location(org.bukkit.World world,double x,double y,double z,float yaw,float pitch){return new org.bukkit.Location(world,x,y,z,yaw,pitch);}
|
||||
public synchronized boolean downedRelocation(UUID player){var attempt=active.get(player);return attempt!=null&&attempt.downedRelocation;}
|
||||
|
||||
public synchronized boolean downedProtected(UUID player){var attempt=active.get(player);return attempt!=null&&attempt.downed==null&&attempt.downedProtectionUntil!=null&&clock.instant().isBefore(attempt.downedProtectionUntil);}
|
||||
|
||||
/** Finish one durable Revive outcome on the exact retained active session. */
|
||||
public synchronized void reviveDowned(DownedEpisode episode,Consumer<Boolean> completion){Objects.requireNonNull(episode);Objects.requireNonNull(completion);if(episode.phase()!=DownedEpisode.Phase.RESOLVED||episode.outcome()!=DownedEpisode.Outcome.REVIVED){completion.accept(false);return;}var attempt=active.get(episode.player());if(attempt==null||attempt.downed==null||!attempt.downed.id().equals(episode.id())||!attempt.session.equals(episode.session())||!attempt.character.equals(episode.character())){completion.accept(false);return;}var expected=attempt.record.revision();var maximum=attempt.record.maximumHealth().min(NativeHealthLimit.read());characters.saveHealth(attempt.character,expected,maximum,maximum).whenComplete((saved,failure)->persistReturn(()->{synchronized(EquipmentSessions.this){if(failure!=null||saved==null||saved.isEmpty()||active.get(attempt.owner)!=attempt||attempt.downed==null||!attempt.downed.id().equals(episode.id())){if(failure!=null)diagnostic(attempt,failure);completion.accept(false);return;}var record=saved.orElseThrow();try{if(vitals!=null&&!record.revision().equals(attempt.record.revision()))vitals.updateProgress(attempt.player,attempt.session,attempt.record,record);attempt.record=record;attempt.downed=null;attempt.downing=false;attempt.downedProtectionUntil=episode.protectionUntil();double nativeMaximum=Objects.requireNonNull(attempt.player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).getValue();attempt.player.setHealth(Math.min(nativeMaximum,record.health().doubleValue()));refreshControls(attempt,new Active(attempt.owner,attempt.session,attempt.character));attempt.player.closeInventory();notice(attempt.owner,"REVIVED","§aRevive restored your downed character.");completion.accept(true);}catch(RuntimeException publicationFailure){diagnostic(attempt,publicationFailure);completion.accept(false);}}}));}
|
||||
|
||||
/** Publish one already-durable final-death outcome through the existing restoration/Tail custody path. */
|
||||
public synchronized boolean finalizeDowned(DownedEpisode episode){Objects.requireNonNull(episode);if(episode.phase()!=DownedEpisode.Phase.RESOLVED||episode.outcome()!=DownedEpisode.Outcome.FINAL_DEATH)return false;var attempt=active.get(episode.player());if(attempt==null){persist(()->recordOfflineFinalDeath(episode),(dead,failure)->{if(failure!=null)diagnostic(new Failure(episode.player(),episode.session(),failure));else if(dead.isEmpty())diagnostic(new Failure(episode.player(),episode.session(),new IllegalStateException("Offline final death conflicted with saved character")));});return true;}if(attempt.downed==null||!attempt.downed.id().equals(episode.id())||!attempt.session.equals(episode.session())||!attempt.character.equals(episode.character()))return false;active.remove(attempt.owner,attempt);attempt.finalDowned=episode;attempt.downed=null;withdrawPresentation(attempt);attempt.defeated=true;boolean reserved=transitioning.putIfAbsent(attempt.owner,attempt)==null;boolean loseCoins=episode.attribution().playerDefeat();persist(()->characters.recordFinalDeath(attempt.character,attempt.record.revision(),episode.id(),episode.resolvedAt(),loseCoins),(dead,failure)->{if(failure!=null){diagnostic(attempt,failure);conversionUncertain(attempt,"Final death save failed or is uncertain");return;}if(dead.isEmpty()){conversionUncertain(attempt,"Character was missing or final death conflicted; no Tail authorized");return;}attempt.deathResolved=true;attempt.deathRecord=dead.orElseThrow();if(downedEvents!=null)downedEvents.finalDeathRecorded(episode);if(closed){conversionUncertain(attempt,"Shutdown before reward, party defeat and Tail conversion");return;}java.util.concurrent.CompletionStage<?> partyMutation=finalConsequences(episode);partyMutation.whenComplete((ignored,partyFailure)->{synchronized(EquipmentSessions.this){if(partyFailure!=null){diagnostic(attempt,partyFailure);conversionUncertain(attempt,"Party defeat mutation failed");return;}attempt.partyDefeatAcknowledged=true;queueTail(attempt);}});});try{var restored=restoreExit(attempt);if(restored==EquipmentStorage.RestoreResult.RESTORED){var retained=attempt.player.getInventory().getItemInOffHand();attempt.deathReference=copy(retained);attempt.deathNativeReference=nativeIdentity(retained);attempt.deathView=attempt.player.getOpenInventory();}double maximum=Objects.requireNonNull(attempt.player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).getValue();attempt.player.setHealth(maximum);notice(attempt.owner,"DEFEAT","§cYour character was defeated.");attempt.defeatReturnTeleport=true;try{if(!attempt.player.teleport(attempt.entryLocation.clone()))notice(attempt.owner,"DEFEAT_RETURN_FAILED","Return to the session entry location was refused; you remain here.");}finally{attempt.defeatReturnTeleport=false;if(!attempt.deliveryInvalidated&&owned.get(attempt.owner)==attempt&&attempt.player.isOnline())attempt.deathView=attempt.player.getOpenInventory();}}catch(RuntimeException failure){diagnostic(attempt,failure);conversionUncertain(attempt,"Native final-death transition failed; inspect retained equipment and death state");}finally{if(reserved)transitioning.remove(attempt.owner,attempt);finishDrain();}return true;}
|
||||
|
||||
private CompletionStage<Void> finalConsequences(DownedEpisode episode){var result=new CompletableFuture<Void>();try{nextTick.accept(()->{CompletionStage<?> work;try{work=downedDefeatRewards.apply(episode).thenCompose(ignored->partyDefeat.apply(episode.character()));}catch(RuntimeException failure){result.completeExceptionally(failure);return;}work.whenComplete((ignored,failure)->{if(failure==null)result.complete(null);else result.completeExceptionally(failure);});});}catch(RuntimeException rejected){result.completeExceptionally(rejected);}return result;}
|
||||
|
||||
private CompletionStage<Optional<CharacterRecord>> recordOfflineFinalDeath(DownedEpisode episode){return characters.find(episode.character()).thenCompose(found->{if(found.isEmpty())return CompletableFuture.completedStage(Optional.empty());var current=found.orElseThrow();return characters.recordFinalDeath(episode.character(),current.revision(),episode.id(),episode.resolvedAt(),episode.attribution().playerDefeat());}).thenCompose(dead->{if(dead.isEmpty())return CompletableFuture.completedStage(dead);if(downedEvents!=null)downedEvents.finalDeathRecorded(episode);return finalConsequences(episode).thenApply(ignored->dead);});}
|
||||
|
||||
private void reconcileOfflineFinalDeath(Player player,DownedEpisode episode){persist(()->recordOfflineFinalDeath(episode),(dead,failure)->persistReturn(()->{synchronized(EquipmentSessions.this){if(failure!=null){diagnostic(new Failure(episode.player(),episode.session(),failure));return;}if(dead.isEmpty()||closed||!player.isOnline())return;var record=dead.orElseThrow();var current=player.getInventory().getItemInOffHand();var existing=tails.identify(current).orElse(null);if(existing!=null&&existing.characterId().equals(record.id())&&existing.deathId().equals(record.deathId())){publishRecoveredDowned(episode);return;}if(!sheets.authorizes(current,record)){notice(player.getUniqueId(),"DOWNED_RECOVERY_REQUIRED","Expired downed custody does not match the exact saved sheet; no native item was changed.");return;}try{player.getInventory().setItemInOffHand(tails.create(record));player.updateInventory();publishRecoveredDowned(episode);}catch(RuntimeException nativeFailure){diagnostic(new Failure(episode.player(),episode.session(),nativeFailure));notice(player.getUniqueId(),"DOWNED_RECOVERY_UNCERTAIN","Final-death Tail delivery was uncertain; retain inventory for inspection.");}}}));}
|
||||
private void publishRecoveredDowned(DownedEpisode episode){if(downedEvents!=null)downedEvents.outcomePublished(episode);downedDirectory.publish(episode.id(),episode.outcomeOperation()).whenComplete((published,failure)->{if(failure!=null)diagnostic(new Failure(episode.player(),episode.session(),failure));else if(downedEvents!=null)downedEvents.outcomePublished(published);});}
|
||||
|
||||
/** No automatic delivery after quit/respawn, including a reconnect of the same Player ID. */
|
||||
public synchronized void invalidateContext(UUID owner) {
|
||||
invalidateCursor(owner);
|
||||
@@ -811,6 +936,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
conversionUncertain(attempt, "Context changed during Tail write; delivery may already have happened"); return;
|
||||
}
|
||||
attempt.bookDelivered = true;
|
||||
publishDowned(attempt.finalDowned,attempt);
|
||||
release(attempt);
|
||||
sync(attempt);
|
||||
} catch (RuntimeException failure) {
|
||||
@@ -822,12 +948,15 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
private void publishDowned(DownedEpisode episode,Attempt attempt){if(episode==null||episode.published()||downedDirectory==null)return;if(downedEvents!=null)downedEvents.outcomePublished(episode);downedDirectory.publish(episode.id(),episode.outcomeOperation()).whenComplete((published,failure)->{if(failure!=null)diagnostic(attempt,failure);else if(downedEvents!=null)downedEvents.outcomePublished(published);});}
|
||||
|
||||
private boolean tailCustody(Attempt attempt) {
|
||||
var current = attempt.player.getInventory().getItemInOffHand();
|
||||
var authority = attempt.deathRecord == null ? attempt.record : attempt.deathRecord;
|
||||
return attempt.deathReference != null && attempt.deathNativeReference != null
|
||||
&& Objects.equals(attempt.deathReference, copy(current))
|
||||
&& nativeIdentity(current) == attempt.deathNativeReference
|
||||
&& sheets.identify(attempt.deathReference).filter(attempt.character::equals).isPresent()
|
||||
&& authority != null && sheets.authorizes(attempt.deathReference, authority)
|
||||
&& attempt.deathReference.getAmount() >= 1
|
||||
&& attempt.deathReference.getAmount() <= attempt.deathReference.getMaxStackSize();
|
||||
}
|
||||
@@ -945,6 +1074,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
}
|
||||
|
||||
private void saveRemainingHealth(Attempt attempt) {
|
||||
if(attempt.downing||attempt.downed!=null){attempt.healthAcknowledged=true;return;}
|
||||
if (!attempt.vitalsStarted || attempt.defeated || attempt.healthSaveRequested) return;
|
||||
attempt.healthSaveRequested = true; // Failed/uncertain writes are not authority for an automatic replay.
|
||||
try {
|
||||
@@ -1042,7 +1172,8 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
|| !Objects.equals(attempt.entryMarker, EquipmentSessionMarker.read(attempt.player).orElse(null)))
|
||||
|| !Arrays.equals(attempt.snapshot, snapshot(attempt.player))
|
||||
|| !Objects.equals(attempt.cursor, copy(attempt.player.getItemOnCursor()))
|
||||
|| !sheets.identify(attempt.player.getInventory().getItemInOffHand()).filter(attempt.character::equals).isPresent()) {
|
||||
|| attempt.sheetIdentity != null && !attempt.sheetIdentity.equals(
|
||||
sheets.identity(attempt.player.getInventory().getItemInOffHand()).orElse(null))) {
|
||||
refuse(attempt, "STALE_ENTRY", "Entry changed before preparation completed; retry with your current equipment.");
|
||||
return false;
|
||||
}
|
||||
@@ -1103,7 +1234,11 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
public synchronized void recover(Player player) { recover(player, false); }
|
||||
|
||||
/** Join reconciliation: recover a pending receipt, otherwise request normal entry. */
|
||||
public synchronized void arrive(Player player) { recover(player, true); }
|
||||
public synchronized void arrive(Player player) {
|
||||
var directory=downedDirectory;
|
||||
if(directory!=null){var death=directory.all().stream().filter(value->value.player().equals(player.getUniqueId())&&value.phase()==DownedEpisode.Phase.RESOLVED&&value.outcome()==DownedEpisode.Outcome.FINAL_DEATH&&!value.published()).findFirst().orElse(null);if(death!=null){reconcileOfflineFinalDeath(player,death);return;}}
|
||||
recover(player,true);
|
||||
}
|
||||
|
||||
private void recover(Player player, boolean arrival) {
|
||||
UUID owner = player.getUniqueId();
|
||||
@@ -1144,7 +1279,8 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
var receipt = state.map(EquipmentSessionStore.State::pending).orElse(null);
|
||||
if (receipt == null) {
|
||||
if (attempt.arrival) {
|
||||
if (sheets.identify(attempt.player.getInventory().getItemInOffHand()).isPresent()) enter(attempt.player);
|
||||
var episode=downedDirectory==null?null:downedDirectory.all().stream().filter(value->value.player().equals(attempt.owner)&&value.phase()==DownedEpisode.Phase.PENDING).findFirst().orElse(null);
|
||||
if (sheets.identify(attempt.player.getInventory().getItemInOffHand()).isPresent()) enter(attempt.player,episode);
|
||||
else if (EquipmentSessionMarker.present(attempt.player) || hasRemnants(attempt.player)) {
|
||||
notice(attempt.owner, "NATIVE_RECOVERY_REQUIRED", "Native equipment remnants have no pending receipt; items were retained for inspection.");
|
||||
}
|
||||
@@ -1210,6 +1346,8 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
}
|
||||
for (var owner : java.util.List.copyOf(pending.keySet())) cancelEntry(owner);
|
||||
for (var attempt : java.util.List.copyOf(active.values())) exit(attempt.player);
|
||||
characterRecoveries.clear();
|
||||
recoveryRecipients.clear();
|
||||
claims.close();
|
||||
} finally {
|
||||
closeSweepFinished = true;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.command.TabExecutor;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Strict ordinary-player command root for personal adventure and presentation controls. */
|
||||
public final class FantasyPlayerCommand implements TabExecutor {
|
||||
private final EquipmentAdminCommand adventures;
|
||||
private final FantasyFeedbackCommand feedback;
|
||||
private final FantasyParticlesCommand particles;
|
||||
|
||||
public FantasyPlayerCommand(EquipmentAdminCommand adventures, FantasyFeedbackCommand feedback,
|
||||
FantasyParticlesCommand particles) {
|
||||
this.adventures = Objects.requireNonNull(adventures);
|
||||
this.feedback = Objects.requireNonNull(feedback);
|
||||
this.particles = Objects.requireNonNull(particles);
|
||||
}
|
||||
|
||||
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (args.length == 0) {
|
||||
if (!(sender instanceof Player)) sender.sendMessage("Fantasy Stick player controls require an in-game player.");
|
||||
return false;
|
||||
}
|
||||
return switch (args[0]) {
|
||||
case "adventure" -> adventures.playerAdventure(sender, args);
|
||||
case "feedback" -> feedback.onCommand(sender, command, label, tail(args));
|
||||
case "particles" -> particles.onCommand(sender, command, label, tail(args));
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
@Override public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
|
||||
if (!(sender instanceof Player)) return List.of();
|
||||
if (args.length == 1) return matching(List.of("adventure", "feedback", "particles"), args[0]);
|
||||
return switch (args[0]) {
|
||||
case "adventure" -> adventures.completePlayerAdventure(sender, args);
|
||||
case "feedback" -> feedback.onTabComplete(sender, command, alias, tail(args));
|
||||
case "particles" -> particles.onTabComplete(sender, command, alias, tail(args));
|
||||
default -> List.of();
|
||||
};
|
||||
}
|
||||
|
||||
private static String[] tail(String[] args) {
|
||||
return java.util.Arrays.copyOfRange(args, 1, args.length);
|
||||
}
|
||||
|
||||
private static List<String> matching(List<String> values, String rawPrefix) {
|
||||
String prefix = rawPrefix.toLowerCase(Locale.ROOT);
|
||||
return values.stream().filter(value -> value.startsWith(prefix)).toList();
|
||||
}
|
||||
}
|
||||
@@ -21,21 +21,20 @@ public final class SessionClaims implements AutoCloseable {
|
||||
* sheet possession, lifecycle, switching deadline and native preflight. This method does no file I/O.
|
||||
*/
|
||||
public synchronized Optional<Claim> tryClaim(UUID player, CharacterRecord character) {
|
||||
java.util.Objects.requireNonNull(player);
|
||||
java.util.Objects.requireNonNull(character);
|
||||
if (closed || character.dead() || character.health().signum() == 0
|
||||
|| players.containsKey(player) || characters.containsKey(character.id())) return Optional.empty();
|
||||
var claim = new Claim(player, character.id());
|
||||
players.put(player, claim);
|
||||
characters.put(character.id(), claim);
|
||||
return Optional.of(claim);
|
||||
return tryClaim(player,character,UUID.randomUUID());
|
||||
}
|
||||
|
||||
synchronized Optional<Claim> tryClaim(UUID player,CharacterRecord character,UUID session){
|
||||
java.util.Objects.requireNonNull(player);java.util.Objects.requireNonNull(character);java.util.Objects.requireNonNull(session);
|
||||
if(closed||character.dead()||character.health().signum()==0||players.containsKey(player)||characters.containsKey(character.id()))return Optional.empty();
|
||||
var claim=new Claim(player,character.id(),session);players.put(player,claim);characters.put(character.id(),claim);return Optional.of(claim);
|
||||
}
|
||||
|
||||
public final class Claim implements AutoCloseable {
|
||||
private final UUID player;
|
||||
private final UUID character;
|
||||
private final UUID session = UUID.randomUUID();
|
||||
private Claim(UUID player, UUID character) { this.player = player; this.character = character; }
|
||||
private final UUID session;
|
||||
private Claim(UUID player,UUID character,UUID session){this.player=player;this.character=character;this.session=session;}
|
||||
public UUID playerId() { return player; }
|
||||
public UUID characterId() { return character; }
|
||||
public UUID sessionId() { return session; }
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import games.dmg.fantasystick.persistence.RareSettings;
|
||||
import games.dmg.fantasystick.session.DownedController;
|
||||
import games.dmg.fantasystick.session.DownedDirectory;
|
||||
import games.dmg.fantasystick.session.DownedEpisode;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Clock;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Explicit exact-episode Phoenix Down reservation, rescue and restart reconciliation. */
|
||||
public final class PhoenixDownService {
|
||||
public enum Status { APPLIED, APPLIED_PENDING_RECOVERY, NOT_AVAILABLE, STALE, EXPIRED, FAILED }
|
||||
public record Outcome(Status status) { }
|
||||
@FunctionalInterface public interface Rescue {
|
||||
void revive(Player player, java.time.Duration protection, DownedEpisode.Decision decision,
|
||||
Consumer<DownedController.RescueStatus> result);
|
||||
}
|
||||
|
||||
private final RareInventoryDirectory inventory;
|
||||
private final RareSettings settings;
|
||||
private final DownedDirectory downed;
|
||||
private final EquipmentSessions sessions;
|
||||
private final Clock clock;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Rescue rescue;
|
||||
private final Consumer<Throwable> failures;
|
||||
|
||||
public PhoenixDownService(RareInventoryDirectory inventory, RareSettings settings,
|
||||
DownedDirectory downed, EquipmentSessions sessions, Clock clock,
|
||||
Consumer<Runnable> nextTick, Rescue rescue, Consumer<Throwable> failures) {
|
||||
this.inventory=Objects.requireNonNull(inventory);this.settings=Objects.requireNonNull(settings);
|
||||
this.downed=Objects.requireNonNull(downed);this.sessions=Objects.requireNonNull(sessions);
|
||||
this.clock=Objects.requireNonNull(clock);this.nextTick=Objects.requireNonNull(nextTick);
|
||||
this.rescue=Objects.requireNonNull(rescue);this.failures=Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
public BigInteger quantity(UUID character) { return inventory.quantity(character, RareProduct.PHOENIX_DOWN); }
|
||||
|
||||
/** A resolved matching outcome consumed the item; every provably unclaimed reservation is refunded. */
|
||||
public void install() {
|
||||
for (var use : inventory.unresolvedUses()) {
|
||||
if (!RareProduct.PHOENIX_DOWN.id().equals(use.product())) continue;
|
||||
var episode=downed.all().stream().filter(value->use.operation().equals(value.outcomeOperation())
|
||||
&& value.outcome()==DownedEpisode.Outcome.REVIVED && value.decision()!=null
|
||||
&& "phoenix_down".equals(value.decision().source())).findFirst();
|
||||
var recovery=episode.isPresent()?inventory.commitUse(use.operation()):inventory.refundUse(use.operation());
|
||||
recovery.exceptionally(failure->{report(failure);return RareInventoryDirectory.UseStatus.RESERVED;});
|
||||
}
|
||||
}
|
||||
|
||||
public void use(Player player, UUID episodeId, Consumer<Outcome> result) {
|
||||
Objects.requireNonNull(player);Objects.requireNonNull(episodeId);Objects.requireNonNull(result);
|
||||
var episode=current(player,episodeId);
|
||||
if(episode==null){result.accept(new Outcome(Status.STALE));return;}
|
||||
if(!clock.instant().isBefore(episode.expiresAt())){result.accept(new Outcome(Status.EXPIRED));return;}
|
||||
var expected=quantity(episode.character());
|
||||
if(expected.signum()<=0){result.accept(new Outcome(Status.NOT_AVAILABLE));return;}
|
||||
var attribute=player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH);double health=player.getHealth(),maximum=attribute==null?Double.NaN:attribute.getValue();
|
||||
if(!Double.isFinite(health)||!Double.isFinite(maximum)||health<0||maximum<=0||health>maximum){result.accept(new Outcome(Status.FAILED));return;}
|
||||
var operation=UUID.randomUUID();
|
||||
inventory.reserveUse(episode.character(),RareProduct.PHOENIX_DOWN,operation,expected).whenComplete((reserved,failure)->publish(()->{
|
||||
if(failure!=null){report(failure);result.accept(new Outcome(Status.FAILED));return;}
|
||||
if(reserved.status()!=RareInventoryDirectory.UseStatus.RESERVED){result.accept(new Outcome(reserved.status()==RareInventoryDirectory.UseStatus.NOT_AVAILABLE?Status.NOT_AVAILABLE:Status.STALE));return;}
|
||||
var accepted=current(player,episodeId);
|
||||
if(accepted==null||!clock.instant().isBefore(accepted.expiresAt())){refund(operation,result,accepted==null?Status.STALE:Status.EXPIRED);return;}
|
||||
var proof=new DownedEpisode.Phoenix(expected,expected.subtract(BigInteger.ONE),java.math.BigDecimal.valueOf(health),java.math.BigDecimal.valueOf(maximum),java.math.BigDecimal.valueOf(maximum),"downed_menu");
|
||||
var decision=new DownedEpisode.Decision("phoenix_down",accepted.player(),accepted.playerName(),accepted.character(),accepted.characterName(),accepted.session(),operation,clock.instant(),null,null,null,proof);
|
||||
rescue.revive(player,settings.phoenixProtection(),decision,status->complete(operation,status,result));
|
||||
}));
|
||||
}
|
||||
|
||||
private DownedEpisode current(Player player,UUID episodeId){
|
||||
var episode=downed.all().stream().filter(value->value.id().equals(episodeId)&&value.phase()==DownedEpisode.Phase.PENDING).findFirst().orElse(null);
|
||||
if(episode==null||!player.isOnline()||!episode.player().equals(player.getUniqueId()))return null;
|
||||
var active=sessions.active(player.getUniqueId()).orElse(null);
|
||||
return active!=null&&active.session().equals(episode.session())&&active.character().equals(episode.character())?episode:null;
|
||||
}
|
||||
|
||||
private void complete(UUID operation,DownedController.RescueStatus status,Consumer<Outcome> result){
|
||||
boolean won=downed.all().stream().anyMatch(value->operation.equals(value.outcomeOperation())&&value.outcome()==DownedEpisode.Outcome.REVIVED&&value.decision()!=null&&"phoenix_down".equals(value.decision().source()));
|
||||
if(won){inventory.commitUse(operation).whenComplete((ignored,failure)->publish(()->{if(failure!=null){report(failure);result.accept(new Outcome(Status.APPLIED_PENDING_RECOVERY));}else result.accept(new Outcome(status==DownedController.RescueStatus.APPLIED?Status.APPLIED:Status.APPLIED_PENDING_RECOVERY));}));return;}
|
||||
var mapped=switch(status){case EXPIRED->Status.EXPIRED;case STALE->Status.STALE;case FAILED->Status.FAILED;case APPLIED,APPLIED_PENDING_RECOVERY->Status.APPLIED_PENDING_RECOVERY;};
|
||||
refund(operation,result,mapped);
|
||||
}
|
||||
|
||||
private void refund(UUID operation,Consumer<Outcome> result,Status status){inventory.refundUse(operation).whenComplete((ignored,failure)->publish(()->{if(failure!=null){report(failure);result.accept(new Outcome(Status.FAILED));}else result.accept(new Outcome(status));}));}
|
||||
private void publish(Runnable action){try{nextTick.accept(()->{try{action.run();}catch(RuntimeException failure){report(failure);}});}catch(RuntimeException failure){report(failure);}}
|
||||
private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.EnumMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/** One durable server-wide rare catalog shared by every registered merchant. */
|
||||
public record RareCatalog(LocalDate day,UUID revision,Instant refreshedAt,RefreshReason reason,
|
||||
Map<RareProduct,Offer> products,Map<UUID,Transaction> transactions){
|
||||
public enum RefreshReason{STARTUP,SCHEDULED,ADMINISTRATIVE}
|
||||
public enum TransactionState{RESERVED,CONSUMED,CANCELLED}
|
||||
public record Offer(BigInteger price,int remaining){public Offer{Objects.requireNonNull(price);if(price.signum()<=0||remaining<0)throw new IllegalArgumentException("Invalid offer");}}
|
||||
public record Transaction(RareProduct product,TransactionState state){public Transaction{Objects.requireNonNull(product);Objects.requireNonNull(state);}}
|
||||
public RareCatalog(LocalDate day,UUID revision,Instant refreshedAt,RefreshReason reason,Map<RareProduct,Offer> products){this(day,revision,refreshedAt,reason,products,Map.of());}
|
||||
public RareCatalog{Objects.requireNonNull(day);Objects.requireNonNull(revision);Objects.requireNonNull(refreshedAt);Objects.requireNonNull(reason);var copy=new EnumMap<RareProduct,Offer>(RareProduct.class);copy.putAll(products);products=Map.copyOf(copy);transactions=Map.copyOf(transactions);}
|
||||
public boolean available(RareProduct product){return products.containsKey(Objects.requireNonNull(product));}
|
||||
public Offer offer(RareProduct product){var offer=products.get(Objects.requireNonNull(product));if(offer==null)throw new IllegalArgumentException("Product unavailable");return offer;}
|
||||
public RareCatalog reserve(UUID operation,RareProduct product){var existing=transactions.get(operation);if(existing!=null){if(existing.product()!=product)throw new IllegalArgumentException("Operation collision");return this;}var offer=offer(product);if(offer.remaining()==0)throw new IllegalStateException("Sold out");var nextProducts=new LinkedHashMap<>(products);nextProducts.put(product,new Offer(offer.price(),offer.remaining()-1));var nextTransactions=new LinkedHashMap<>(transactions);nextTransactions.put(operation,new Transaction(product,TransactionState.RESERVED));return new RareCatalog(day,revision,refreshedAt,reason,nextProducts,nextTransactions);}
|
||||
public RareCatalog settle(UUID operation,boolean consumed){var transaction=transactions.get(operation);if(transaction==null||transaction.state()!=TransactionState.RESERVED)return this;var nextProducts=new LinkedHashMap<>(products);if(!consumed){var offer=offer(transaction.product());nextProducts.put(transaction.product(),new Offer(offer.price(),Math.addExact(offer.remaining(),1)));}var nextTransactions=new LinkedHashMap<>(transactions);nextTransactions.put(operation,new Transaction(transaction.product(),consumed?TransactionState.CONSUMED:TransactionState.CANCELLED));return new RareCatalog(day,revision,refreshedAt,reason,nextProducts,nextTransactions);}
|
||||
public RareCatalog prune(UUID operation){if(!transactions.containsKey(operation))return this;var next=new LinkedHashMap<>(transactions);next.remove(operation);return new RareCatalog(day,revision,refreshedAt,reason,products,next);}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.EnumMap;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Exact inclusive stock and basis-point price generation without floating-point drift. */
|
||||
public final class RareCatalogGenerator {
|
||||
@FunctionalInterface public interface InclusiveRoll{int roll(int minimum,int maximum);}
|
||||
private static final BigInteger BASIS=BigInteger.valueOf(10_000);
|
||||
private RareCatalogGenerator(){}
|
||||
public static RareCatalog generate(LocalDate day,Instant refreshedAt,RareCatalog.RefreshReason reason,RareMarketRules rules,InclusiveRoll random){Objects.requireNonNull(rules);Objects.requireNonNull(random);var offers=new EnumMap<RareProduct,RareCatalog.Offer>(RareProduct.class);for(var product:RareProduct.values()){int spread=Math.multiplyExact(100,rules.variancePercent());int offset=checked(random,-spread,spread);var numerator=rules.basePrices().get(product).multiply(BigInteger.valueOf(10_000L+offset));var divided=numerator.divideAndRemainder(BASIS);var price=divided[0].add(divided[1].signum()==0?BigInteger.ZERO:BigInteger.ONE).max(BigInteger.ONE);var range=rules.stock().get(product);offers.put(product,new RareCatalog.Offer(price,checked(random,range.minimum(),range.maximum())));}return new RareCatalog(day,UUID.randomUUID(),refreshedAt,reason,offers);}
|
||||
private static int checked(InclusiveRoll random,int minimum,int maximum){int value=random.roll(minimum,maximum);if(value<minimum||value>maximum)throw new IllegalArgumentException("Random roll outside range");return value;}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import games.dmg.fantasystick.persistence.YamlRareInventoryRepository;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.*;
|
||||
|
||||
/** Serialized durable authority for character-owned rare goods and one-use reservations. */
|
||||
public final class RareInventoryDirectory implements AutoCloseable {
|
||||
public enum AddStatus{APPLIED,DUPLICATE}
|
||||
public enum UseStatus{RESERVED,COMMITTED,REFUNDED,NOT_AVAILABLE,STALE}
|
||||
public record UseOutcome(UseStatus status,RareInventoryState.Use use){}
|
||||
public record Item(String id,String name,BigInteger quantity,boolean available){}
|
||||
private final YamlRareInventoryRepository repository;
|
||||
private final ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-rare-inventory"));
|
||||
private volatile RareInventoryState snapshot;
|
||||
private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
public RareInventoryDirectory(YamlRareInventoryRepository repository)throws IOException{this.repository=Objects.requireNonNull(repository);snapshot=repository.load();}
|
||||
public BigInteger quantity(UUID character,RareProduct product){return snapshot.quantity(character,product.id());}
|
||||
public java.util.List<Item> inventory(UUID character){return snapshot.items().getOrDefault(character,Map.of()).entrySet().stream().sorted(Map.Entry.comparingByKey()).map(entry->{var product=RareProduct.find(entry.getKey());return new Item(entry.getKey(),product.map(RareProduct::displayName).orElse(entry.getKey()),entry.getValue(),product.isPresent());}).toList();}
|
||||
public CompletableFuture<AddStatus> add(UUID character,RareProduct product,UUID operation){Objects.requireNonNull(character);Objects.requireNonNull(product);Objects.requireNonNull(operation);return submit(()->{var receipt=snapshot.purchaseReceipts().get(operation);if(receipt!=null){if(!receipt.character().equals(character)||!receipt.product().equals(product.id()))throw new IOException("Rare purchase operation collision");return AddStatus.DUPLICATE;}var items=mutableItems(snapshot.items());var goods=items.computeIfAbsent(character,ignored->new LinkedHashMap<>());goods.merge(product.id(),BigInteger.ONE,BigInteger::add);var receipts=new LinkedHashMap<>(snapshot.purchaseReceipts());receipts.put(operation,new RareInventoryState.PurchaseReceipt(character,product.id()));save(new RareInventoryState(items,receipts,snapshot.uses()));return AddStatus.APPLIED;});}
|
||||
public CompletableFuture<UseOutcome> reserveUse(UUID character,RareProduct product,UUID operation,BigInteger expectedQuantity){if(product.heals())throw new IllegalArgumentException("Healing use requires captured health policy");return reserveUse(character,product,operation,expectedQuantity,null,null,0);}
|
||||
public CompletableFuture<UseOutcome> reserveUse(UUID character,RareProduct product,UUID operation,BigInteger expectedQuantity,BigDecimal healthBefore,BigDecimal maximum,int percent){Objects.requireNonNull(character);Objects.requireNonNull(product);Objects.requireNonNull(operation);if(!product.heals()&&(healthBefore!=null||maximum!=null||percent!=0))throw new IllegalArgumentException("Non-healing use cannot carry healing policy");return submit(()->{var existing=snapshot.uses().get(operation);if(existing!=null){if(!existing.character().equals(character)||!existing.product().equals(product.id()))throw new IOException("Rare use operation collision");return new UseOutcome(status(existing.phase()),existing);}var current=snapshot.quantity(character,product.id());if(current.signum()<=0)return new UseOutcome(UseStatus.NOT_AVAILABLE,null);if(!current.equals(expectedQuantity))return new UseOutcome(UseStatus.STALE,null);RareInventoryState.Use use;try{use=new RareInventoryState.Use(operation,character,product.id(),expectedQuantity,healthBefore,maximum,percent,RareInventoryState.UsePhase.RESERVED);}catch(IllegalArgumentException refused){return new UseOutcome(UseStatus.NOT_AVAILABLE,null);}var items=mutableItems(snapshot.items());var goods=items.get(character);var remaining=current.subtract(BigInteger.ONE);if(remaining.signum()==0)goods.remove(product.id());else goods.put(product.id(),remaining);if(goods.isEmpty())items.remove(character);var uses=new LinkedHashMap<>(snapshot.uses());uses.put(operation,use);save(new RareInventoryState(items,snapshot.purchaseReceipts(),uses));return new UseOutcome(UseStatus.RESERVED,use);});}
|
||||
public CompletableFuture<UseStatus> commitUse(UUID operation){return updateUse(operation,true);}
|
||||
public CompletableFuture<UseStatus> refundUse(UUID operation){return updateUse(operation,false);}
|
||||
private CompletableFuture<UseStatus> updateUse(UUID operation,boolean commit){return submit(()->{var use=snapshot.uses().get(operation);if(use==null)return UseStatus.NOT_AVAILABLE;if(use.phase()!=RareInventoryState.UsePhase.RESERVED)return status(use.phase());var phase=commit?RareInventoryState.UsePhase.COMMITTED:RareInventoryState.UsePhase.REFUNDED;var changed=new RareInventoryState.Use(use.operation(),use.character(),use.product(),use.expectedQuantity(),use.healthBefore(),use.maximum(),use.percent(),phase);var uses=new LinkedHashMap<>(snapshot.uses());uses.put(operation,changed);var items=mutableItems(snapshot.items());if(!commit)items.computeIfAbsent(use.character(),ignored->new LinkedHashMap<>()).merge(use.product(),BigInteger.ONE,BigInteger::add);save(new RareInventoryState(items,snapshot.purchaseReceipts(),uses));return status(phase);});}
|
||||
public java.util.List<RareInventoryState.Use> unresolvedUses(){return snapshot.uses().values().stream().filter(use->use.phase()==RareInventoryState.UsePhase.RESERVED).toList();}
|
||||
private static UseStatus status(RareInventoryState.UsePhase phase){return switch(phase){case RESERVED->UseStatus.RESERVED;case COMMITTED->UseStatus.COMMITTED;case REFUNDED->UseStatus.REFUNDED;};}
|
||||
private static Map<UUID,Map<String,BigInteger>> mutableItems(Map<UUID,Map<String,BigInteger>> source){var result=new LinkedHashMap<UUID,Map<String,BigInteger>>();for(var entry:source.entrySet())result.put(entry.getKey(),new LinkedHashMap<>(entry.getValue()));return result;}
|
||||
private void save(RareInventoryState state)throws IOException{repository.save(state);snapshot=state;}
|
||||
private <T>CompletableFuture<T> submit(Callable<T> action){var result=new CompletableFuture<T>();synchronized(this){if(closed){result.completeExceptionally(new IOException("Rare inventory closed"));return result;}pending++;}worker.execute(()->{try{result.complete(action.call());}catch(Throwable failure){result.completeExceptionally(failure);}finally{finished();}});return result;}
|
||||
private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}
|
||||
public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}
|
||||
@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Durable typed character inventory and consume-once operation proof. */
|
||||
public record RareInventoryState(Map<UUID,Map<String,BigInteger>> items,
|
||||
Map<UUID,PurchaseReceipt> purchaseReceipts,
|
||||
Map<UUID,Use> uses){
|
||||
public enum UsePhase{RESERVED,COMMITTED,REFUNDED}
|
||||
public record PurchaseReceipt(UUID character,String product){public PurchaseReceipt{Objects.requireNonNull(character);if(product==null||product.isBlank())throw new IllegalArgumentException("Invalid product");}}
|
||||
public record Use(UUID operation,UUID character,String product,BigInteger expectedQuantity,
|
||||
BigDecimal healthBefore,BigDecimal maximum,int percent,UsePhase phase){
|
||||
public Use{Objects.requireNonNull(operation);Objects.requireNonNull(character);Objects.requireNonNull(product);Objects.requireNonNull(expectedQuantity);Objects.requireNonNull(phase);boolean healing=healthBefore!=null||maximum!=null||percent!=0;if(expectedQuantity.signum()<=0||RareProduct.find(product).map(RareProduct::heals).filter(value->value!=healing).isPresent()||healing&&(healthBefore==null||maximum==null||healthBefore.signum()<0||maximum.signum()<=0||healthBefore.compareTo(maximum)>=0||percent<=0||percent>100))throw new IllegalArgumentException("Invalid rare item use");}
|
||||
}
|
||||
public RareInventoryState(){this(Map.of(),Map.of(),Map.of());}
|
||||
public RareInventoryState{var itemCopy=new LinkedHashMap<UUID,Map<String,BigInteger>>();for(var entry:items.entrySet()){var values=new LinkedHashMap<String,BigInteger>();for(var value:entry.getValue().entrySet()){if(value.getKey().isBlank()||value.getValue().signum()<0)throw new IllegalArgumentException("Invalid inventory");if(value.getValue().signum()>0)values.put(value.getKey(),value.getValue());}if(!values.isEmpty())itemCopy.put(entry.getKey(),Map.copyOf(values));}items=Map.copyOf(itemCopy);purchaseReceipts=Map.copyOf(purchaseReceipts);uses=Map.copyOf(uses);}
|
||||
public BigInteger quantity(UUID character,String product){return items.getOrDefault(character,Map.of()).getOrDefault(product,BigInteger.ZERO);}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Recoverable one-Core-to-one-ordinary-point exchange for the exact active character. */
|
||||
public final class RareMagicCoreService {
|
||||
public enum Status { APPLIED, NOT_AVAILABLE, STALE, FAILED }
|
||||
public record Outcome(Status status, Optional<CharacterRecord> record) { }
|
||||
private record Context(EquipmentSessions.Active active, CharacterRecord record) { }
|
||||
|
||||
private final RareInventoryDirectory inventory;
|
||||
private final CharacterStorage characters;
|
||||
private final EquipmentSessions sessions;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Throwable> failures;
|
||||
|
||||
public RareMagicCoreService(RareInventoryDirectory inventory, CharacterStorage characters,
|
||||
EquipmentSessions sessions, Consumer<Runnable> nextTick,
|
||||
Consumer<Throwable> failures) {
|
||||
this.inventory=Objects.requireNonNull(inventory);this.characters=Objects.requireNonNull(characters);
|
||||
this.sessions=Objects.requireNonNull(sessions);this.nextTick=Objects.requireNonNull(nextTick);
|
||||
this.failures=Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
public BigInteger quantity(UUID character){return inventory.quantity(character,RareProduct.MAGIC_CORE);}
|
||||
|
||||
public void use(Player player,Consumer<Outcome> result){
|
||||
var captured=context(player).orElse(null);
|
||||
if(captured==null){result.accept(new Outcome(Status.STALE,Optional.empty()));return;}
|
||||
BigInteger quantity=quantity(captured.record().id());
|
||||
if(quantity.signum()<=0){result.accept(new Outcome(Status.NOT_AVAILABLE,Optional.of(captured.record())));return;}
|
||||
UUID operation=UUID.randomUUID();
|
||||
inventory.reserveUse(captured.record().id(),RareProduct.MAGIC_CORE,operation,quantity).whenComplete((reservation,reserveFailure)->{
|
||||
if(reserveFailure!=null){fail(reserveFailure,result);return;}
|
||||
if(reservation.status()!=RareInventoryDirectory.UseStatus.RESERVED){publish(()->result.accept(new Outcome(reservation.status()==RareInventoryDirectory.UseStatus.NOT_AVAILABLE?Status.NOT_AVAILABLE:Status.STALE,Optional.empty())));return;}
|
||||
characters.consumeMagicCore(captured.record().id(),captured.record().revision(),operation).whenComplete((credit,creditFailure)->complete(player,captured,operation,credit,creditFailure,result));
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve interrupted credits from authoritative character proof without publishing a second point. */
|
||||
public void install(){for(var use:inventory.unresolvedUses()){if(!use.product().equals(RareProduct.MAGIC_CORE.id()))continue;characters.find(use.character()).whenComplete((record,failure)->{if(failure!=null){report(failure);return;}boolean applied=record.isPresent()&&record.orElseThrow().rewardReceipts().contains(use.operation());var work=applied?inventory.commitUse(use.operation()):inventory.refundUse(use.operation());work.exceptionally(problem->{report(problem);return RareInventoryDirectory.UseStatus.RESERVED;});});}}
|
||||
|
||||
private void complete(Player player,Context captured,UUID operation,CharacterStorage.MagicCore credit,Throwable failure,Consumer<Outcome> result){
|
||||
if(failure!=null||credit==null){if(failure!=null)report(failure);refund(operation,()->result.accept(new Outcome(Status.FAILED,Optional.empty())));return;}
|
||||
if(credit.status()!=CharacterStorage.MagicCoreStatus.APPLIED&&credit.status()!=CharacterStorage.MagicCoreStatus.DUPLICATE){var status=credit.status()==CharacterStorage.MagicCoreStatus.UNAVAILABLE?Status.NOT_AVAILABLE:Status.STALE;refund(operation,()->result.accept(new Outcome(status,credit.record())));return;}
|
||||
publish(()->{boolean acknowledged=false;try{var current=context(player);acknowledged=current.isPresent()&¤t.orElseThrow().active().session().equals(captured.active().session())&&(credit.status()==CharacterStorage.MagicCoreStatus.DUPLICATE||sessions.acknowledgeProgress(captured.active(),captured.record().revision(),credit.record().orElseThrow()));}catch(RuntimeException uncertain){report(uncertain);}boolean currentSession=acknowledged;inventory.commitUse(operation).whenComplete((ignored,commitFailure)->{if(commitFailure!=null){report(commitFailure);publish(()->result.accept(new Outcome(Status.FAILED,credit.record())));}else publish(()->result.accept(new Outcome(currentSession?Status.APPLIED:Status.STALE,credit.record())));});});
|
||||
}
|
||||
|
||||
private void refund(UUID operation,Runnable after){inventory.refundUse(operation).whenComplete((ignored,failure)->{if(failure!=null)report(failure);publish(after);});}
|
||||
private Optional<Context> context(Player player){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);return record==null?Optional.empty():Optional.of(new Context(active,record));}
|
||||
private void fail(Throwable failure,Consumer<Outcome> result){report(failure);publish(()->result.accept(new Outcome(Status.FAILED,Optional.empty())));}
|
||||
private void publish(Runnable action){try{nextTick.accept(action);}catch(RuntimeException failure){report(failure);}}
|
||||
private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import games.dmg.fantasystick.persistence.*;
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/** Serialized recoverable authority for the separate shared rare catalog. */
|
||||
public final class RareMarket implements AutoCloseable {
|
||||
public enum PurchaseStatus{APPLIED,SOLD_OUT,STALE,INSUFFICIENT,UNAVAILABLE,FAILED}
|
||||
public record PurchaseProof(UUID operation,RareProduct product,BigInteger price,int stockAfter){}
|
||||
public record PurchaseOutcome(PurchaseStatus status,Optional<CharacterRecord> record,RareCatalog catalog,PurchaseProof proof){public PurchaseOutcome(PurchaseStatus status,Optional<CharacterRecord> record,RareCatalog catalog){this(status,record,catalog,null);}}
|
||||
private final RareCatalogRepository catalogs;private final RarePurchaseOperationStore operations;private final CharacterStorage characters;private final RareInventoryDirectory inventory;private final Supplier<RareMarketRules> rules;private final Clock clock;private final RareCatalogGenerator.InclusiveRoll random;private final ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-rare-market"));private final CompletionStage<Void> readiness;private volatile RareCatalog snapshot;private volatile boolean initialized;private volatile Consumer<RareCatalog> listener=ignored->{};private boolean closed,operationsClosing;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
public RareMarket(RareCatalogRepository catalogs,RarePurchaseOperationStore operations,CharacterStorage characters,RareInventoryDirectory inventory,Supplier<RareMarketRules> rules,Clock clock,RareCatalogGenerator.InclusiveRoll random){this.catalogs=Objects.requireNonNull(catalogs);this.operations=Objects.requireNonNull(operations);this.characters=Objects.requireNonNull(characters);this.inventory=Objects.requireNonNull(inventory);this.rules=Objects.requireNonNull(rules);this.clock=Objects.requireNonNull(clock);this.random=Objects.requireNonNull(random);readiness=submit(()->{initialize();return null;});}
|
||||
public void attachListener(Consumer<RareCatalog> listener){this.listener=Objects.requireNonNull(listener);}public Optional<RareCatalog> snapshot(){return Optional.ofNullable(snapshot);}public Instant now(){return clock.instant();}public CompletionStage<Void> ready(){return readiness;}public CompletionStage<RareCatalog> current(){return readiness.thenCompose(ignored->submit(()->ensureCurrent(RareCatalog.RefreshReason.SCHEDULED)));}
|
||||
public CompletionStage<RareCatalog> restock(){return readiness.thenCompose(ignored->submit(()->{var current=ensureCurrent(RareCatalog.RefreshReason.SCHEDULED);if(!current.transactions().isEmpty())throw new IOException("Rare purchases are unresolved");var generated=RareCatalogGenerator.generate(LocalDate.now(clock.withZone(ZoneOffset.UTC)),clock.instant().truncatedTo(java.time.temporal.ChronoUnit.SECONDS),RareCatalog.RefreshReason.ADMINISTRATIVE,rules.get(),random);persist(generated,true);return generated;}));}
|
||||
public static Instant nextScheduledRestock(Instant now){return now.atZone(ZoneOffset.UTC).toLocalDate().plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();}
|
||||
public CompletionStage<PurchaseOutcome> purchase(UUID character,BigInteger expectedCharacterRevision,UUID expectedCatalogRevision,RareProduct product){return readiness.thenCompose(ignored->submit(()->purchaseBlocking(character,expectedCharacterRevision,expectedCatalogRevision,product)));}
|
||||
private void initialize()throws Exception{snapshot=catalogs.load().orElse(null);var plans=operations.load().toCompletableFuture().join();if(snapshot==null&&!plans.isEmpty())throw new IOException("Rare catalog missing for pending purchases");for(var plan:plans)execute(plan);if(snapshot!=null&&!snapshot.transactions().isEmpty()){var next=snapshot;for(var entry:snapshot.transactions().entrySet()){if(entry.getValue().state()==RareCatalog.TransactionState.RESERVED)throw new IOException("Orphan reserved rare purchase "+entry.getKey());next=next.prune(entry.getKey());}persist(next,false);}ensureCurrent(RareCatalog.RefreshReason.STARTUP);initialized=true;}
|
||||
private PurchaseOutcome purchaseBlocking(UUID character,BigInteger expectedCharacterRevision,UUID expectedCatalogRevision,RareProduct product)throws Exception{var catalog=ensureCurrent(RareCatalog.RefreshReason.SCHEDULED);if(!catalog.transactions().isEmpty())return new PurchaseOutcome(PurchaseStatus.FAILED,Optional.empty(),catalog);if(!catalog.revision().equals(expectedCatalogRevision))return new PurchaseOutcome(PurchaseStatus.STALE,Optional.empty(),catalog);if(!catalog.available(product))return new PurchaseOutcome(PurchaseStatus.UNAVAILABLE,Optional.empty(),catalog);if(catalog.offer(product).remaining()==0)return new PurchaseOutcome(PurchaseStatus.SOLD_OUT,Optional.empty(),catalog);var plan=new RarePurchaseOperationStore.Plan(UUID.randomUUID(),character,expectedCharacterRevision,catalog.revision(),product,catalog.offer(product).price());operations.prepare(plan).toCompletableFuture().join();return execute(plan);}
|
||||
private PurchaseOutcome execute(RarePurchaseOperationStore.Plan plan)throws Exception{var catalog=Objects.requireNonNull(snapshot,"Rare catalog unavailable");if(!catalog.revision().equals(plan.catalogRevision()))throw new IOException("Pending rare purchase belongs to another catalog");var transaction=catalog.transactions().get(plan.id());if(transaction==null){try{catalog=catalog.reserve(plan.id(),plan.product());}catch(IllegalStateException soldOut){operations.complete(plan.id()).toCompletableFuture().join();return new PurchaseOutcome(PurchaseStatus.SOLD_OUT,Optional.empty(),catalog);}persist(catalog,false);transaction=catalog.transactions().get(plan.id());}if(transaction.product()!=plan.product())throw new IOException("Rare reservation product mismatch");if(transaction.state()!=RareCatalog.TransactionState.RESERVED){if(transaction.state()==RareCatalog.TransactionState.CONSUMED)inventory.add(plan.character(),plan.product(),plan.id()).join();operations.complete(plan.id()).toCompletableFuture().join();var pruned=catalog.prune(plan.id());persist(pruned,false);return new PurchaseOutcome(transaction.state()==RareCatalog.TransactionState.CONSUMED?PurchaseStatus.APPLIED:PurchaseStatus.FAILED,characters.find(plan.character()).join(),pruned,transaction.state()==RareCatalog.TransactionState.CONSUMED?new PurchaseProof(plan.id(),plan.product(),plan.price(),pruned.offer(plan.product()).remaining()):null);}var purchase=characters.purchaseRare(plan.character(),plan.expectedCharacterRevision(),plan.id(),plan.price()).join();boolean consumed=purchase.status()==CharacterStorage.RarePurchaseStatus.APPLIED||purchase.status()==CharacterStorage.RarePurchaseStatus.DUPLICATE;if(consumed)inventory.add(plan.character(),plan.product(),plan.id()).join();catalog=catalog.settle(plan.id(),consumed);persist(catalog,consumed);operations.complete(plan.id()).toCompletableFuture().join();catalog=catalog.prune(plan.id());persist(catalog,false);var status=switch(purchase.status()){case APPLIED,DUPLICATE->PurchaseStatus.APPLIED;case INSUFFICIENT->PurchaseStatus.INSUFFICIENT;case STALE->PurchaseStatus.STALE;case UNAVAILABLE->PurchaseStatus.UNAVAILABLE;};return new PurchaseOutcome(status,purchase.record(),catalog,status==PurchaseStatus.APPLIED?new PurchaseProof(plan.id(),plan.product(),plan.price(),catalog.offer(plan.product()).remaining()):null);}
|
||||
private RareCatalog ensureCurrent(RareCatalog.RefreshReason reason)throws IOException{var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));if(snapshot!=null&&snapshot.day().equals(today))return snapshot;if(snapshot!=null&&!snapshot.transactions().isEmpty())throw new IOException("Rare purchases unresolved at rollover");var generated=RareCatalogGenerator.generate(today,clock.instant().truncatedTo(java.time.temporal.ChronoUnit.SECONDS),reason,rules.get(),random);persist(generated,true);return generated;}
|
||||
private void persist(RareCatalog catalog,boolean notify)throws IOException{catalogs.save(catalog);snapshot=catalog;if(notify&&initialized)try{listener.accept(catalog);}catch(RuntimeException ignored){}}
|
||||
private <T>CompletionStage<T> submit(Callable<T> action){synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Rare market closed"));pending++;}return CompletableFuture.supplyAsync(()->{try{return action.call();}catch(Exception failure){throw new CompletionException(failure);}finally{finished();}},worker).minimalCompletionStage();}
|
||||
private synchronized void finished(){pending--;if(closed&&pending==0)closeOperations();}private void closeOperations(){if(operationsClosing)return;operationsClosing=true;operations.close();operations.drained().whenComplete((ignored,failure)->{if(failure==null)drained.complete(null);else drained.completeExceptionally(failure);});}public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)closeOperations();}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.EnumMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Validated rare-market settings captured by catalog generation or item use. */
|
||||
public record RareMarketRules(Map<RareProduct,BigInteger> basePrices,int variancePercent,
|
||||
Map<RareProduct,StockRange> stock,Map<RareProduct,Integer> healingPercent) {
|
||||
public record StockRange(int minimum,int maximum){public StockRange{if(minimum<0||maximum<minimum)throw new IllegalArgumentException("Invalid stock range");}}
|
||||
public RareMarketRules {
|
||||
Objects.requireNonNull(basePrices);Objects.requireNonNull(stock);Objects.requireNonNull(healingPercent);
|
||||
if(variancePercent<0||variancePercent>100)throw new IllegalArgumentException("Invalid variance");
|
||||
var prices=new EnumMap<RareProduct,BigInteger>(RareProduct.class);var ranges=new EnumMap<RareProduct,StockRange>(RareProduct.class);var healing=new EnumMap<RareProduct,Integer>(RareProduct.class);
|
||||
for(var product:RareProduct.values()){
|
||||
var price=Objects.requireNonNull(basePrices.get(product),"Missing price");if(price.signum()<0)throw new IllegalArgumentException("Negative price");prices.put(product,price);
|
||||
ranges.put(product,Objects.requireNonNull(stock.get(product),"Missing stock"));
|
||||
if(product.heals()){int percent=Objects.requireNonNull(healingPercent.get(product),"Missing healing");if(percent<0||percent>100)throw new IllegalArgumentException("Invalid healing percent");healing.put(product,percent);}
|
||||
}
|
||||
basePrices=Map.copyOf(prices);stock=Map.copyOf(ranges);healingPercent=Map.copyOf(healing);
|
||||
}
|
||||
public static RareMarketRules defaults(){var prices=new EnumMap<RareProduct,BigInteger>(RareProduct.class);var stock=new EnumMap<RareProduct,StockRange>(RareProduct.class);var healing=new EnumMap<RareProduct,Integer>(RareProduct.class);for(var product:RareProduct.values()){prices.put(product,BigInteger.valueOf(product.basePrice()));stock.put(product,new StockRange(0,product.maximumStock()));if(product.heals())healing.put(product,product.defaultHealingPercent());}return new RareMarketRules(prices,10,stock,healing);}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
import java.util.*;
|
||||
/** Durable desired location for one administrator-managed Rare Items. */
|
||||
public record RareMerchant(UUID id,String name,UUID world,double x,double y,double z,float yaw,float pitch){public RareMerchant{Objects.requireNonNull(id);Objects.requireNonNull(name);Objects.requireNonNull(world);name=name.strip();if(name.codePointCount(0,name.length())>64||name.codePoints().anyMatch(codePoint->{int type=Character.getType(codePoint);return type==Character.CONTROL||type==Character.FORMAT||type==Character.SURROGATE;})||!Double.isFinite(x)||!Double.isFinite(y)||!Double.isFinite(z)||!Float.isFinite(yaw)||!Float.isFinite(pitch))throw new IllegalArgumentException("Invalid Rare Items");}}
|
||||
@@ -0,0 +1,12 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
import java.util.*;import java.util.function.BiConsumer;import org.bukkit.*;import org.bukkit.entity.*;import org.bukkit.event.*;import org.bukkit.persistence.PersistentDataType;import org.bukkit.plugin.Plugin;
|
||||
/** Loaded-chunk Rare merchant reconciliation and native protection, distinct from Party Registrars. */
|
||||
public final class RareMerchantController implements Listener,AutoCloseable{
|
||||
private final Plugin plugin;private final RareMerchantDirectory directory;private final BiConsumer<Player,UUID> interactions;private final NamespacedKey idKey,typeKey;private final Set<Entity> relocating=Collections.newSetFromMap(new IdentityHashMap<>());private boolean closed;public RareMerchantController(Plugin plugin,RareMerchantDirectory directory,BiConsumer<Player,UUID> interactions){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.interactions=Objects.requireNonNull(interactions);idKey=new NamespacedKey(plugin,"rare_merchant_id");typeKey=new NamespacedKey(plugin,"managed_npc_type");}
|
||||
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 "fantasy_rare_merchant".equals(entity.getPersistentDataContainer().get(typeKey,PersistentDataType.STRING));}
|
||||
public void reconcile(Chunk chunk){if(closed)return;var desired=new HashMap<UUID,RareMerchant>();for(var value:directory.all())if(value.world().equals(chunk.getWorld().getUID())&§ion(value.x())==chunk.getX()&§ion(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 target:desired.values())if(!retained.contains(target.id()))spawn(target);}
|
||||
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(RareMerchant value){var world=plugin.getServer().getWorld(value.world());if(world==null)return;int x=section(value.x()),z=section(value.z());if(world.isChunkLoaded(x,z))world.spawn(location(value),Villager.class,villager->configure(villager,value));}private void configure(Villager villager,RareMerchant value){var data=villager.getPersistentDataContainer();data.set(typeKey,PersistentDataType.STRING,"fantasy_rare_merchant");data.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()?"Rare Items":value.name()));villager.setCustomNameVisible(true);}private Location location(RareMerchant value){return new Location(plugin.getServer().getWorld(value.world()),value.x(),value.y(),value.z(),value.yaw(),value.pitch());}private UUID id(Entity entity){String value=entity.getPersistentDataContainer().get(idKey,PersistentDataType.STRING);try{return value==null?null:UUID.fromString(value);}catch(IllegalArgumentException invalid){return null;}}private static int section(double value){return((int)Math.floor(value))>>4;}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void interact(org.bukkit.event.player.PlayerInteractEntityEvent event){if(!tagged(event.getRightClicked()))return;event.setCancelled(true);if(event.getHand()!=org.bukkit.inventory.EquipmentSlot.HAND)return;UUID id=id(event.getRightClicked());if(id!=null&&directory.find(id).isPresent())interactions.accept(event.getPlayer(),id);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void damage(org.bukkit.event.entity.EntityDamageEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void combust(org.bukkit.event.entity.EntityCombustEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void transform(org.bukkit.event.entity.EntityTransformEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void breed(org.bukkit.event.entity.EntityBreedEvent event){if(tagged(event.getMother())||tagged(event.getFather()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void vehicle(org.bukkit.event.vehicle.VehicleEnterEvent event){if(tagged(event.getEntered()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void leash(org.bukkit.event.entity.PlayerLeashEntityEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void teleport(org.bukkit.event.entity.EntityTeleportEvent event){if(tagged(event.getEntity())&&!relocating.contains(event.getEntity()))event.setCancelled(true);}@EventHandler(priority=EventPriority.MONITOR)public void chunk(org.bukkit.event.world.ChunkLoadEvent event){reconcile(event.getChunk());}
|
||||
@Override public void close(){closed=true;for(var world:plugin.getServer().getWorlds()){var entities=world.getEntities();if(entities!=null)for(var entity:entities)if(tagged(entity))entity.remove();}}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
import games.dmg.fantasystick.persistence.YamlRareMerchantRepository;import java.io.*;import java.util.*;import java.util.concurrent.*;
|
||||
/** Serialized durable Fantasy Rare merchant desired state. */
|
||||
public final class RareMerchantDirectory implements AutoCloseable{
|
||||
private final YamlRareMerchantRepository repository;private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-rare-merchant-persistence"));private volatile List<RareMerchant> snapshot;private final Map<UUID,Integer> leases=new HashMap<>();private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();public RareMerchantDirectory(YamlRareMerchantRepository repository)throws IOException{this.repository=Objects.requireNonNull(repository);snapshot=repository.load();}public List<RareMerchant> all(){return snapshot;}public Optional<RareMerchant> find(UUID id){return snapshot.stream().filter(value->value.id().equals(id)).findFirst();}
|
||||
public CompletableFuture<RareMerchant> place(String name,UUID world,double x,double y,double z,float yaw,float pitch){return submit(()->{synchronized(this){var value=new RareMerchant(UUID.randomUUID(),name,world,x,y,z,yaw,pitch);var next=new ArrayList<>(snapshot);next.add(value);repository.save(next);snapshot=List.copyOf(next);return value;}});}public CompletableFuture<Optional<RareMerchant>> remove(UUID id){return submit(()->{synchronized(this){var found=find(id);if(found.isEmpty())return Optional.empty();if(leases.getOrDefault(id,0)>0)throw new IOException("Fantasy Rare merchant has an active purchase");var next=snapshot.stream().filter(value->!value.id().equals(id)).toList();repository.save(next);snapshot=next;return found;}});}public CompletableFuture<RareMerchant> restore(RareMerchant value){return submit(()->{synchronized(this){var found=find(value.id());if(found.isPresent()){if(found.orElseThrow().equals(value))return value;throw new IOException("Rare merchant identity collision");}var next=new ArrayList<>(snapshot);next.add(value);repository.save(next);snapshot=List.copyOf(next);return value;}});}
|
||||
public synchronized Optional<Lease> acquire(UUID id){if(closed||find(id).isEmpty())return Optional.empty();leases.merge(id,1,Integer::sum);return Optional.of(new Lease(id));}public final class Lease implements AutoCloseable{private final UUID id;private boolean released;private Lease(UUID id){this.id=id;}public RareMerchant shop(){return find(id).orElseThrow();}@Override public void close(){synchronized(RareMerchantDirectory.this){if(released)return;released=true;int remaining=leases.getOrDefault(id,0)-1;if(remaining<=0)leases.remove(id);else leases.put(id,remaining);}}}
|
||||
private <T>CompletableFuture<T> submit(Callable<T> operation){var result=new CompletableFuture<T>();synchronized(this){if(closed){result.completeExceptionally(new IOException("Rare merchant directory closed"));return result;}pending++;}worker.execute(()->{try{result.complete(operation.call());}catch(Throwable failure){result.completeExceptionally(failure);}finally{finished();}});return result;}private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.RareSettings;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import games.dmg.fantasystick.session.NativeHealthLimit;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityRegainHealthEvent;
|
||||
|
||||
/** Exact-character potion reservation, persisted health, native publication and consume-once proof. */
|
||||
public final class RarePotionService {
|
||||
public enum Status{APPLIED,FULL_HEALTH,NO_EFFECT,NOT_AVAILABLE,STALE,FAILED}
|
||||
public record Outcome(Status status,BigDecimal healed){}
|
||||
private record Context(EquipmentSessions.Active active,CharacterRecord record){}
|
||||
private final RareInventoryDirectory inventory;private final RareSettings settings;private final CharacterStorage characters;private final EquipmentSessions sessions;private final Consumer<EntityRegainHealthEvent> healingEvents;private final Consumer<Runnable> nextTick;private final Consumer<Throwable> failures;
|
||||
public RarePotionService(RareInventoryDirectory inventory,RareSettings settings,CharacterStorage characters,EquipmentSessions sessions,Consumer<EntityRegainHealthEvent> healingEvents,Consumer<Runnable> nextTick,Consumer<Throwable> failures){this.inventory=Objects.requireNonNull(inventory);this.settings=Objects.requireNonNull(settings);this.characters=Objects.requireNonNull(characters);this.sessions=Objects.requireNonNull(sessions);this.healingEvents=Objects.requireNonNull(healingEvents);this.nextTick=Objects.requireNonNull(nextTick);this.failures=Objects.requireNonNull(failures);}
|
||||
/** Ambiguous pre-restart native delivery is consumed but never replayed onto a replacement holder. */
|
||||
public void install(){for(var use:inventory.unresolvedUses())if(RareProduct.find(use.product()).map(RareProduct::heals).orElse(false))inventory.commitUse(use.operation()).exceptionally(failure->{report(failure);return RareInventoryDirectory.UseStatus.RESERVED;});}
|
||||
public BigInteger quantity(UUID character,RareProduct product){return inventory.quantity(character,product);}
|
||||
public void use(Player player,RareProduct product,Consumer<Outcome> result){if(!product.heals()){result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO));return;}var context=context(player);if(context.isEmpty()){result.accept(new Outcome(Status.STALE,BigDecimal.ZERO));return;}var captured=context.orElseThrow();var attribute=player.getAttribute(Attribute.MAX_HEALTH);double current=player.getHealth(),maximum=attribute==null?Double.NaN:attribute.getValue();if(!Double.isFinite(current)||!Double.isFinite(maximum)||maximum<=0||current>=maximum){result.accept(new Outcome(Status.FULL_HEALTH,BigDecimal.ZERO));return;}int percent=settings.rules().healingPercent().get(product);double proposed=Math.min(maximum-current,maximum*percent/100d);if(!Double.isFinite(proposed)||proposed<=0){result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO));return;}var event=new EntityRegainHealthEvent(player,proposed,EntityRegainHealthEvent.RegainReason.CUSTOM);healingEvents.accept(event);double accepted=Math.min(proposed,Math.min(maximum-current,event.getAmount()));if(event.isCancelled()||!Double.isFinite(accepted)||accepted<=0){result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO));return;}UUID operation=UUID.randomUUID();BigInteger expectedQuantity=inventory.quantity(captured.record().id(),product);BigDecimal before=BigDecimal.valueOf(current),limit=BigDecimal.valueOf(maximum).min(NativeHealthLimit.read()),target=BigDecimal.valueOf(current+accepted).min(limit);inventory.reserveUse(captured.record().id(),product,operation,expectedQuantity,before,limit,percent).whenComplete((reservation,reserveFailure)->{if(reserveFailure!=null){report(reserveFailure);publish(()->result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO)));return;}if(reservation.status()!=RareInventoryDirectory.UseStatus.RESERVED){publish(()->result.accept(new Outcome(reservation.status()==RareInventoryDirectory.UseStatus.NOT_AVAILABLE?Status.NOT_AVAILABLE:Status.STALE,BigDecimal.ZERO)));return;}characters.saveHealth(captured.record().id(),captured.record().revision(),target,limit).whenComplete((saved,saveFailure)->publish(()->complete(player,captured,operation,accepted,saved,saveFailure,result)));});}
|
||||
private void complete(Player player,Context captured,UUID operation,double accepted,Optional<CharacterRecord> saved,Throwable failure,Consumer<Outcome> result){if(failure!=null||saved==null||saved.isEmpty()){if(failure!=null)report(failure);inventory.refundUse(operation).whenComplete((ignored,refundFailure)->{if(refundFailure!=null)report(refundFailure);publish(()->result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO)));});return;}var persisted=saved.orElseThrow();var current=context(player);boolean acknowledged=false;try{acknowledged=current.isPresent()&¤t.orElseThrow().active().session().equals(captured.active().session())&&sessions.acknowledgeProgress(captured.active(),captured.record().revision(),persisted);}catch(RuntimeException uncertain){report(uncertain);}if(!acknowledged){inventory.commitUse(operation).whenComplete((ignored,commitFailure)->{if(commitFailure!=null)report(commitFailure);publish(()->result.accept(new Outcome(Status.STALE,BigDecimal.ZERO)));});return;}var attribute=player.getAttribute(Attribute.MAX_HEALTH);double now=player.getHealth(),maximum=attribute==null?Double.NaN:attribute.getValue(),desired=Math.min(maximum,persisted.health().doubleValue()),restored=Math.min(accepted,desired-now);if(!Double.isFinite(now)||!Double.isFinite(maximum)||!Double.isFinite(restored)||restored<=0){inventory.refundUse(operation).whenComplete((ignored,refundFailure)->{if(refundFailure!=null)report(refundFailure);publish(()->result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO)));});return;}try{player.setHealth(Math.min(maximum,now+restored));}catch(RuntimeException uncertain){report(uncertain);inventory.commitUse(operation);result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO));return;}inventory.commitUse(operation).whenComplete((status,commitFailure)->{if(commitFailure!=null){report(commitFailure);publish(()->result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO)));return;}publish(()->result.accept(new Outcome(Status.APPLIED,BigDecimal.valueOf(restored).stripTrailingZeros())));});}
|
||||
private Optional<Context> context(Player player){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);return record==null?Optional.empty():Optional.of(new Context(active,record));}
|
||||
private void publish(Runnable action){try{nextTick.accept(action);}catch(RuntimeException failure){report(failure);}}private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Products whose complete purchase and use capability is currently delivered. */
|
||||
public enum RareProduct {
|
||||
LESSER_HEALTH_POTION("lesser-health-potion", "Lesser Health Potion", 50, 10, 25),
|
||||
HEALTH_POTION("health-potion", "Health Potion", 150, 5, 50),
|
||||
GREATER_HEALTH_POTION("greater-health-potion", "Greater Health Potion", 500, 2, 100),
|
||||
RESKILL("reskill", "Reskill", 2000, 1, null),
|
||||
MAGIC_CORE("magic-core", "Magic Core", 3000, 3, null),
|
||||
PHOENIX_DOWN("phoenix-down", "Phoenix Down", 1000, 2, null);
|
||||
|
||||
private final String id;
|
||||
private final String displayName;
|
||||
private final int basePrice;
|
||||
private final int maximumStock;
|
||||
private final Integer defaultHealingPercent;
|
||||
|
||||
RareProduct(String id, String displayName, int basePrice, int maximumStock, Integer defaultHealingPercent) {
|
||||
this.id=id;this.displayName=displayName;this.basePrice=basePrice;
|
||||
this.maximumStock=maximumStock;this.defaultHealingPercent=defaultHealingPercent;
|
||||
}
|
||||
public String id(){return id;}
|
||||
public String displayName(){return displayName;}
|
||||
public int basePrice(){return basePrice;}
|
||||
public int maximumStock(){return maximumStock;}
|
||||
public boolean heals(){return defaultHealingPercent!=null;}
|
||||
public int defaultHealingPercent(){if(defaultHealingPercent==null)throw new IllegalStateException("Product does not heal");return defaultHealingPercent;}
|
||||
public static Optional<RareProduct> find(String id){return Arrays.stream(values()).filter(value->value.id.equals(id)).findFirst();}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import games.dmg.fantasystick.session.NativeHealthLimit;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Confirmed, recoverable one-item reset for the exact living active character. */
|
||||
public final class RareReskillService {
|
||||
public enum Status { APPLIED, NOTHING_TO_RESET, NOT_AVAILABLE, STALE, FAILED }
|
||||
public record Preview(UUID operation, EquipmentSessions.Active active, CharacterRecord record,
|
||||
BigInteger quantity, BigInteger refund) { }
|
||||
public record Outcome(Status status, Optional<CharacterRecord> record) { }
|
||||
private record Context(EquipmentSessions.Active active, CharacterRecord record) { }
|
||||
|
||||
private final RareInventoryDirectory inventory;
|
||||
private final CharacterStorage characters;
|
||||
private final EquipmentSessions sessions;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<CharacterRecord> cleanup;
|
||||
private final Consumer<Throwable> failures;
|
||||
|
||||
public RareReskillService(RareInventoryDirectory inventory, CharacterStorage characters,
|
||||
EquipmentSessions sessions, Consumer<Runnable> nextTick,
|
||||
Consumer<CharacterRecord> cleanup, Consumer<Throwable> failures) {
|
||||
this.inventory = Objects.requireNonNull(inventory);
|
||||
this.characters = Objects.requireNonNull(characters);
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.cleanup = Objects.requireNonNull(cleanup);
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
public Optional<Preview> preview(Player player) {
|
||||
var context = context(player).orElse(null);
|
||||
if (context == null) return Optional.empty();
|
||||
var quantity = inventory.quantity(context.record().id(), RareProduct.RESKILL);
|
||||
if (quantity.signum() <= 0) return Optional.empty();
|
||||
return Optional.of(new Preview(UUID.randomUUID(), context.active(), context.record(), quantity,
|
||||
refund(context.record())));
|
||||
}
|
||||
|
||||
public void confirm(Player player, Preview preview, Consumer<Outcome> result) {
|
||||
Objects.requireNonNull(player); Objects.requireNonNull(preview); Objects.requireNonNull(result);
|
||||
var current = context(player).orElse(null);
|
||||
if (current == null || !current.active().session().equals(preview.active().session())
|
||||
|| !current.record().revision().equals(preview.record().revision())
|
||||
|| !inventory.quantity(current.record().id(), RareProduct.RESKILL).equals(preview.quantity())) {
|
||||
result.accept(new Outcome(Status.STALE, Optional.ofNullable(current).map(Context::record)));
|
||||
return;
|
||||
}
|
||||
if (preview.refund().signum() == 0) {
|
||||
result.accept(new Outcome(Status.NOTHING_TO_RESET, Optional.of(current.record())));
|
||||
return;
|
||||
}
|
||||
var nativeLimit = NativeHealthLimit.read();
|
||||
inventory.reserveUse(current.record().id(), RareProduct.RESKILL, preview.operation(), preview.quantity())
|
||||
.whenComplete((reservation, reserveFailure) -> {
|
||||
if (reserveFailure != null) { fail(reserveFailure, result); return; }
|
||||
if (reservation.status() != RareInventoryDirectory.UseStatus.RESERVED) {
|
||||
publish(() -> result.accept(new Outcome(
|
||||
reservation.status() == RareInventoryDirectory.UseStatus.NOT_AVAILABLE
|
||||
? Status.NOT_AVAILABLE : Status.STALE, Optional.empty())));
|
||||
return;
|
||||
}
|
||||
characters.reskill(current.record().id(), current.record().revision(), preview.operation(), nativeLimit)
|
||||
.whenComplete((reset, resetFailure) -> complete(player, preview, reset, resetFailure, result));
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve interrupted resets from authoritative character proof; never replay native cleanup. */
|
||||
public void install() {
|
||||
for (var use : inventory.unresolvedUses()) {
|
||||
if (!use.product().equals(RareProduct.RESKILL.id())) continue;
|
||||
characters.find(use.character()).whenComplete((record, failure) -> {
|
||||
if (failure != null) { report(failure); return; }
|
||||
boolean applied = record.isPresent() && record.orElseThrow().rewardReceipts().contains(use.operation());
|
||||
var work = applied ? inventory.commitUse(use.operation()) : inventory.refundUse(use.operation());
|
||||
work.exceptionally(problem -> { report(problem); return RareInventoryDirectory.UseStatus.RESERVED; });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void complete(Player player, Preview preview, CharacterStorage.Reskill reset, Throwable failure,
|
||||
Consumer<Outcome> result) {
|
||||
if (failure != null || reset == null) {
|
||||
if (failure != null) report(failure);
|
||||
refund(preview.operation(), () -> result.accept(new Outcome(Status.FAILED, Optional.empty())));
|
||||
return;
|
||||
}
|
||||
if (reset.status() != CharacterStorage.ReskillStatus.APPLIED
|
||||
&& reset.status() != CharacterStorage.ReskillStatus.DUPLICATE) {
|
||||
var status = switch (reset.status()) {
|
||||
case NOTHING_TO_RESET -> Status.NOTHING_TO_RESET;
|
||||
case STALE -> Status.STALE;
|
||||
case UNAVAILABLE -> Status.NOT_AVAILABLE;
|
||||
case APPLIED, DUPLICATE -> throw new IllegalStateException();
|
||||
};
|
||||
refund(preview.operation(), () -> result.accept(new Outcome(status, reset.record())));
|
||||
return;
|
||||
}
|
||||
publish(() -> {
|
||||
boolean acknowledged = false;
|
||||
try {
|
||||
var current = context(player);
|
||||
acknowledged = current.isPresent()
|
||||
&& current.orElseThrow().active().session().equals(preview.active().session())
|
||||
&& (reset.status() == CharacterStorage.ReskillStatus.DUPLICATE
|
||||
|| sessions.acknowledgeProgress(preview.active(), preview.record().revision(), reset.record().orElseThrow()));
|
||||
} catch (RuntimeException uncertain) { report(uncertain); }
|
||||
if (acknowledged) try { cleanup.accept(preview.record()); } catch (RuntimeException uncertain) { report(uncertain); acknowledged=false; }
|
||||
boolean currentSession = acknowledged;
|
||||
inventory.commitUse(preview.operation()).whenComplete((ignored, commitFailure) -> {
|
||||
if (commitFailure != null) { report(commitFailure); publish(() -> result.accept(new Outcome(Status.FAILED, reset.record()))); }
|
||||
else publish(() -> result.accept(new Outcome(currentSession ? Status.APPLIED : Status.STALE, reset.record())));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void refund(UUID operation, Runnable after) {
|
||||
inventory.refundUse(operation).whenComplete((ignored, failure) -> {
|
||||
if (failure != null) report(failure);
|
||||
publish(after);
|
||||
});
|
||||
}
|
||||
|
||||
private Optional<Context> context(Player player) {
|
||||
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);
|
||||
return record == null ? Optional.empty() : Optional.of(new Context(active, record));
|
||||
}
|
||||
|
||||
private static BigInteger refund(CharacterRecord record) {
|
||||
return record.stats().values().stream().reduce(BigInteger.ZERO, BigInteger::add)
|
||||
.add(BigInteger.valueOf(record.learned().size()))
|
||||
.add(BigInteger.valueOf(record.passives().stream().filter(id -> !id.isEmpty()).distinct().count()));
|
||||
}
|
||||
|
||||
private void fail(Throwable failure, Consumer<Outcome> result) {
|
||||
report(failure); publish(() -> result.accept(new Outcome(Status.FAILED, Optional.empty())));
|
||||
}
|
||||
private void publish(Runnable action) { try { nextTick.accept(action); } catch (RuntimeException failure) { report(failure); } }
|
||||
private void report(Throwable failure) { try { failures.accept(failure); } catch (RuntimeException ignored) { } }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Living active-character boundary for the one recoverable shared rare market. */
|
||||
public final class RareShop {
|
||||
public enum Status{APPLIED,INVALID_SESSION,SOLD_OUT,INSUFFICIENT,STALE,FAILED}
|
||||
public record Outcome(Status status,Optional<CharacterRecord> record){}
|
||||
public record View(CharacterRecord record,UUID session,RareCatalog catalog){}
|
||||
private record Context(EquipmentSessions.Active active,CharacterRecord record,RareMerchant shop){}
|
||||
private final RareMerchantDirectory shops;private final EquipmentSessions sessions;private final RareMarket market;private final RareInventoryDirectory inventory;private final Consumer<Runnable> nextTick;private final Consumer<Throwable> failures;private games.dmg.fantasystick.events.RareEventBridge events;
|
||||
public RareShop(RareMerchantDirectory shops,EquipmentSessions sessions,RareMarket market,RareInventoryDirectory inventory,Consumer<Runnable> nextTick,Consumer<Throwable> failures){this.shops=Objects.requireNonNull(shops);this.sessions=Objects.requireNonNull(sessions);this.market=Objects.requireNonNull(market);this.inventory=Objects.requireNonNull(inventory);this.nextTick=Objects.requireNonNull(nextTick);this.failures=Objects.requireNonNull(failures);}
|
||||
public void attachEvents(games.dmg.fantasystick.events.RareEventBridge events){if(this.events!=null)throw new IllegalStateException("Rare events already attached");this.events=Objects.requireNonNull(events);}
|
||||
public Optional<CharacterRecord> active(Player player,UUID shop){return context(player,shop).map(Context::record);}public Optional<UUID> activeSession(Player player,UUID shop){return context(player,shop).map(value->value.active().session());}public Instant now(){return market.now();}
|
||||
public void view(Player player,UUID shopId,Consumer<Optional<View>> result){var captured=context(player,shopId);if(captured.isEmpty()){result.accept(Optional.empty());return;}market.current().whenComplete((catalog,failure)->{if(failure!=null){report(failure);publish(()->result.accept(Optional.empty()));return;}publish(()->{var current=context(player,shopId);if(current.isEmpty()||!current.orElseThrow().active().session().equals(captured.orElseThrow().active().session()))result.accept(Optional.empty());else result.accept(Optional.of(new View(current.orElseThrow().record(),current.orElseThrow().active().session(),catalog)));});});}
|
||||
public void buy(Player player,UUID shopId,UUID catalogRevision,RareProduct product,Consumer<Outcome> result){var lease=shops.acquire(shopId);if(lease.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var held=lease.orElseThrow();var context=context(player,shopId);if(context.isEmpty()){held.close();result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var captured=context.orElseThrow();market.purchase(captured.record().id(),captured.record().revision(),catalogRevision,product).whenComplete((purchase,failure)->{if(failure!=null){report(failure);publishHeld(held,()->result.accept(new Outcome(Status.FAILED,Optional.empty())));return;}publishHeld(held,()->{var current=context(player,shopId);if(current.isEmpty()||!current.orElseThrow().active().session().equals(captured.active().session())){result.accept(new Outcome(Status.STALE,purchase.record()));return;}var status=switch(purchase.status()){case APPLIED->Status.APPLIED;case SOLD_OUT->Status.SOLD_OUT;case INSUFFICIENT->Status.INSUFFICIENT;case STALE->Status.STALE;case UNAVAILABLE,FAILED->Status.FAILED;};if(purchase.status()==RareMarket.PurchaseStatus.APPLIED&&purchase.record().isPresent()){sessions.acknowledgeProgress(captured.active(),captured.record().revision(),purchase.record().orElseThrow());if(product==RareProduct.PHOENIX_DOWN&&purchase.proof()!=null&&events!=null){var after=inventory.quantity(captured.record().id(),product);events.purchased(purchase.proof().operation(),purchase.catalog().revision(),player.getUniqueId(),player.getName(),captured.record().id(),captured.record().name(),captured.active().session(),purchase.proof().price(),purchase.proof().stockAfter()+1,purchase.proof().stockAfter(),after.subtract(java.math.BigInteger.ONE),after);}}result.accept(new Outcome(status,purchase.record()));});});}
|
||||
private Optional<Context> context(Player player,UUID shopId){var shop=shops.find(shopId).orElse(null);var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);if(shop==null||record==null)return Optional.empty();var location=player.getLocation();if(location.getWorld()==null||!location.getWorld().getUID().equals(shop.world()))return Optional.empty();double x=location.getX()-shop.x(),y=location.getY()-shop.y(),z=location.getZ()-shop.z();return x*x+y*y+z*z<=36?Optional.of(new Context(active,record,shop)):Optional.empty();}
|
||||
private void publishHeld(RareMerchantDirectory.Lease lease,Runnable action){try{nextTick.accept(()->{try{action.run();}finally{lease.close();}});}catch(RuntimeException failure){lease.close();report(failure);}}private void publish(Runnable action){try{nextTick.accept(action);}catch(RuntimeException failure){report(failure);}}private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package games.dmg.fantasystick.shop;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.event.inventory.InventoryDragEvent;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.InventoryHolder;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
/** Bounded nonexportable rare-shop views with authoritative stale-generation revalidation. */
|
||||
public final class RareShopMenus implements Listener,AutoCloseable {
|
||||
private record Holder(UUID shop,UUID character,UUID session,UUID catalog)implements InventoryHolder{public Inventory getInventory(){return null;}}
|
||||
private final RareShop service;private final org.bukkit.Server server;private final NamespacedKey productKey;private boolean closed;
|
||||
public RareShopMenus(Plugin plugin,RareShop service){this.service=Objects.requireNonNull(service);server=plugin.getServer();productKey=new NamespacedKey(plugin,"rare_shop_product");}
|
||||
public void open(Player player,UUID shop){service.view(player,shop,view->{if(closed||!player.isOnline())return;if(view.isEmpty()){player.sendMessage("Rare Items shop requires a living active character beside the registered merchant.");return;}var current=view.orElseThrow();var inventory=server.createInventory(new Holder(shop,current.record().id(),current.session(),current.catalog().revision()),27,net.kyori.adventure.text.Component.text("Rare Items"));int slot=10;boolean available=false;for(var product:RareProduct.values()){var offer=current.catalog().available(product)?current.catalog().offer(product):null;inventory.setItem(slot++,item(product,offer));if(offer!=null&&offer.remaining()>0)available=true;}inventory.setItem(22,schedule(current.catalog(),service.now()));if(!available)inventory.setItem(16,empty());player.openInventory(inventory);});}
|
||||
private ItemStack item(RareProduct product,RareCatalog.Offer offer){var item=new ItemStack(Material.POTION);var meta=item.getItemMeta();if(offer==null){meta.displayName(net.kyori.adventure.text.Component.text("Unavailable — "+product.displayName()));meta.lore(List.of(net.kyori.adventure.text.Component.text("Available after the next rare restock")));}else{meta.displayName(net.kyori.adventure.text.Component.text((offer.remaining()==0?"Sold out — ":"Buy ")+product.displayName()+" — "+offer.price()+" coins"));meta.lore(List.of(net.kyori.adventure.text.Component.text("Stock: "+offer.remaining())));meta.getPersistentDataContainer().set(productKey,PersistentDataType.STRING,product.id());}item.setItemMeta(meta);return item;}
|
||||
private static ItemStack empty(){var item=new ItemStack(Material.BARRIER);var meta=item.getItemMeta();meta.displayName(net.kyori.adventure.text.Component.text("All Rare Items are sold out"));item.setItemMeta(meta);return item;}
|
||||
private static ItemStack schedule(RareCatalog catalog,Instant now){var next=catalog.day().plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();long seconds=Math.max(0,Duration.between(now,next).toSeconds()),hours=seconds/3600,minutes=(seconds%3600)/60;var item=new ItemStack(Material.CLOCK);var meta=item.getItemMeta();meta.displayName(net.kyori.adventure.text.Component.text("Restock in: "+hours+"h "+minutes+"m"));item.setItemMeta(meta);return item;}
|
||||
public void refreshOpen(){if(closed)return;for(var player:server.getOnlinePlayers()){var view=player.getOpenInventory();if(view==null)continue;var inventory=view.getTopInventory();if(inventory!=null&&inventory.getHolder() instanceof Holder holder)open(player,holder.shop());}}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof Holder holder)||!(event.getWhoClicked() instanceof Player player))return;event.setCancelled(true);if(event.getRawSlot()<0||event.getRawSlot()>=event.getInventory().getSize())return;if(closed||!service.active(player,holder.shop()).map(value->value.id().equals(holder.character())).orElse(false)||!service.activeSession(player,holder.shop()).map(holder.session()::equals).orElse(false)){player.sendMessage("Rare purchase refused: the character, session or merchant changed.");return;}var item=event.getCurrentItem();if(item==null)return;String id=item.getItemMeta().getPersistentDataContainer().get(productKey,PersistentDataType.STRING);var product=RareProduct.find(id).orElse(null);if(product==null)return;service.buy(player,holder.shop(),holder.catalog(),product,outcome->{if(outcome.status()==RareShop.Status.APPLIED)player.sendMessage("Purchased "+product.displayName()+" for character Inventory.");else{player.sendMessage("Rare purchase refused: "+outcome.status().name().toLowerCase(Locale.ROOT).replace('_',' ')+".");if(outcome.status()==RareShop.Status.STALE||outcome.status()==RareShop.Status.SOLD_OUT)open(player,holder.shop());}});}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void drag(InventoryDragEvent event){if(event.getInventory().getHolder() instanceof Holder)event.setCancelled(true);}
|
||||
@Override public void close(){closed=true;}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
# Captured when a new fantasy session starts; changing this does not alter active sessions.
|
||||
# Admin command: /fantasystick config adventure-mode [true|false]
|
||||
# Admin command: /fstickadmin config adventure-mode [true|false]
|
||||
adventure-mode: true
|
||||
admin-revive-selection-timeout-seconds: 60
|
||||
|
||||
@@ -28,11 +28,36 @@ gear-stock-diamond-min: 5
|
||||
gear-stock-diamond-max: 10
|
||||
gear-stock-netherite-min: 1
|
||||
gear-stock-netherite-max: 3
|
||||
rare-price-variance-percent: 10
|
||||
rare-price-lesser-health-potion: 50
|
||||
rare-stock-lesser-health-potion-min: 0
|
||||
rare-stock-lesser-health-potion-max: 10
|
||||
rare-lesser-health-potion-healing-percent: 25
|
||||
rare-price-health-potion: 150
|
||||
rare-stock-health-potion-min: 0
|
||||
rare-stock-health-potion-max: 5
|
||||
rare-health-potion-healing-percent: 50
|
||||
rare-price-greater-health-potion: 500
|
||||
rare-stock-greater-health-potion-min: 0
|
||||
rare-stock-greater-health-potion-max: 2
|
||||
rare-greater-health-potion-healing-percent: 100
|
||||
rare-price-reskill: 2000
|
||||
rare-stock-reskill-min: 0
|
||||
rare-stock-reskill-max: 1
|
||||
rare-price-magic-core: 3000
|
||||
rare-stock-magic-core-min: 0
|
||||
rare-stock-magic-core-max: 3
|
||||
rare-price-phoenix-down: 1000
|
||||
rare-stock-phoenix-down-min: 0
|
||||
rare-stock-phoenix-down-max: 2
|
||||
rare-phoenix-down-invulnerability-seconds: 5
|
||||
downed-timeout-seconds: 300
|
||||
revive-downed-invulnerability-seconds: 5
|
||||
sheet-resource-pack-url: ''
|
||||
sheet-resource-pack-sha1: ''
|
||||
|
||||
# Active-session recovery after actual accepted damage has stopped.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
out-of-combat-healing-delay-seconds: 10
|
||||
out-of-combat-healing-interval-seconds: 10
|
||||
out-of-combat-healing-amount: 1
|
||||
@@ -73,9 +98,11 @@ adventure-champion-health: 100
|
||||
adventure-champion-damage: 8
|
||||
adventure-champion-reward-xp: 150
|
||||
adventure-champion-reward-coins: 40
|
||||
fishing-catch-xp-min: 1
|
||||
fishing-catch-xp-max: 3
|
||||
|
||||
# Fireball defaults from the reviewed starter catalogue.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
fireball-base-damage: 6
|
||||
fireball-cooldown-seconds: 1.2
|
||||
fireball-range-blocks: 32
|
||||
@@ -87,7 +114,7 @@ great-sword-base-damage: 7
|
||||
great-sword-recovery-seconds: 0.75
|
||||
|
||||
# Frost Shard defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
frost-shard-base-damage: 4
|
||||
frost-shard-cooldown-seconds: 5
|
||||
frost-shard-range-blocks: 32
|
||||
@@ -97,7 +124,7 @@ frost-shard-slowness-amplifier: 0
|
||||
frost-shard-slowness-duration-seconds: 2
|
||||
|
||||
# Longbow defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
longbow-base-damage: 5
|
||||
longbow-recovery-seconds: 1
|
||||
longbow-minimum-draw-ticks: 3
|
||||
@@ -107,24 +134,24 @@ longbow-lifetime-seconds: 10
|
||||
longbow-full-projectile-speed-blocks-per-tick: 3
|
||||
|
||||
# Quickstep defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
quickstep-cooldown-seconds: 5
|
||||
quickstep-distance-blocks: 4
|
||||
quickstep-speed-blocks-per-tick: 1
|
||||
|
||||
# Second Wind defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
second-wind-base-healing: 6
|
||||
second-wind-cooldown-seconds: 15
|
||||
|
||||
# Stone Skin defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
stone-skin-cooldown-seconds: 14
|
||||
stone-skin-duration-seconds: 4
|
||||
stone-skin-damage-reduction-percent: 25
|
||||
|
||||
# Shield Bash defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
shield-bash-base-damage: 4
|
||||
shield-bash-cooldown-seconds: 3
|
||||
shield-bash-range-blocks: 3
|
||||
@@ -132,39 +159,39 @@ shield-bash-horizontal-knockback: 0.8
|
||||
shield-bash-minimum-vertical-knockback: 0.35
|
||||
|
||||
# Hex defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
hex-cooldown-seconds: 10
|
||||
hex-range-blocks: 12
|
||||
hex-duration-seconds: 4
|
||||
hex-damage-reduction-percent: 20
|
||||
|
||||
# Inspire defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
inspire-base-temporary-health: 4
|
||||
inspire-cooldown-seconds: 15
|
||||
inspire-range-blocks: 12
|
||||
inspire-duration-seconds: 6
|
||||
|
||||
# Mending Light defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
mending-light-base-healing: 6
|
||||
mending-light-cooldown-seconds: 8
|
||||
mending-light-range-blocks: 12
|
||||
|
||||
# Purify defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
purify-cooldown-seconds: 15
|
||||
purify-range-blocks: 12
|
||||
|
||||
# Guardian Bond defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
guardian-bond-cooldown-seconds: 20
|
||||
guardian-bond-range-blocks: 10
|
||||
guardian-bond-duration-seconds: 6
|
||||
guardian-bond-redirect-percent: 30
|
||||
|
||||
# Rallying Cry defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
rallying-cry-cooldown-seconds: 25
|
||||
rallying-cry-radius-blocks: 8
|
||||
rallying-cry-duration-seconds: 6
|
||||
@@ -172,7 +199,7 @@ rallying-cry-speed-amplifier: 0
|
||||
rallying-cry-damage-bonus-percent: 20
|
||||
|
||||
# Frost Nova defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
frost-nova-base-damage: 5
|
||||
frost-nova-cooldown-seconds: 12
|
||||
frost-nova-radius-blocks: 5
|
||||
@@ -180,7 +207,7 @@ frost-nova-slowness-amplifier: 1
|
||||
frost-nova-slowness-duration-seconds: 3
|
||||
|
||||
# Chain Lightning defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
chain-lightning-base-damage: 8
|
||||
chain-lightning-cooldown-seconds: 18
|
||||
chain-lightning-initial-range-blocks: 16
|
||||
@@ -189,7 +216,7 @@ chain-lightning-additional-targets: 3
|
||||
chain-lightning-jump-multiplier: 0.75
|
||||
|
||||
# Sanctuary defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
sanctuary-cooldown-seconds: 45
|
||||
sanctuary-radius-blocks: 6
|
||||
sanctuary-vertical-range-blocks: 3
|
||||
@@ -199,7 +226,7 @@ sanctuary-healing-per-pulse: 2
|
||||
sanctuary-damage-reduction-percent: 20
|
||||
|
||||
# War Banner defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
war-banner-cooldown-seconds: 45
|
||||
war-banner-placement-range-blocks: 12
|
||||
war-banner-radius-blocks: 8
|
||||
@@ -209,7 +236,7 @@ war-banner-damage-bonus-percent: 25
|
||||
war-banner-knockback-resistance-percent: 50
|
||||
|
||||
# Meteor defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
meteor-base-damage: 14
|
||||
meteor-cooldown-seconds: 45
|
||||
meteor-target-range-blocks: 24
|
||||
|
||||
@@ -6,16 +6,13 @@ depend: [ProtocolLib]
|
||||
description: Transferable characters, native fantasy health, recoverable equipment, character books and parties.
|
||||
author: dmg.games
|
||||
commands:
|
||||
fantasystick:
|
||||
description: Inspect daily adventures or administer Fantasy Stick.
|
||||
usage: /fantasystick adventure [reroll confirm] | character view <online-player> | adventure-site <place <camp|champion> [name...]|remove <site-uuid>|teleport <site-uuid>|list> | dungeon <teleport <dungeon-uuid>|...> | quest-giver <place [name...]|remove <giver-uuid>|list> | <admin route>
|
||||
|
||||
fantasyfeedback:
|
||||
description: Read or change routine Fantasy Stick combat and ability feedback for your player account.
|
||||
usage: /fantasyfeedback [on|off]
|
||||
fantasyparticles:
|
||||
description: Read or change Fantasy Stick particle visibility for your player account.
|
||||
usage: /fantasyparticles <all|players> [on|off]
|
||||
fstick:
|
||||
description: Inspect personal adventures and manage Fantasy Stick player preferences.
|
||||
usage: /fstick adventure [tracking [on|off]] | feedback [on|off] | particles <all|players> [on|off]
|
||||
fstickadmin:
|
||||
description: Administer Fantasy Stick.
|
||||
usage: /fstickadmin adventure reroll [confirm] | character view <online-player> | adventure-site <place <camp|champion> [name...]|remove <site-uuid>|teleport <site-uuid>|list> | dungeon <teleport <dungeon-uuid>|...> | quest-giver <place [name...]|remove <giver-uuid>|list> | <admin route>
|
||||
permission: fantasystick.admin
|
||||
permissions:
|
||||
fantasystick.admin:
|
||||
description: Issue/revive sheets, grant fantasy experience, recover sessions and configure fantasy mode.
|
||||
|
||||
@@ -42,6 +42,10 @@ class AbilityRegistryNativeTest {
|
||||
() -> assertThrows(NullPointerException.class, () -> new AbilityDefinition("category", "Category", Material.STONE,
|
||||
List.of("Lore"), "Use", Map.of(), BigInteger.ONE, null, AbilityDefinition.Activation.IMMEDIATE,
|
||||
ControlInput.Gesture.USE, Duration.ZERO, Map.of(), AbilityDefinition.TargetPolicy.SELF, ignored -> {})),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> new AbilityDefinition("ambiguous_cost", "Ambiguous", Material.STONE,
|
||||
List.of("Lore"), "Use", Map.of(), BigInteger.TWO, AbilityDefinition.Category.ATK,
|
||||
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, Duration.ZERO,
|
||||
Map.of(), AbilityDefinition.TargetPolicy.SELF, ignored -> {})),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "low", Map.of(Stat.INT, BigInteger.valueOf(9)), Map.of(), Duration.ZERO)),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "negative", valid.requirements(), Map.of("range", BigDecimal.valueOf(-1)), Duration.ZERO)),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "infinite", valid.requirements(), Map.of("range", new BigDecimal("1E1000000")), Duration.ZERO)),
|
||||
|
||||
@@ -275,8 +275,8 @@ class AdminCharacterBookNativeTest {
|
||||
|
||||
assertEquals(java.util.List.of("character", "config"),
|
||||
fixture.command.tabComplete(admin.player, "fantasystick", new String[]{"c"}));
|
||||
assertEquals(java.util.List.of("view"),
|
||||
fixture.command.tabComplete(admin.player, "fantasystick", new String[]{"character", ""}));
|
||||
assertEquals(java.util.List.of("find", "recover", "show", "view"),
|
||||
fixture.command.tabComplete(admin.player, "fstickadmin", new String[]{"character", ""}));
|
||||
assertEquals(java.util.List.of("ActiveTarget"),
|
||||
fixture.command.tabComplete(admin.player, "fantasystick", new String[]{"character", "view", ""}));
|
||||
assertEquals(java.util.List.of("ActiveTarget"),
|
||||
@@ -293,8 +293,8 @@ class AdminCharacterBookNativeTest {
|
||||
admin.allowed = true;
|
||||
|
||||
var console = new EquipmentAdminCommandNativeTest.Sender();
|
||||
assertTrue(fixture.command.tabComplete(console.port, "fantasystick",
|
||||
new String[]{"character", ""}).isEmpty());
|
||||
assertEquals(java.util.List.of("find"), fixture.command.tabComplete(console.port, "fstickadmin",
|
||||
new String[]{"character", ""}));
|
||||
fixture.command.execute(console.port, "fantasystick",
|
||||
new String[]{"character", "view", "ActiveTarget"});
|
||||
assertTrue(console.messages.getLast().contains("in-game administrator"));
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.event.inventory.InventoryAction;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.event.inventory.InventoryType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Registered confirmed reissue/revival through durable generation authority and native delivery. */
|
||||
class AdminCharacterRecoveryNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void confirmedLivingRecoveryRevokesOldSheetPreservesCharacterAndActivatesReplacement() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var administrator = actor(data, "Admin");
|
||||
var recipient = actor(data, "Recipient");
|
||||
var source = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(administrator.core.id, administrator.player);
|
||||
fixture.players.put(recipient.core.id, recipient.player);
|
||||
var storage = characters(fixture);
|
||||
var original = storage.renameCharacter(source.core.record.id(), "Recovered Hero")
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
var sheets = new games.dmg.fantasystick.items.CharacterSheets();
|
||||
var issued = sheets.create(original);
|
||||
var legacy = issued.clone();
|
||||
var legacyMeta = legacy.getItemMeta();
|
||||
legacyMeta.getPersistentDataContainer().remove(
|
||||
new org.bukkit.NamespacedKey("fantasystick", "sheet_generation"));
|
||||
legacy.setItemMeta(legacyMeta);
|
||||
assertTrue(sheets.authorizes(legacy, original));
|
||||
var malformed = issued.clone();
|
||||
var malformedMeta = malformed.getItemMeta();
|
||||
malformedMeta.getPersistentDataContainer().set(
|
||||
new org.bukkit.NamespacedKey("fantasystick", "sheet_generation"),
|
||||
org.bukkit.persistence.PersistentDataType.STRING, "not-a-generation");
|
||||
malformed.setItemMeta(malformedMeta);
|
||||
assertFalse(sheets.authorizes(malformed, original));
|
||||
var oldSheet = legacy;
|
||||
int destination = recipient.core.inventory.firstEmpty();
|
||||
|
||||
assertTrue(fixture.adminCommand.execute(administrator.player, "fstickadmin",
|
||||
new String[]{"character", "find", "Recovered"}));
|
||||
drain(fixture, recipient.core.id);
|
||||
assertEquals(java.util.List.of(original.id().toString()), fixture.adminCommand.tabComplete(
|
||||
administrator.player, "fstickadmin", new String[]{"character", "recover", original.id().toString().substring(0, 8)}));
|
||||
assertEquals(java.util.List.of("Recipient"), fixture.adminCommand.tabComplete(
|
||||
administrator.player, "fstickadmin", new String[]{"character", "recover", original.id().toString(), "Rec"}));
|
||||
administrator.allowed = false;
|
||||
assertTrue(fixture.adminCommand.tabComplete(administrator.player, "fstickadmin",
|
||||
new String[]{"character", "recover", ""}).isEmpty());
|
||||
administrator.allowed = true;
|
||||
|
||||
assertTrue(fixture.adminCommand.execute(administrator.player, "fstickadmin",
|
||||
new String[]{"character", "recover", original.id().toString(), "Recipient"}));
|
||||
var recovery = FantasyStickLifecycleNativeTest.Fixture.field(
|
||||
fixture.adminCommand.getExecutor(), "characterRecovery");
|
||||
assertEquals(1, ((java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(
|
||||
recovery, "pending")).size());
|
||||
drain(fixture, recipient.core.id);
|
||||
assertEquals(1, administrator.inventories.size(), () -> administrator.messages + " pending="
|
||||
+ ((java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(recovery, "pending")).size()
|
||||
+ " tasks=" + fixture.tasks.size());
|
||||
var preview = administrator.inventories.getLast();
|
||||
assertEquals(Material.PAPER, preview.getItem(2).getType());
|
||||
assertEquals(Material.BARRIER, preview.getItem(6).getType());
|
||||
String previewText = String.valueOf(preview.getItem(2).getItemMeta().lore());
|
||||
assertTrue(previewText.contains(original.id().toString()));
|
||||
assertTrue(previewText.contains(recipient.core.id.toString()));
|
||||
click(fixture, administrator, preview, 2);
|
||||
recipient.core.inventory.setItemInOffHand(oldSheet);
|
||||
sessions(fixture).enter(recipient.player);
|
||||
assertTrue(sessions(fixture).active(recipient.core.id).isEmpty());
|
||||
assertTrue(recipient.messages.stream().anyMatch(message -> message.contains("recovery is still completing")));
|
||||
recipient.core.inventory.setItemInOffHand(null);
|
||||
drain(fixture, recipient.core.id);
|
||||
|
||||
var recovered = storage.inspect(original.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertEquals(original.id(), recovered.id());
|
||||
assertEquals(original.name(), recovered.name());
|
||||
assertEquals(original.stats(), recovered.stats());
|
||||
assertEquals(original.level(), recovered.level());
|
||||
assertEquals(original.experience(), recovered.experience());
|
||||
assertEquals(original.unspentPoints(), recovered.unspentPoints());
|
||||
assertEquals(original.health(), recovered.health());
|
||||
assertEquals(original.learned(), recovered.learned());
|
||||
assertEquals(original.loadout(), recovered.loadout());
|
||||
assertEquals(original.passives(), recovered.passives());
|
||||
assertEquals(original.cooldowns(), recovered.cooldowns());
|
||||
assertEquals(original.coins(), recovered.coins());
|
||||
assertEquals(original.gear(), recovered.gear());
|
||||
assertEquals(original.sheetGeneration().add(java.math.BigInteger.ONE), recovered.sheetGeneration());
|
||||
var replacement = recipient.core.inventory.getItem(destination);
|
||||
assertTrue(sheets.authorizes(replacement, recovered), () -> administrator.messages
|
||||
+ " item=" + replacement);
|
||||
assertFalse(sheets.authorizes(oldSheet, recovered));
|
||||
assertTrue(administrator.messages.stream().anyMatch(message -> message.contains("Older sheets are revoked")));
|
||||
|
||||
var ordinary = recipient.core.inventory.getItemInOffHand();
|
||||
recipient.core.inventory.setItemInOffHand(oldSheet);
|
||||
sessions(fixture).enter(recipient.player);
|
||||
drain(fixture, recipient.core.id);
|
||||
assertTrue(sessions(fixture).active(recipient.core.id).isEmpty());
|
||||
fixture.plugin.setEnabled(false);
|
||||
fixture.plugin.setEnabled(true);
|
||||
recipient.core.inventory.setItem(destination, null);
|
||||
recipient.core.inventory.setItemInOffHand(replacement);
|
||||
sessions(fixture).enter(recipient.player);
|
||||
drain(fixture, recipient.core.id);
|
||||
var active = sessions(fixture).active(recipient.core.id);
|
||||
assertTrue(active.isPresent(), () -> "messages=" + recipient.messages + " contents="
|
||||
+ java.util.Arrays.toString(recipient.core.inventory.getContents()));
|
||||
assertEquals(recovered.id(), active.orElseThrow().character());
|
||||
recipient.core.inventory.setItemInOffHand(ordinary);
|
||||
assertEquals(recovered.sheetGeneration(), new games.dmg.fantasystick.persistence.YamlCharacterRepository(data)
|
||||
.load(recovered.id()).orElseThrow().sheetGeneration());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void malformedConsoleFullInventoryClosedPreviewAndPermissionLossMutateNothing() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var administrator = actor(data, "Admin");
|
||||
var recipient = actor(data, "Recipient");
|
||||
var source = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(administrator.core.id, administrator.player);
|
||||
fixture.players.put(recipient.core.id, recipient.player);
|
||||
var storage = characters(fixture);
|
||||
var original = storage.inspect(source.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
|
||||
for (String[] arguments : java.util.List.of(
|
||||
new String[]{"character", "recover", "not-a-uuid", "Recipient"},
|
||||
new String[]{"character", "recover", original.id().toString()},
|
||||
new String[]{"character", "recover", original.id().toString(), "Recipient", "extra"}))
|
||||
assertTrue(fixture.adminCommand.execute(administrator.player, "fstickadmin", arguments));
|
||||
|
||||
var console = mock(org.bukkit.command.CommandSender.class);
|
||||
when(console.hasPermission(games.dmg.fantasystick.session.EquipmentAdminCommand.PERMISSION)).thenReturn(true);
|
||||
assertTrue(fixture.adminCommand.execute(console, "fstickadmin",
|
||||
new String[]{"character", "recover", original.id().toString(), "Recipient"}));
|
||||
verify(console).sendMessage(contains("in-game administrator"));
|
||||
|
||||
var reservation = new games.dmg.fantasystick.persistence.RevivalStore.Reservation(
|
||||
UUID.randomUUID(), UUID.randomUUID(), recipient.core.id, original.id(), UUID.randomUUID(),
|
||||
java.time.Instant.parse("2026-09-23T00:00:00Z"), 0, 1, -1, true);
|
||||
assertTrue(revivals(fixture).reserve(reservation).get(5, TimeUnit.SECONDS).isPresent());
|
||||
fixture.adminCommand.execute(administrator.player, "fstickadmin",
|
||||
new String[]{"character", "recover", original.id().toString(), "Recipient"});
|
||||
drain(fixture, recipient.core.id);
|
||||
assertTrue(administrator.messages.stream().anyMatch(message -> message.contains("custody must be resolved")));
|
||||
assertTrue(revivals(fixture).clear(original.id(), reservation.id()).get(5, TimeUnit.SECONDS));
|
||||
|
||||
for (int slot = 0; slot < 36; slot++)
|
||||
recipient.core.inventory.setItem(slot, new org.bukkit.inventory.ItemStack(Material.STONE));
|
||||
fixture.adminCommand.execute(administrator.player, "fstickadmin",
|
||||
new String[]{"character", "recover", original.id().toString(), "Recipient"});
|
||||
drain(fixture, recipient.core.id);
|
||||
assertTrue(administrator.inventories.isEmpty());
|
||||
assertTrue(administrator.messages.stream().anyMatch(message -> message.contains("empty main-inventory slot")));
|
||||
assertEquals(original, storage.inspect(original.id()).get(5, TimeUnit.SECONDS).orElseThrow());
|
||||
|
||||
recipient.core.inventory.setItem(1, null);
|
||||
fixture.adminCommand.execute(administrator.player, "fstickadmin",
|
||||
new String[]{"character", "recover", original.id().toString(), "Recipient"});
|
||||
drain(fixture, recipient.core.id);
|
||||
var preview = administrator.inventories.getLast();
|
||||
close(fixture, administrator, preview);
|
||||
var ignored = clickEvent(administrator, preview, 2);
|
||||
fixture.dispatcher.accept(ignored);
|
||||
assertFalse(ignored.isCancelled());
|
||||
assertEquals(original, storage.inspect(original.id()).get(5, TimeUnit.SECONDS).orElseThrow());
|
||||
|
||||
fixture.adminCommand.execute(administrator.player, "fstickadmin",
|
||||
new String[]{"character", "recover", original.id().toString(), "Recipient"});
|
||||
drain(fixture, recipient.core.id);
|
||||
administrator.allowed = false;
|
||||
click(fixture, administrator, administrator.inventories.getLast(), 2);
|
||||
drain(fixture, recipient.core.id);
|
||||
assertEquals(original, storage.inspect(original.id()).get(5, TimeUnit.SECONDS).orElseThrow());
|
||||
assertNull(recipient.core.inventory.getItem(1));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void activeCharacterCustodyRefusesRecoveryBeforePreview() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var administrator = actor(data, "Admin");
|
||||
var recipient = actor(data, "Recipient");
|
||||
var holder = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(administrator.core.id, administrator.player);
|
||||
fixture.players.put(recipient.core.id, recipient.player);
|
||||
fixture.players.put(holder.core.id, holder.player);
|
||||
fixture.activate(holder);
|
||||
|
||||
fixture.adminCommand.execute(administrator.player, "fstickadmin",
|
||||
new String[]{"character", "recover", holder.core.record.id().toString(), "Recipient"});
|
||||
drain(fixture, recipient.core.id);
|
||||
assertTrue(administrator.inventories.isEmpty());
|
||||
assertTrue(administrator.messages.stream().anyMatch(message -> message.contains("custody must be resolved")));
|
||||
assertEquals(java.math.BigInteger.ZERO, characters(fixture).inspect(holder.core.record.id())
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow().sheetGeneration());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void changedNativeDestinationAfterCommitRetainsUncertainCustodyWithoutBlindReplay() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var administrator = actor(data, "Admin");
|
||||
var recipient = actor(data, "Recipient");
|
||||
var source = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(administrator.core.id, administrator.player);
|
||||
fixture.players.put(recipient.core.id, recipient.player);
|
||||
var storage = characters(fixture);
|
||||
var original = storage.inspect(source.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
int destination = recipient.core.inventory.firstEmpty();
|
||||
|
||||
fixture.adminCommand.execute(administrator.player, "fstickadmin",
|
||||
new String[]{"character", "recover", original.id().toString(), "Recipient"});
|
||||
drain(fixture, recipient.core.id);
|
||||
click(fixture, administrator, administrator.inventories.getLast(), 2);
|
||||
awaitTask(fixture, recipient.core.id).run();
|
||||
awaitTask(fixture, recipient.core.id).run();
|
||||
characters(fixture).inspect(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
|
||||
recipient.core.inventory.setItem(destination, new org.bukkit.inventory.ItemStack(Material.DIAMOND));
|
||||
awaitTask(fixture, recipient.core.id).run();
|
||||
|
||||
var committed = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data)
|
||||
.load(original.id()).orElseThrow();
|
||||
assertEquals(original.sheetGeneration().add(java.math.BigInteger.ONE), committed.sheetGeneration());
|
||||
assertEquals(Material.DIAMOND, recipient.core.inventory.getItem(destination).getType());
|
||||
assertThrows(java.util.concurrent.CompletionException.class, () -> storage.find(original.id()).join());
|
||||
assertTrue(administrator.messages.stream().anyMatch(message -> message.contains("delivery is uncertain")));
|
||||
fixture.adminCommand.execute(administrator.player, "fstickadmin",
|
||||
new String[]{"character", "recover", original.id().toString(), "Recipient"});
|
||||
drain(fixture, recipient.core.id);
|
||||
assertTrue(administrator.messages.stream().anyMatch(message -> message.contains("custody must be resolved")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void explicitNoTailRecoveryRevivesSameDeathAtFullHealthWithoutReplayingConsequences() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var administrator = actor(data, "Admin");
|
||||
var recipient = actor(data, "Recipient");
|
||||
var source = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(administrator.core.id, administrator.player);
|
||||
fixture.players.put(recipient.core.id, recipient.player);
|
||||
var storage = characters(fixture);
|
||||
var dead = storage.recordDeath(source.core.record.id(), true).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
var oldTail = new games.dmg.fantasystick.items.TailBooks().create(dead);
|
||||
int destination = recipient.core.inventory.firstEmpty();
|
||||
|
||||
fixture.adminCommand.execute(administrator.player, "fstickadmin",
|
||||
new String[]{"character", "recover", dead.id().toString(), "Recipient"});
|
||||
drain(fixture, recipient.core.id);
|
||||
assertFalse(administrator.inventories.isEmpty(), administrator.messages::toString);
|
||||
var preview = administrator.inventories.getLast();
|
||||
assertEquals(Material.BARRIER, preview.getItem(2).getType());
|
||||
assertEquals(Material.TOTEM_OF_UNDYING, preview.getItem(6).getType());
|
||||
click(fixture, administrator, preview, 6);
|
||||
drain(fixture, recipient.core.id);
|
||||
|
||||
var revived = storage.inspect(dead.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertFalse(revived.dead());
|
||||
assertEquals(revived.maximumHealth(), revived.health());
|
||||
assertEquals(dead.id(), revived.id());
|
||||
assertEquals(dead.stats(), revived.stats());
|
||||
assertEquals(dead.coins(), revived.coins());
|
||||
assertEquals(java.math.BigInteger.ZERO, revived.pvpCoinEscrow());
|
||||
assertEquals(dead.deathId(), revived.deathId());
|
||||
assertTrue(new games.dmg.fantasystick.items.CharacterSheets()
|
||||
.authorizes(recipient.core.inventory.getItem(destination), revived));
|
||||
assertEquals(dead.deathId(), new games.dmg.fantasystick.items.TailBooks().identify(oldTail).orElseThrow().deathId());
|
||||
assertTrue(storage.restoreDeathRecordAdministratively(dead.id(), dead.deathId(), revived.maximumHealth())
|
||||
.get(5, TimeUnit.SECONDS).isEmpty(), "The old Tail death authority was consumed by the living state");
|
||||
var laterDeath = storage.recordDeath(revived.id(), false).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertNotEquals(dead.deathId(), laterDeath.deathId());
|
||||
assertTrue(storage.restoreDeathRecordAdministratively(dead.id(), dead.deathId(), revived.maximumHealth())
|
||||
.get(5, TimeUnit.SECONDS).isEmpty(), "An old Tail cannot revive a different later death");
|
||||
assertEquals(laterDeath.deathId(), storage.inspect(dead.id()).get(5, TimeUnit.SECONDS)
|
||||
.orElseThrow().deathId());
|
||||
}
|
||||
}
|
||||
|
||||
private static FantasyStickLifecycleNativeTest.Actor actor(Path data, String name) throws Exception {
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data); actor.accountName = name; return actor;
|
||||
}
|
||||
|
||||
private static void click(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor administrator, org.bukkit.inventory.Inventory inventory, int slot) {
|
||||
var click = clickEvent(administrator, inventory, slot);
|
||||
fixture.dispatcher.accept(click);
|
||||
assertTrue(click.isCancelled());
|
||||
}
|
||||
|
||||
private static InventoryClickEvent clickEvent(FantasyStickLifecycleNativeTest.Actor administrator,
|
||||
org.bukkit.inventory.Inventory inventory, int slot) {
|
||||
var view = view(administrator, inventory);
|
||||
return new InventoryClickEvent(view, InventoryType.SlotType.CONTAINER, slot,
|
||||
org.bukkit.event.inventory.ClickType.LEFT, InventoryAction.PICKUP_ALL);
|
||||
}
|
||||
|
||||
private static void close(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor administrator, org.bukkit.inventory.Inventory inventory) {
|
||||
fixture.dispatcher.accept(new org.bukkit.event.inventory.InventoryCloseEvent(view(administrator, inventory)));
|
||||
}
|
||||
|
||||
private static org.bukkit.inventory.InventoryView view(
|
||||
FantasyStickLifecycleNativeTest.Actor administrator, org.bukkit.inventory.Inventory inventory) {
|
||||
var view = mock(org.bukkit.inventory.InventoryView.class);
|
||||
when(view.getTopInventory()).thenReturn(inventory);
|
||||
when(view.getBottomInventory()).thenReturn(administrator.core.inventory);
|
||||
when(view.getPlayer()).thenReturn(administrator.player);
|
||||
return view;
|
||||
}
|
||||
|
||||
private static Runnable awaitTask(FantasyStickLifecycleNativeTest.Fixture fixture, UUID recipient) throws Exception {
|
||||
for (int pass = 0; pass < 100; pass++) {
|
||||
characters(fixture).inspect(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
|
||||
fixture.receipts().find(recipient).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
revivals(fixture).find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
|
||||
var callback = fixture.tasks.poll();
|
||||
if (callback != null) return callback;
|
||||
Thread.sleep(5);
|
||||
}
|
||||
throw new AssertionError("Expected a deferred recovery callback");
|
||||
}
|
||||
|
||||
private static void drain(FantasyStickLifecycleNativeTest.Fixture fixture, UUID recipient) throws Exception {
|
||||
for (int pass = 0; pass < 40; pass++) {
|
||||
characters(fixture).inspect(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
|
||||
fixture.receipts().find(recipient).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
revivals(fixture).find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
|
||||
Runnable callback;
|
||||
boolean ran = false;
|
||||
while ((callback = fixture.tasks.poll()) != null) { callback.run(); ran = true; }
|
||||
if (!ran) Thread.sleep(5);
|
||||
}
|
||||
}
|
||||
|
||||
private static games.dmg.fantasystick.persistence.CharacterStorage characters(
|
||||
FantasyStickLifecycleNativeTest.Fixture fixture) {
|
||||
return (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
|
||||
}
|
||||
private static games.dmg.fantasystick.persistence.RevivalStore revivals(
|
||||
FantasyStickLifecycleNativeTest.Fixture fixture) {
|
||||
return (games.dmg.fantasystick.persistence.RevivalStore)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "revivals");
|
||||
}
|
||||
private static games.dmg.fantasystick.session.EquipmentSessions sessions(
|
||||
FantasyStickLifecycleNativeTest.Fixture fixture) {
|
||||
return (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ class AdminExperienceNativeTest {
|
||||
var viewer = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
viewer.core.inventory.setItemInOffHand(null); viewer.core.location.setWorld(target.world);
|
||||
f.players.put(viewer.core.id, viewer.player);
|
||||
f.particlesCommand.execute(target.player, "fantasyparticles", new String[]{"all", "off"}); f.tick();
|
||||
PartyBookNativeTest.disableAllParticles(f,target);
|
||||
target.particleCalls.clear(); viewer.particleCalls.clear();
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
var before = storage.find(target.core.record.id()).get().orElseThrow();
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestController;
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestDirectory;
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestState;
|
||||
import games.dmg.fantasystick.dungeon.DungeonDirectory;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
final class AdventureParticipationModeNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void registeredSiteAndDungeonCommandsPersistReportUpdateAndCompleteModes() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "adventure-daily-site-count: 0\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(admin.core.id, admin.player);
|
||||
doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
|
||||
when(admin.world.getUID()).thenReturn(UUID.randomUUID());
|
||||
|
||||
assertEquals(List.of("--mode"), f.command.tabComplete(admin.player, "fantasystick",
|
||||
new String[]{"adventure-site", "place", "camp", "--"}));
|
||||
assertEquals(List.of("individual-only"), f.command.tabComplete(admin.player, "fantasystick",
|
||||
new String[]{"adventure-site", "place", "camp", "--mode", "i"}));
|
||||
var adventures = (AdventureQuestDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,
|
||||
"adventureQuests");
|
||||
f.command.execute(admin.player, "fantasystick", new String[]{"adventure-site", "place", "camp",
|
||||
"--mode", "individual-only", "10", "Solo Camp"});
|
||||
await(f, () -> !adventures.snapshot().sites().isEmpty());
|
||||
var site = adventures.snapshot().sites().getFirst();
|
||||
assertEquals(AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY, site.participationMode());
|
||||
assertTrue(admin.messages.getLast().contains("mode=individual-only"));
|
||||
f.command.execute(admin.player, "fantasystick", new String[]{"adventure-site", "list"});
|
||||
assertTrue(admin.messages.getLast().contains("mode=individual-only"));
|
||||
assertEquals(List.of(site.id().toString()), f.command.tabComplete(admin.player, "fantasystick",
|
||||
new String[]{"adventure-site", "mode", ""}));
|
||||
assertEquals(List.of("party-available", "party-required"), f.command.tabComplete(admin.player,
|
||||
"fantasystick", new String[]{"adventure-site", "mode", site.id().toString(), "party-"}));
|
||||
f.command.execute(admin.player, "fantasystick", new String[]{"adventure-site", "mode",
|
||||
site.id().toString()});
|
||||
assertTrue(admin.messages.getLast().contains("individual-only"));
|
||||
f.command.execute(admin.player, "fantasystick", new String[]{"adventure-site", "mode",
|
||||
site.id().toString(), "party-required"});
|
||||
drain(f);
|
||||
assertEquals(AdventureQuestState.ParticipationMode.PARTY_REQUIRED,
|
||||
adventures.findSite(site.id()).orElseThrow().participationMode());
|
||||
int count = adventures.snapshot().sites().size();
|
||||
f.command.execute(admin.player, "fantasystick", new String[]{"adventure-site", "place", "camp",
|
||||
"--mode", "invalid", "Broken"});
|
||||
assertEquals(count, adventures.snapshot().sites().size());
|
||||
|
||||
assertEquals(List.of("--mode"), f.command.tabComplete(admin.player, "fantasystick",
|
||||
new String[]{"dungeon", "create", "--"}));
|
||||
var dungeons = (DungeonDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "dungeons");
|
||||
f.command.execute(admin.player, "fantasystick", new String[]{"dungeon", "create", "--mode",
|
||||
"party-required", "Mode Vault"});
|
||||
await(f, () -> !dungeons.snapshot().dungeons().isEmpty());
|
||||
var dungeon = dungeons.snapshot().dungeons().getFirst();
|
||||
assertEquals(AdventureQuestState.ParticipationMode.PARTY_REQUIRED, dungeon.participationMode());
|
||||
assertEquals(List.of(dungeon.id().toString()), f.command.tabComplete(admin.player, "fantasystick",
|
||||
new String[]{"dungeon", "mode", ""}));
|
||||
f.command.execute(admin.player, "fantasystick", new String[]{"dungeon", "mode",
|
||||
dungeon.id().toString(), "party-available"});
|
||||
drain(f);
|
||||
assertEquals(AdventureQuestState.ParticipationMode.PARTY_AVAILABLE,
|
||||
dungeons.find(dungeon.id()).orElseThrow().participationMode());
|
||||
f.command.execute(admin.player, "fantasystick", new String[]{"dungeon", "mode",
|
||||
dungeon.id().toString(), "party-required"}); drain(f);
|
||||
var room = dungeons.addRadiusRoom(dungeon.id(), "Boss", 0, 64, 0, 2, 3)
|
||||
.get(5, TimeUnit.SECONDS);
|
||||
dungeons.setBoss(dungeon.id(), room.id(), org.bukkit.entity.EntityType.HUSK, "Keeper",
|
||||
0, 65, 0, 0, 0).get(5, TimeUnit.SECONDS);
|
||||
dungeons.setCompletionReward(dungeon.id(), java.math.BigInteger.ZERO, java.math.BigInteger.ZERO)
|
||||
.get(5, TimeUnit.SECONDS);
|
||||
f.command.execute(admin.player, "fantasystick", new String[]{"dungeon", "enable",
|
||||
dungeon.id().toString()}); drain(f);
|
||||
f.command.execute(admin.player, "fantasystick", new String[]{"dungeon", "open",
|
||||
dungeon.id().toString()}); drain(f);
|
||||
assertEquals(AdventureQuestState.ParticipationMode.PARTY_REQUIRED,
|
||||
adventures.findSite(dungeon.id()).orElseThrow().participationMode());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void questGiverLabelsAndExplicitlyAcceptsPersonalOrPartyForm() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "adventure-daily-site-count: 3\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var leader = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var member = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(leader.core.id, leader.player); f.players.put(member.core.id, member.player);
|
||||
UUID world = UUID.randomUUID(); when(leader.world.getUID()).thenReturn(world);
|
||||
member.core.location.setWorld(leader.world);
|
||||
f.activate(leader); f.activate(member);
|
||||
var parties = PartyLifecycleNativeTest.parties(f);
|
||||
var party = parties.create(leader.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(member.core.id,
|
||||
parties.invite(leader.core.id, party.id(), member.core.id).orElseThrow().id()));
|
||||
var adventures = (AdventureQuestDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,
|
||||
"adventureQuests");
|
||||
var personal = adventures.placeSite(AdventureQuestState.SiteType.CAMP, "Flexible Camp", world,
|
||||
0, 64, 0, null, AdventureQuestState.ParticipationMode.PARTY_AVAILABLE).get(5, TimeUnit.SECONDS);
|
||||
var required = adventures.placeSite(AdventureQuestState.SiteType.CHAMPION, "Party Summit", world,
|
||||
20, 64, 0, null, AdventureQuestState.ParticipationMode.PARTY_REQUIRED).get(5, TimeUnit.SECONDS);
|
||||
var individual = adventures.placeSite(AdventureQuestState.SiteType.CAMP, "Solo Camp", world,
|
||||
40, 64, 0, null, AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY).get(5, TimeUnit.SECONDS);
|
||||
adventures.ensureToday().get(5, TimeUnit.SECONDS);
|
||||
var controller = (AdventureQuestController) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,
|
||||
"adventureQuestController");
|
||||
|
||||
controller.open(member.player); drain(f);
|
||||
var memberMenu = member.inventories.getLast();
|
||||
var personalItem = java.util.stream.IntStream.range(0, memberMenu.getSize())
|
||||
.mapToObj(memberMenu::getItem).filter(java.util.Objects::nonNull)
|
||||
.filter(item -> PlainTextComponentSerializer.plainText().serialize(item.getItemMeta().displayName())
|
||||
.equals("Flexible Camp")).findFirst().orElseThrow();
|
||||
var lore = personalItem.getItemMeta().lore().stream()
|
||||
.map(PlainTextComponentSerializer.plainText()::serialize).toList();
|
||||
assertTrue(lore.contains("Mode: party-available"));
|
||||
assertTrue(lore.stream().anyMatch(line -> line.contains("Left click: Accept individually")));
|
||||
var personalClick = mock(InventoryClickEvent.class);
|
||||
when(personalClick.getWhoClicked()).thenReturn(member.player);
|
||||
when(personalClick.getInventory()).thenReturn(memberMenu);
|
||||
when(personalClick.getCurrentItem()).thenReturn(personalItem);
|
||||
controller.click(personalClick); drain(f);
|
||||
var personalQuest = adventures.activeFor(member.core.record.id(), party.id()).orElseThrow();
|
||||
assertEquals(AdventureQuestState.UnitType.SOLO, personalQuest.unitType());
|
||||
assertEquals(Set.of(member.core.record.id()), personalQuest.snapshot());
|
||||
assertTrue(adventures.abandon(personalQuest.id(), personalQuest.unit()).get(5, TimeUnit.SECONDS));
|
||||
|
||||
controller.open(member.player); drain(f);
|
||||
var memberIndividualMenu = member.inventories.getLast();
|
||||
var individualItem = offer(memberIndividualMenu, "Solo Camp");
|
||||
click(controller, member, memberIndividualMenu, individualItem, false); drain(f);
|
||||
var memberIndividual = adventures.activeFor(member.core.record.id(), party.id()).orElseThrow();
|
||||
assertEquals(individual.id(), memberIndividual.site());
|
||||
assertEquals(AdventureQuestState.UnitType.SOLO, memberIndividual.unitType());
|
||||
assertTrue(adventures.abandon(memberIndividual.id(), memberIndividual.unit()).get(5, TimeUnit.SECONDS));
|
||||
click(controller, member, memberIndividualMenu, individualItem, true);
|
||||
assertTrue(member.messages.getLast().contains("individual-only"));
|
||||
var memberRequiredItem = offer(memberIndividualMenu, "Party Summit");
|
||||
click(controller, member, memberIndividualMenu, memberRequiredItem, false);
|
||||
assertTrue(member.messages.getLast().contains("party-required"));
|
||||
click(controller, member, memberIndividualMenu, memberRequiredItem, true);
|
||||
assertTrue(member.messages.getLast().contains("Only the active party leader"));
|
||||
|
||||
var partyless = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
partyless.core.location.setWorld(leader.world); f.players.put(partyless.core.id, partyless.player);
|
||||
f.activate(partyless); controller.open(partyless.player); drain(f);
|
||||
var partylessMenu = partyless.inventories.getLast();
|
||||
click(controller, partyless, partylessMenu, offer(partylessMenu, "Solo Camp"), false); drain(f);
|
||||
var partylessQuest = adventures.activeFor(partyless.core.record.id(), null).orElseThrow();
|
||||
assertEquals(AdventureQuestState.UnitType.SOLO, partylessQuest.unitType());
|
||||
assertTrue(adventures.abandon(partylessQuest.id(), partylessQuest.unit()).get(5, TimeUnit.SECONDS));
|
||||
var partylessRequired = offer(partylessMenu, "Party Summit");
|
||||
click(controller, partyless, partylessMenu, partylessRequired, false);
|
||||
assertTrue(partyless.messages.getLast().contains("party-required"));
|
||||
click(controller, partyless, partylessMenu, partylessRequired, true);
|
||||
assertTrue(partyless.messages.getLast().contains("Only the active party leader"));
|
||||
|
||||
controller.open(leader.player); drain(f);
|
||||
var leaderIndividualMenu = leader.inventories.getLast();
|
||||
var leaderIndividualItem = offer(leaderIndividualMenu, "Solo Camp");
|
||||
click(controller, leader, leaderIndividualMenu, leaderIndividualItem, false); drain(f);
|
||||
var leaderIndividual = adventures.activeFor(leader.core.record.id(), party.id()).orElseThrow();
|
||||
assertEquals(AdventureQuestState.UnitType.SOLO, leaderIndividual.unitType());
|
||||
assertTrue(adventures.abandon(leaderIndividual.id(), leaderIndividual.unit()).get(5, TimeUnit.SECONDS));
|
||||
click(controller, leader, leaderIndividualMenu, leaderIndividualItem, true);
|
||||
assertTrue(leader.messages.getLast().contains("individual-only"));
|
||||
|
||||
controller.open(leader.player); drain(f);
|
||||
var leaderMenu = leader.inventories.getLast();
|
||||
var requiredItem = java.util.stream.IntStream.range(0, leaderMenu.getSize())
|
||||
.mapToObj(leaderMenu::getItem).filter(java.util.Objects::nonNull)
|
||||
.filter(item -> PlainTextComponentSerializer.plainText().serialize(item.getItemMeta().displayName())
|
||||
.equals("Party Summit")).findFirst().orElseThrow();
|
||||
var requiredLore = requiredItem.getItemMeta().lore().stream()
|
||||
.map(PlainTextComponentSerializer.plainText()::serialize).toList();
|
||||
assertTrue(requiredLore.contains("Mode: party-required"));
|
||||
assertTrue(requiredLore.stream().anyMatch(line -> line.contains("Right click: Accept for party")));
|
||||
var partyClick = mock(InventoryClickEvent.class);
|
||||
when(partyClick.getWhoClicked()).thenReturn(leader.player);
|
||||
when(partyClick.getInventory()).thenReturn(leaderMenu);
|
||||
when(partyClick.getCurrentItem()).thenReturn(requiredItem);
|
||||
when(partyClick.isRightClick()).thenReturn(true);
|
||||
controller.click(partyClick);
|
||||
assertTrue(parties.disband(leader.core.id, party.id()), "party changes before acceptance publication");
|
||||
await(f, () -> leader.messages.stream().anyMatch(message -> message.contains("character or party changed")));
|
||||
assertTrue(adventures.active().isEmpty());
|
||||
|
||||
party = parties.create(leader.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(member.core.id,
|
||||
parties.invite(leader.core.id, party.id(), member.core.id).orElseThrow().id()));
|
||||
controller.click(partyClick);
|
||||
UUID acceptedParty = party.id();
|
||||
await(f, () -> adventures.activeFor(leader.core.record.id(), acceptedParty).isPresent());
|
||||
var partyQuest = adventures.activeFor(leader.core.record.id(), acceptedParty).orElseThrow();
|
||||
assertEquals(AdventureQuestState.UnitType.PARTY, partyQuest.unitType());
|
||||
assertEquals(Set.of(leader.core.record.id(), member.core.record.id()), partyQuest.snapshot());
|
||||
assertEquals(AdventureQuestState.ParticipationMode.PARTY_REQUIRED,
|
||||
partyQuest.participationMode());
|
||||
}
|
||||
}
|
||||
|
||||
private static org.bukkit.inventory.ItemStack offer(org.bukkit.inventory.Inventory menu, String name) {
|
||||
return java.util.stream.IntStream.range(0, menu.getSize()).mapToObj(menu::getItem)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.filter(item -> PlainTextComponentSerializer.plainText().serialize(item.getItemMeta().displayName())
|
||||
.equals(name)).findFirst().orElseThrow();
|
||||
}
|
||||
private static void click(AdventureQuestController controller, FantasyStickLifecycleNativeTest.Actor actor,
|
||||
org.bukkit.inventory.Inventory menu, org.bukkit.inventory.ItemStack item,
|
||||
boolean right) {
|
||||
var event = mock(InventoryClickEvent.class);
|
||||
when(event.getWhoClicked()).thenReturn(actor.player); when(event.getInventory()).thenReturn(menu);
|
||||
when(event.getCurrentItem()).thenReturn(item); when(event.isRightClick()).thenReturn(right);
|
||||
controller.click(event);
|
||||
}
|
||||
private static void drain(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
|
||||
for (int attempt = 0; attempt < 20; attempt++) {
|
||||
var task = f.tasks.poll(50, TimeUnit.MILLISECONDS);
|
||||
if (task == null) break;
|
||||
task.run();
|
||||
}
|
||||
}
|
||||
private static void await(FantasyStickLifecycleNativeTest.Fixture f,
|
||||
java.util.function.BooleanSupplier condition) throws Exception {
|
||||
for (int attempt = 0; attempt < 50 && !condition.getAsBoolean(); attempt++) {
|
||||
drain(f); Thread.sleep(5);
|
||||
}
|
||||
assertTrue(condition.getAsBoolean());
|
||||
}
|
||||
}
|
||||
@@ -123,7 +123,7 @@ class AdventureQuestNativeTest {
|
||||
assertEquals(1,directory.snapshot().sites().size());assertEquals(1,directory.snapshot().givers().size());
|
||||
assertEquals(List.of("camp","champion"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure-site","place","c"}));
|
||||
var current=directory.snapshot();var snapshotField=directory.getClass().getDeclaredField("snapshot");snapshotField.setAccessible(true);snapshotField.set(directory,new games.dmg.fantasystick.adventure.AdventureQuestState(current.sites(),current.givers(),null,current.quests(),current.unitClaims(),current.characterClaims(),current.rosters()));directory.ensureToday().get(5,java.util.concurrent.TimeUnit.SECONDS);
|
||||
var controller=(games.dmg.fantasystick.adventure.AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");controller.reconcile(chunk);
|
||||
var controller=(games.dmg.fantasystick.adventure.AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");var trophies=(games.dmg.fantasystick.adventure.FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");controller.reconcile(chunk);
|
||||
var giver=spawned.stream().filter(Villager.class::isInstance).map(Villager.class::cast).findFirst().orElseThrow();verify(giver,atLeastOnce()).setAI(false);verify(giver,atLeastOnce()).setGravity(false);verify(giver,atLeastOnce()).setInvulnerable(true);verify(giver,atLeastOnce()).setCollidable(false);
|
||||
f.activate(actor);int books=actor.books.size();var interaction=new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.HAND);f.dispatcher.accept(interaction);drainOne(f);assertTrue(interaction.isCancelled());assertEquals(books,actor.books.size());assertFalse(actor.inventories.isEmpty());int menus=actor.inventories.size();var duplicate=new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.OFF_HAND);f.dispatcher.accept(duplicate);assertTrue(duplicate.isCancelled());assertEquals(menus,actor.inventories.size());
|
||||
var menu=actor.inventories.getLast();assertEquals(Material.PAPER,menu.getItem(0).getType());assertFalse(menu.getItem(0).getItemMeta().hasEnchants());assertNull(menu.getItem(0).getItemMeta().getItemModel());assertFalse(menu.getItem(0).getItemMeta().lore().stream().map(Object::toString).anyMatch(line->line.contains("0.0")),"Coordinates stay hidden before acceptance");var originalCatalog=directory.snapshot().catalog();
|
||||
@@ -135,7 +135,7 @@ class AdventureQuestNativeTest {
|
||||
assertTrue(actor.messages.stream().anyMatch(message -> message.equals(
|
||||
"Accepted Dawn Camp; expires " + directory.active().getFirst().expiresAt()
|
||||
+ ". Follow the Quest Compass.")));
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"adventure"});
|
||||
f.playerCommand.execute(actor.player,"fstick",new String[]{"adventure"});
|
||||
assertEquals("Dawn Camp; state=waiting; expires "+directory.active().getFirst().expiresAt()
|
||||
+"; Quest Compass guidance=on.",actor.messages.getLast());
|
||||
assertFalse(actor.messages.getLast().contains("(12, 70, -8)"));assertFalse(actor.messages.getLast().contains(worldId.toString()));
|
||||
@@ -145,17 +145,17 @@ class AdventureQuestNativeTest {
|
||||
|
||||
for(Runnable pulse:List.copyOf(f.pulses))pulse.run();for(int retry=0;retry<10&&spawned.stream().filter(entity->!(entity instanceof Villager)).count()<4;retry++)drainOne(f);
|
||||
var enemies=spawned.stream().filter(entity->!(entity instanceof Villager)).toList();assertEquals(4,enemies.size());assertTrue(controller.hasActiveEncounter());
|
||||
var enemy=enemies.getFirst();when(enemy.getLocation()).thenReturn(new Location(actor.world,siteRecord.x()+11,siteRecord.y(),siteRecord.z()));when(actor.world.getEntities()).thenReturn(new ArrayList<Entity>(spawned));for(Runnable pulse:List.copyOf(f.pulses))pulse.run();verify(enemy).teleport(argThat((Location location)->location.getX()==siteRecord.x()&&location.getY()==siteRecord.y()&&location.getZ()==siteRecord.z()));
|
||||
var enemy=enemies.getFirst();assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CAMP,controller.encounterOrigin(enemy).orElseThrow().kind());when(enemy.getLocation()).thenReturn(new Location(actor.world,siteRecord.x()+11,siteRecord.y(),siteRecord.z()));when(actor.world.getEntities()).thenReturn(new ArrayList<Entity>(spawned));for(Runnable pulse:List.copyOf(f.pulses))pulse.run();verify(enemy).teleport(argThat((Location location)->location.getX()==siteRecord.x()&&location.getY()==siteRecord.y()&&location.getZ()==siteRecord.z()));
|
||||
var outsider=new FantasyStickLifecycleNativeTest.Actor(data);outsider.core.location.setWorld(actor.world);f.players.put(outsider.core.id,outsider.player);var outsiderSource=mock(org.bukkit.damage.DamageSource.class);when(outsiderSource.getCausingEntity()).thenReturn(outsider.player);when(outsiderSource.getDirectEntity()).thenReturn(outsider.player);var refusedHit=new org.bukkit.event.entity.EntityDamageByEntityEvent(outsider.player,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,outsiderSource,1);f.dispatcher.accept(refusedHit);assertTrue(refusedHit.isCancelled());var enemySource=mock(org.bukkit.damage.DamageSource.class);when(enemySource.getCausingEntity()).thenReturn(enemy);when(enemySource.getDirectEntity()).thenReturn(enemy);var refusedAttack=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,outsider.player,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,enemySource,1);f.dispatcher.accept(refusedAttack);assertTrue(refusedAttack.isCancelled());var targetEvent=new org.bukkit.event.entity.EntityTargetLivingEntityEvent(enemy,outsider.player,org.bukkit.event.entity.EntityTargetEvent.TargetReason.CLOSEST_PLAYER);f.dispatcher.accept(targetEvent);assertTrue(targetEvent.isCancelled());
|
||||
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);
|
||||
var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());
|
||||
var attemptsField=controller.getClass().getDeclaredField("attempts");attemptsField.setAccessible(true);var attempts=(Map<?,?>)attemptsField.get(controller);assertEquals(1,attempts.size());var attempt=attempts.values().iterator().next();var representedField=attempt.getClass().getDeclaredField("represented");representedField.setAccessible(true);assertEquals(1,((Set<?>)representedField.get(attempt)).size());var recipientsMethod=controller.getClass().getDeclaredMethod("recipients",games.dmg.fantasystick.adventure.AdventureQuestState.Quest.class,UUID.class);recipientsMethod.setAccessible(true);var activeQuest=directory.active().getFirst();assertEquals(List.of(actor.core.record.id()),recipientsMethod.invoke(controller,activeQuest,activeQuest.site()));
|
||||
for(var value:enemies){var death=new org.bukkit.event.entity.EntityDeathEvent(value,source,new ArrayList<>(),7);f.dispatcher.accept(death);assertEquals(0,death.getDroppedExp());assertTrue(death.getDrops().isEmpty());}
|
||||
for(var value:enemies){var death=new org.bukkit.event.entity.EntityDeathEvent(value,source,new ArrayList<>(),7);f.dispatcher.accept(death);assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CAMP,controller.encounterOrigin(value).orElseThrow().kind(),"each processed death retains immutable managed origin authority");assertEquals(0,death.getDroppedExp());assertTrue(death.getDrops().isEmpty());}
|
||||
assertTrue(((Map<?,?>)attemptsField.get(controller)).isEmpty());
|
||||
for(int i=0;i<50&&(directory.active().size()!=1||directory.unclaimed(activeQuest.catalog(),activeQuest.site(),actor.core.record.id()).xp().signum()==0||actor.core.inventory.getItem(0).getType()!=Material.BOOK);i++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}
|
||||
var banked=storage.find(actor.core.record.id()).get().orElseThrow();assertEquals(java.math.BigInteger.ZERO,banked.experience());assertEquals(actorCoins,banked.coins());assertEquals(new games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward(java.math.BigInteger.valueOf(100),java.math.BigInteger.valueOf(20)),directory.unclaimed(activeQuest.catalog(),activeQuest.site(),actor.core.record.id()));assertEquals(Material.BOOK,actor.core.inventory.getItem(0).getType(),"completion restores the US-069 book control");assertEquals(1,directory.active().size(),"Unrepresented party retains its quest");assertEquals(java.math.BigInteger.ZERO,storage.find(partyA.core.record.id()).get().orElseThrow().experience());assertEquals(java.math.BigInteger.ZERO,storage.find(partyB.core.record.id()).get().orElseThrow().experience());
|
||||
int rewardMenuCount=actor.inventories.size();controller.open(actor.player);for(int retry=0;retry<10&&actor.inventories.size()==rewardMenuCount;retry++)drainOne(f);var rewardsMenu=actor.inventories.getLast();assertEquals(Material.PAPER,rewardsMenu.getItem(0).getType());assertTrue(rewardsMenu.getItem(0).getItemMeta().lore().stream().map(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText()::serialize).anyMatch(line->line.contains("Rewards ready")),rewardsMenu.getItem(0).getItemMeta().lore().toString());var rewardClick=mock(InventoryClickEvent.class);when(rewardClick.getWhoClicked()).thenReturn(actor.player);when(rewardClick.getInventory()).thenReturn(rewardsMenu);when(rewardClick.getCurrentItem()).thenReturn(rewardsMenu.getItem(0));controller.click(rewardClick);for(int i=0;i<50&&directory.unclaimed(activeQuest.catalog(),activeQuest.site(),actor.core.record.id()).xp().signum()>0;i++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}var saved=storage.find(actor.core.record.id()).get().orElseThrow();assertEquals(java.math.BigInteger.valueOf(100),saved.experience());assertEquals(actorCoins.add(java.math.BigInteger.valueOf(20)),saved.coins());assertTrue(directory.rewardClaimed(activeQuest.catalog(),activeQuest.site(),actor.core.record.id()),"durable reward receipt missing");
|
||||
int priorEnemies=enemies.size();for(Runnable pulse:List.copyOf(f.pulses))pulse.run();for(int retry=0;retry<10&&spawned.stream().filter(entity->!(entity instanceof Villager)).count()<priorEnemies+4;retry++)drainOne(f);var second=spawned.stream().filter(entity->!(entity instanceof Villager)).skip(priorEnemies).toList();assertEquals(4,second.size());var partySource=mock(org.bukkit.damage.DamageSource.class);when(partySource.getCausingEntity()).thenReturn(partyA.player);when(partySource.getDirectEntity()).thenReturn(partyA.player);var partyHit=new org.bukkit.event.entity.EntityDamageByEntityEvent(partyA.player,second.getFirst(),org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1);f.dispatcher.accept(partyHit);for(var value:second)f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(value,partySource,new ArrayList<>(),0));for(int i=0;i<50&&!directory.active().isEmpty();i++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}var savedA=storage.find(partyA.core.record.id()).get().orElseThrow();var savedB=storage.find(partyB.core.record.id()).get().orElseThrow();assertEquals(java.math.BigInteger.ZERO,savedA.experience());assertEquals(java.math.BigInteger.ZERO,savedB.experience());var pendingA=directory.unclaimed(catalog.revision(),siteId,partyA.core.record.id());var pendingB=directory.unclaimed(catalog.revision(),siteId,partyB.core.record.id());assertEquals(java.math.BigInteger.valueOf(100),pendingA.xp().add(pendingB.xp()));assertTrue(pendingA.xp().signum()>0&&pendingB.xp().signum()>0,"Present non-attacker receives an independent banked share");assertEquals(java.math.BigInteger.valueOf(20),pendingA.coins().add(pendingB.coins()));assertTrue(directory.active().isEmpty());for(var member:List.of(partyA,partyB)){int memberMenuCount=member.inventories.size();controller.open(member.player);for(int retry=0;retry<10&&member.inventories.size()==memberMenuCount;retry++)drainOne(f);var memberMenu=member.inventories.getLast();assertEquals(Material.PAPER,memberMenu.getItem(0).getType());var memberClick=mock(InventoryClickEvent.class);when(memberClick.getWhoClicked()).thenReturn(member.player);when(memberClick.getInventory()).thenReturn(memberMenu);when(memberClick.getCurrentItem()).thenReturn(memberMenu.getItem(0));controller.click(memberClick);for(int retry=0;retry<50&&directory.unclaimed(catalog.revision(),siteId,member.core.record.id()).xp().signum()>0;retry++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}}savedA=storage.find(partyA.core.record.id()).get().orElseThrow();savedB=storage.find(partyB.core.record.id()).get().orElseThrow();assertEquals(java.math.BigInteger.valueOf(100),savedA.experience().add(savedB.experience()));assertEquals(java.math.BigInteger.valueOf(20),savedA.coins().subtract(partyACoins).add(savedB.coins().subtract(partyBCoins)));var completedCatalog=directory.snapshot().catalog();assertFalse(directory.snapshot().rosters().isEmpty());f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll","confirm"});for(int retry=0;retry<15&&(directory.snapshot().catalog().revision().equals(completedCatalog.revision())||directory.snapshot().reset()!=null);retry++)drainOne(f);var replacement=directory.snapshot().catalog();assertNotEquals(completedCatalog.revision(),replacement.revision());assertTrue(directory.snapshot().rosters().isEmpty());assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Acceptance.APPLIED,directory.accept(replacement.revision(),replacement.sites().getFirst(),games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,actor.core.record.id(),Set.of(actor.core.record.id())).get(5,java.util.concurrent.TimeUnit.SECONDS).status());
|
||||
for(int i=0;i<50&&(directory.active().size()!=1||trophies.trophies(actor.core.record.id()).isEmpty()||actor.core.inventory.getItem(0).getType()!=Material.BOOK);i++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}
|
||||
var banked=storage.find(actor.core.record.id()).get().orElseThrow();assertEquals(java.math.BigInteger.ZERO,banked.experience());assertEquals(actorCoins,banked.coins());assertEquals(games.dmg.fantasystick.adventure.FishingState.TrophyType.CAMP,trophies.trophies(actor.core.record.id()).getFirst().type());assertEquals(Material.BOOK,actor.core.inventory.getItem(0).getType(),"completion restores the US-069 book control");assertEquals(1,directory.active().size(),"Unrepresented party retains its quest");assertEquals(java.math.BigInteger.ZERO,storage.find(partyA.core.record.id()).get().orElseThrow().experience());assertEquals(java.math.BigInteger.ZERO,storage.find(partyB.core.record.id()).get().orElseThrow().experience());
|
||||
var soloTrophy=trophies.trophies(actor.core.record.id()).getFirst();assertEquals("Spoils of Dawn Camp",soloTrophy.name());assertEquals("Dawn Camp",soloTrophy.origin());assertEquals(new games.dmg.fantasystick.adventure.FishingState.Reward(java.math.BigInteger.valueOf(100),java.math.BigInteger.valueOf(20)),soloTrophy.total());assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward.empty(),directory.unclaimedAll(actor.core.record.id()),"camp clear does not immediately unlock rewards");
|
||||
int priorEnemies=enemies.size();for(Runnable pulse:List.copyOf(f.pulses))pulse.run();for(int retry=0;retry<10&&spawned.stream().filter(entity->!(entity instanceof Villager)).count()<priorEnemies+4;retry++)drainOne(f);var second=spawned.stream().filter(entity->!(entity instanceof Villager)).skip(priorEnemies).toList();assertEquals(4,second.size());var partySource=mock(org.bukkit.damage.DamageSource.class);when(partySource.getCausingEntity()).thenReturn(partyA.player);when(partySource.getDirectEntity()).thenReturn(partyA.player);var partyHit=new org.bukkit.event.entity.EntityDamageByEntityEvent(partyA.player,second.getFirst(),org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1);f.dispatcher.accept(partyHit);partyA.core.location.setX(100);doReturn(false).when(partyA.player).isOnline();for(var value:second)f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(value,partySource,new ArrayList<>(),0));await(f,()->directory.active().isEmpty()&&!trophies.trophies(partyA.core.record.id()).isEmpty());var partyTrophy=trophies.trophies(partyA.core.record.id()).getFirst();assertEquals(games.dmg.fantasystick.adventure.FishingState.TrophyType.CAMP,partyTrophy.type());assertEquals(Set.of(partyB.core.record.id()),partyTrophy.rewards().keySet(),"absent leader carries the spoils without gaining a share");assertEquals(new games.dmg.fantasystick.adventure.FishingState.Reward(java.math.BigInteger.valueOf(100),java.math.BigInteger.valueOf(20)),partyTrophy.total());assertEquals(java.math.BigInteger.ZERO,storage.find(partyA.core.record.id()).get().orElseThrow().experience());assertEquals(java.math.BigInteger.ZERO,storage.find(partyB.core.record.id()).get().orElseThrow().experience());var completedCatalog=directory.snapshot().catalog();assertFalse(directory.snapshot().rosters().isEmpty());f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll","confirm"});for(int retry=0;retry<15&&(directory.snapshot().catalog().revision().equals(completedCatalog.revision())||directory.snapshot().reset()!=null);retry++)drainOne(f);var replacement=directory.snapshot().catalog();assertNotEquals(completedCatalog.revision(),replacement.revision());assertTrue(directory.snapshot().rosters().isEmpty());var turn=(games.dmg.fantasystick.adventure.FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");redeemAndClaim(f,turn,trophies,storage,actor,actorCoins,100,20);doReturn(true).when(partyA.player).isOnline();redeem(f,turn,partyA);claim(f,turn,trophies,storage,partyB,partyBCoins,100,20);assertEquals(partyACoins,storage.find(partyA.core.record.id()).get().orElseThrow().coins(),"carrier without a share receives no coins");assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Acceptance.APPLIED,directory.accept(replacement.revision(),replacement.sites().getFirst(),games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,actor.core.record.id(),Set.of(actor.core.record.id())).get(5,java.util.concurrent.TimeUnit.SECONDS).status());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,13 +394,13 @@ class AdventureQuestNativeTest {
|
||||
quest.unit().toString(), actor.core.record.id().toString())) {
|
||||
assertFalse(accepted.contains(hidden));
|
||||
}
|
||||
f.command.execute(actor.player, "fantasystick", new String[]{"adventure"});
|
||||
f.playerCommand.execute(actor.player, "fstick", new String[]{"adventure"});
|
||||
String inspected = actor.messages.getLast();
|
||||
assertEquals("Monster Camp; state=waiting; expires "+quest.expiresAt()+"; Quest Compass guidance=on.",inspected);
|
||||
for(String hidden:List.of("(12, -1, -9)",worldId.toString(),site.id().toString(),quest.id().toString()))assertFalse(inspected.contains(hidden));
|
||||
|
||||
when(actor.world.getUID()).thenReturn(UUID.randomUUID());
|
||||
f.command.execute(actor.player, "fantasystick", new String[]{"adventure"});
|
||||
f.playerCommand.execute(actor.player, "fstick", new String[]{"adventure"});
|
||||
assertEquals("Monster Camp; state=waiting; expires "+quest.expiresAt()+"; Quest Compass guidance=on.",actor.messages.getLast());
|
||||
assertFalse(actor.messages.getLast().contains(worldId.toString()));
|
||||
f.command.execute(actor.player, "fantasystick", new String[]{"adventure-site", "list"});
|
||||
@@ -461,15 +461,15 @@ class AdventureQuestNativeTest {
|
||||
characters.find(UUID.randomUUID()).get(5,java.util.concurrent.TimeUnit.SECONDS);for(int retry=0;retry<10&&actor.books.isEmpty();retry++)f.tick();assertEquals(1,actor.books.size());
|
||||
|
||||
doReturn(false).when(actor.player).hasPermission("fantasystick.admin");
|
||||
assertEquals(List.of("tracking"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure","t"}));
|
||||
assertEquals(List.of("off","on"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure","tracking",""}));
|
||||
assertTrue(f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure","tracking","on","extra"}).isEmpty());
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"adventure","tracking"});
|
||||
assertEquals(List.of("tracking"),f.playerCommand.tabComplete(actor.player,"fstick",new String[]{"adventure","t"}));
|
||||
assertEquals(List.of("off","on"),f.playerCommand.tabComplete(actor.player,"fstick",new String[]{"adventure","tracking",""}));
|
||||
assertTrue(f.playerCommand.tabComplete(actor.player,"fstick",new String[]{"adventure","tracking","on","extra"}).isEmpty());
|
||||
f.playerCommand.execute(actor.player,"fstick",new String[]{"adventure","tracking"});
|
||||
assertTrue(actor.messages.getLast().contains("on"));
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"adventure","tracking","off"});for(int retry=0;retry<10&&actor.core.inventory.getItem(0).getType()!=Material.BOOK;retry++)drainOne(f);
|
||||
f.playerCommand.execute(actor.player,"fstick",new String[]{"adventure","tracking","off"});for(int retry=0;retry<10&&actor.core.inventory.getItem(0).getType()!=Material.BOOK;retry++)drainOne(f);
|
||||
assertEquals(Material.BOOK,actor.core.inventory.getItem(0).getType());
|
||||
assertFalse(directory.trackingEnabled(quest.id(),actor.core.record.id()));
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"adventure","tracking","on"});for(int retry=0;retry<10&&actor.core.inventory.getItem(0).getType()!=Material.COMPASS;retry++)drainOne(f);
|
||||
f.playerCommand.execute(actor.player,"fstick",new String[]{"adventure","tracking","on"});for(int retry=0;retry<10&&actor.core.inventory.getItem(0).getType()!=Material.COMPASS;retry++)drainOne(f);
|
||||
assertEquals(Material.COMPASS,actor.core.inventory.getItem(0).getType());
|
||||
|
||||
actor.core.location.setX(site.x());actor.core.location.setY(site.y());actor.core.location.setZ(site.z());
|
||||
@@ -533,5 +533,11 @@ class AdventureQuestNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static void redeemAndClaim(FantasyStickLifecycleNativeTest.Fixture f,games.dmg.fantasystick.adventure.FishingController turn,games.dmg.fantasystick.adventure.FishingDirectory trophies,games.dmg.fantasystick.persistence.CharacterStorage storage,FantasyStickLifecycleNativeTest.Actor actor,java.math.BigInteger initialCoins,long xp,long coins)throws Exception{redeem(f,turn,actor);claim(f,turn,trophies,storage,actor,initialCoins,xp,coins);}
|
||||
private static void redeem(FantasyStickLifecycleNativeTest.Fixture f,games.dmg.fantasystick.adventure.FishingController turn,FantasyStickLifecycleNativeTest.Actor actor)throws Exception{turn.open(actor.player);var menu=actor.inventories.getLast();click(turn,actor,menu,item(menu,"Redeem Spoils of"));await(f,()->actor.inventories.size()>0&&menu!=actor.inventories.getLast());}
|
||||
private static void claim(FantasyStickLifecycleNativeTest.Fixture f,games.dmg.fantasystick.adventure.FishingController turn,games.dmg.fantasystick.adventure.FishingDirectory trophies,games.dmg.fantasystick.persistence.CharacterStorage storage,FantasyStickLifecycleNativeTest.Actor actor,java.math.BigInteger initialCoins,long xp,long coins)throws Exception{turn.open(actor.player);var menu=actor.inventories.getLast();click(turn,actor,menu,item(menu,"Claim earned quest rewards"));await(f,()->trophies.unclaimed(actor.core.record.id()).equals(games.dmg.fantasystick.adventure.FishingState.Reward.empty()));var saved=storage.find(actor.core.record.id()).get().orElseThrow();assertEquals(java.math.BigInteger.valueOf(xp),saved.experience());assertEquals(initialCoins.add(java.math.BigInteger.valueOf(coins)),saved.coins());}
|
||||
private static org.bukkit.inventory.ItemStack item(org.bukkit.inventory.Inventory menu,String prefix){var plain=net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText();return java.util.stream.IntStream.range(0,menu.getSize()).mapToObj(menu::getItem).filter(java.util.Objects::nonNull).filter(value->value.getItemMeta().displayName()!=null&&plain.serialize(value.getItemMeta().displayName()).startsWith(prefix)).findFirst().orElseThrow();}
|
||||
private static void click(games.dmg.fantasystick.adventure.FishingController turn,FantasyStickLifecycleNativeTest.Actor actor,org.bukkit.inventory.Inventory menu,org.bukkit.inventory.ItemStack item){var event=mock(InventoryClickEvent.class);when(event.getWhoClicked()).thenReturn(actor.player);when(event.getInventory()).thenReturn(menu);when(event.getCurrentItem()).thenReturn(item);turn.click(event);}
|
||||
private static void await(FantasyStickLifecycleNativeTest.Fixture f,java.util.function.BooleanSupplier condition)throws Exception{for(int retry=0;retry<80&&!condition.getAsBoolean();retry++){var task=f.tasks.poll(20,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(condition.getAsBoolean());}
|
||||
private static void drainOne(FantasyStickLifecycleNativeTest.Fixture f)throws Exception{var task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ class ChainLightningNativeTest {
|
||||
var viewer = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
viewer.core.inventory.setItemInOffHand(null); viewer.core.location.setWorld(caster.world);
|
||||
fixture.players.put(viewer.core.id, viewer.player);
|
||||
fixture.particlesCommand.execute(caster.player, "fantasyparticles", new String[]{"all", "off"}); PartyBookNativeTest.drain(fixture);
|
||||
PartyBookNativeTest.disableAllParticles(fixture,caster);
|
||||
caster.particleCalls.clear(); viewer.particleCalls.clear();
|
||||
var damage = new LinkedHashMap<UUID, Double>(); nativeDamage(fixture, first, damage); nativeDamage(fixture, second, damage);
|
||||
use(fixture, caster); drain(fixture, 2);
|
||||
|
||||
@@ -37,12 +37,19 @@ class ChampionQuestNativeTest {
|
||||
assertEquals(List.of("champion"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure-site","place","ch"}));
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"config","adventure-champion-damage","9"});drainOne(f);assertEquals(List.of("8","9"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","adventure-champion-damage",""}));
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","place","champion","20","60","Summit"});drainOne(f);f.command.execute(actor.player,"fantasystick",new String[]{"quest-giver","place","Guide"});drainOne(f);
|
||||
var directory=(games.dmg.fantasystick.adventure.AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");var current=directory.snapshot();var snapshotField=directory.getClass().getDeclaredField("snapshot");snapshotField.setAccessible(true);snapshotField.set(directory,new games.dmg.fantasystick.adventure.AdventureQuestState(current.sites(),current.givers(),null,current.quests(),current.unitClaims(),current.characterClaims(),current.rosters()));directory.ensureToday().get(5,java.util.concurrent.TimeUnit.SECONDS);
|
||||
var controller=(games.dmg.fantasystick.adventure.AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");controller.reconcile(chunk);var giver=spawned.stream().filter(Villager.class::isInstance).map(Villager.class::cast).findFirst().orElseThrow();f.activate(actor);var member=new FantasyStickLifecycleNativeTest.Actor(data);member.core.location.setWorld(actor.world);f.players.put(member.core.id,member.player);f.activate(member);var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(actor.core.id).orElseThrow();assertTrue(parties.accept(member.core.id,parties.invite(actor.core.id,party.id(),member.core.id).orElseThrow().id()));f.dispatcher.accept(new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.HAND));drainOne(f);var menu=actor.inventories.getLast();assertEquals(Material.PAPER,menu.getItem(0).getType());var lore=menu.getItem(0).getItemMeta().lore().toString();assertTrue(lore.contains("Champion"));assertTrue(lore.contains("150 XP, 40 coins"));var click=mock(InventoryClickEvent.class);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getInventory()).thenReturn(menu);when(click.getCurrentItem()).thenReturn(menu.getItem(0));controller.click(click);drainOne(f);
|
||||
var solo=new FantasyStickLifecycleNativeTest.Actor(data);solo.core.location.setWorld(actor.world);f.players.put(solo.core.id,solo.player);f.activate(solo);var catalog=directory.snapshot().catalog();var site=catalog.sites().getFirst();assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Acceptance.APPLIED,directory.accept(catalog.revision(),site,games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,solo.core.record.id(),Set.of(solo.core.record.id())).get(5,java.util.concurrent.TimeUnit.SECONDS).status());var siteRecord=directory.findSite(site).orElseThrow();assertEquals(games.dmg.fantasystick.adventure.AdventureQuestState.Arena.rectangle(20,60),siteRecord.arena());actor.core.location.setZ(siteRecord.z()+25);member.core.location.setZ(siteRecord.z()+25);solo.core.location.setZ(siteRecord.z()+25);var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var initial=storage.find(actor.core.record.id()).get().orElseThrow();var memberInitial=storage.find(member.core.record.id()).get().orElseThrow();var soloInitial=storage.find(solo.core.record.id()).get().orElseThrow();for(Runnable pulse:List.copyOf(f.pulses))pulse.run();var champion=spawned.stream().filter(Vindicator.class::isInstance).map(Vindicator.class::cast).findFirst().orElseThrow();verify(maxHealth).setBaseValue(100);verify(attack).setBaseValue(9);verify(champion).setHealth(100);verify(champion).customName(argThat(component->component.toString().contains("Daybreak Champion")));assertEquals(1,f.bossBars.size());verify(f.bossBars.getFirst()).addPlayer(actor.player);verify(f.bossBars.getFirst()).addPlayer(member.player);verify(f.bossBars.getFirst()).addPlayer(solo.player);assertFalse(actor.particleCalls.isEmpty());assertFalse(member.particleCalls.isEmpty());assertFalse(solo.particleCalls.isEmpty());int memberParticles=member.particleCalls.size();member.core.location.setX(100);for(Runnable pulse:List.copyOf(f.pulses))pulse.run();verify(f.bossBars.getFirst()).removePlayer(member.player);assertEquals(memberParticles,member.particleCalls.size());member.core.location.setX(siteRecord.x());for(Runnable pulse:List.copyOf(f.pulses))pulse.run();verify(f.bossBars.getFirst(),atLeast(2)).addPlayer(member.player);f.command.execute(actor.player,"fantasystick",new String[]{"config","adventure-champion-health","120"});drainOne(f);f.command.execute(actor.player,"fantasystick",new String[]{"config","adventure-champion-damage","10"});drainOne(f);var liveSettings=(games.dmg.fantasystick.persistence.AdventureQuestSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestSettings");for(int retry=0;retry<10&&liveSettings.rules().championDamage()!=10;retry++)drainOne(f);verify(maxHealth,times(1)).setBaseValue(100);verify(attack,times(1)).setBaseValue(9);
|
||||
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);var cancelled=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,champion,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);cancelled.setCancelled(true);f.dispatcher.accept(cancelled);verify(f.bossBars.getFirst(),never()).setProgress(0.99);var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,champion,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());verify(f.bossBars.getFirst()).setProgress(0.99);var death=new org.bukkit.event.entity.EntityDeathEvent(champion,source,new ArrayList<>(),9);f.dispatcher.accept(death);assertEquals(0,death.getDroppedExp());assertTrue(death.getDrops().isEmpty());for(int i=0;i<50&&(directory.active().size()!=1||directory.unclaimed(catalog.revision(),site,actor.core.record.id()).xp().add(directory.unclaimed(catalog.revision(),site,member.core.record.id()).xp()).signum()==0);i++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}var saved=storage.find(actor.core.record.id()).get().orElseThrow();var savedMember=storage.find(member.core.record.id()).get().orElseThrow();assertEquals(initial.experience(),saved.experience());assertEquals(memberInitial.experience(),savedMember.experience());var partyRewardA=directory.unclaimed(catalog.revision(),site,actor.core.record.id());var partyRewardB=directory.unclaimed(catalog.revision(),site,member.core.record.id());assertEquals(java.math.BigInteger.valueOf(150),partyRewardA.xp().add(partyRewardB.xp()));assertEquals(java.math.BigInteger.valueOf(40),partyRewardA.coins().add(partyRewardB.coins()));assertEquals(1,directory.active().size(),"Unrepresented solo quest remains");verify(f.bossBars.getFirst(),atLeastOnce()).removeAll();int memberParticlesAfterCompletion=member.particleCalls.size();
|
||||
for(Runnable pulse:List.copyOf(f.pulses))pulse.run();assertEquals(memberParticlesAfterCompletion,member.particleCalls.size(),"completed party members stop receiving the shared boundary");var second=spawned.stream().filter(Vindicator.class::isInstance).map(Vindicator.class::cast).skip(1).findFirst().orElseThrow();assertEquals(2,f.bossBars.size());verify(maxHealth).setBaseValue(120);verify(attack).setBaseValue(10);verify(second).setHealth(120);var soloSource=mock(org.bukkit.damage.DamageSource.class);when(soloSource.getCausingEntity()).thenReturn(solo.player);when(soloSource.getDirectEntity()).thenReturn(solo.player);f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(solo.player,second,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,soloSource,1));f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(second,soloSource,new ArrayList<>(),0));for(int i=0;i<50&&!directory.active().isEmpty();i++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}var savedSolo=storage.find(solo.core.record.id()).get().orElseThrow();assertEquals(soloInitial.experience(),savedSolo.experience());assertEquals(soloInitial.coins(),savedSolo.coins());assertEquals(new games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward(java.math.BigInteger.valueOf(150),java.math.BigInteger.valueOf(40)),directory.unclaimed(catalog.revision(),site,solo.core.record.id()));verify(f.bossBars.get(1),atLeastOnce()).removeAll();
|
||||
var directory=(games.dmg.fantasystick.adventure.AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");var trophies=(games.dmg.fantasystick.adventure.FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");var current=directory.snapshot();var snapshotField=directory.getClass().getDeclaredField("snapshot");snapshotField.setAccessible(true);snapshotField.set(directory,new games.dmg.fantasystick.adventure.AdventureQuestState(current.sites(),current.givers(),null,current.quests(),current.unitClaims(),current.characterClaims(),current.rosters()));directory.ensureToday().get(5,java.util.concurrent.TimeUnit.SECONDS);
|
||||
var controller=(games.dmg.fantasystick.adventure.AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");controller.reconcile(chunk);var giver=spawned.stream().filter(Villager.class::isInstance).map(Villager.class::cast).findFirst().orElseThrow();f.activate(actor);var member=new FantasyStickLifecycleNativeTest.Actor(data);member.core.location.setWorld(actor.world);f.players.put(member.core.id,member.player);f.activate(member);var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(actor.core.id).orElseThrow();assertTrue(parties.accept(member.core.id,parties.invite(actor.core.id,party.id(),member.core.id).orElseThrow().id()));f.dispatcher.accept(new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.HAND));drainOne(f);var menu=actor.inventories.getLast();assertEquals(Material.PAPER,menu.getItem(0).getType());var lore=menu.getItem(0).getItemMeta().lore().toString();assertTrue(lore.contains("Champion"));assertTrue(lore.contains("150 XP, 40 coins"));var click=mock(InventoryClickEvent.class);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getInventory()).thenReturn(menu);when(click.getCurrentItem()).thenReturn(menu.getItem(0));when(click.isRightClick()).thenReturn(true);controller.click(click);drainOne(f);
|
||||
var solo=new FantasyStickLifecycleNativeTest.Actor(data);solo.core.location.setWorld(actor.world);f.players.put(solo.core.id,solo.player);f.activate(solo);var catalog=directory.snapshot().catalog();var site=catalog.sites().getFirst();assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Acceptance.APPLIED,directory.accept(catalog.revision(),site,games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,solo.core.record.id(),Set.of(solo.core.record.id())).get(5,java.util.concurrent.TimeUnit.SECONDS).status());var soloQuest=directory.activeFor(solo.core.record.id(),null).orElseThrow();assertTrue(directory.bank(soloQuest.id(),UUID.randomUUID(),Map.of(solo.core.record.id(),new games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward(java.math.BigInteger.valueOf(7),java.math.BigInteger.valueOf(3)))).get(5,java.util.concurrent.TimeUnit.SECONDS));var siteRecord=directory.findSite(site).orElseThrow();assertEquals(games.dmg.fantasystick.adventure.AdventureQuestState.Arena.rectangle(20,60),siteRecord.arena());actor.core.location.setZ(siteRecord.z()+25);member.core.location.setZ(siteRecord.z()+25);solo.core.location.setZ(siteRecord.z()+25);var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var initial=storage.find(actor.core.record.id()).get().orElseThrow();var memberInitial=storage.find(member.core.record.id()).get().orElseThrow();var soloInitial=storage.find(solo.core.record.id()).get().orElseThrow();for(Runnable pulse:List.copyOf(f.pulses))pulse.run();var champion=spawned.stream().filter(Vindicator.class::isInstance).map(Vindicator.class::cast).findFirst().orElseThrow();assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CHAMPION,controller.encounterOrigin(champion).orElseThrow().kind());verify(maxHealth).setBaseValue(100);verify(attack).setBaseValue(9);verify(champion).setHealth(100);verify(champion).customName(argThat(component->component.toString().contains("Daybreak Champion")));assertEquals(1,f.bossBars.size());verify(f.bossBars.getFirst()).addPlayer(actor.player);verify(f.bossBars.getFirst()).addPlayer(member.player);verify(f.bossBars.getFirst()).addPlayer(solo.player);assertFalse(actor.particleCalls.isEmpty());assertFalse(member.particleCalls.isEmpty());assertFalse(solo.particleCalls.isEmpty());int memberParticles=member.particleCalls.size();member.core.location.setX(100);for(Runnable pulse:List.copyOf(f.pulses))pulse.run();verify(f.bossBars.getFirst()).removePlayer(member.player);assertEquals(memberParticles,member.particleCalls.size());member.core.location.setX(siteRecord.x());for(Runnable pulse:List.copyOf(f.pulses))pulse.run();verify(f.bossBars.getFirst(),atLeast(2)).addPlayer(member.player);f.command.execute(actor.player,"fantasystick",new String[]{"config","adventure-champion-health","120"});drainOne(f);f.command.execute(actor.player,"fantasystick",new String[]{"config","adventure-champion-damage","10"});drainOne(f);var liveSettings=(games.dmg.fantasystick.persistence.AdventureQuestSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestSettings");for(int retry=0;retry<10&&liveSettings.rules().championDamage()!=10;retry++)drainOne(f);verify(maxHealth,times(1)).setBaseValue(100);verify(attack,times(1)).setBaseValue(9);
|
||||
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);var cancelled=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,champion,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);cancelled.setCancelled(true);f.dispatcher.accept(cancelled);verify(f.bossBars.getFirst(),never()).setProgress(0.99);var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,champion,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());verify(f.bossBars.getFirst()).setProgress(0.99);actor.core.location.setX(100);var death=new org.bukkit.event.entity.EntityDeathEvent(champion,source,new ArrayList<>(),9);f.dispatcher.accept(death);assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CHAMPION,controller.encounterOrigin(champion).orElseThrow().kind(),"death processing retains immutable managed origin authority");assertEquals(0,death.getDroppedExp());assertTrue(death.getDrops().isEmpty());for(int i=0;i<50&&(directory.active().size()!=1||trophies.trophies(actor.core.record.id()).isEmpty());i++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}var saved=storage.find(actor.core.record.id()).get().orElseThrow();var savedMember=storage.find(member.core.record.id()).get().orElseThrow();assertEquals(initial.experience(),saved.experience());assertEquals(memberInitial.experience(),savedMember.experience());assertEquals(initial.restrictedKills(),saved.restrictedKills());assertEquals(memberInitial.restrictedKills(),savedMember.restrictedKills());var partyTrophy=trophies.trophies(actor.core.record.id()).getFirst();assertEquals(games.dmg.fantasystick.adventure.FishingState.TrophyType.CHAMPION,partyTrophy.type());assertEquals("Summit",partyTrophy.origin());assertEquals(java.math.BigInteger.valueOf(150),partyTrophy.total().xp());assertEquals(java.math.BigInteger.valueOf(40),partyTrophy.total().coins());assertFalse(partyTrophy.rewards().containsKey(actor.core.record.id()),"absent leader custody does not invent a beneficiary share");assertTrue(partyTrophy.rewards().containsKey(member.core.record.id()));assertEquals(games.dmg.fantasystick.adventure.FishingState.Reward.empty(),trophies.unclaimed(actor.core.record.id()));assertEquals(1,directory.active().size(),"Unrepresented solo quest remains");verify(f.bossBars.getFirst(),atLeastOnce()).removeAll();int memberParticlesAfterCompletion=member.particleCalls.size();
|
||||
awaitPulse(f,()->spawned.stream().filter(Vindicator.class::isInstance).count()>=2);assertEquals(memberParticlesAfterCompletion,member.particleCalls.size(),"completed party members stop receiving the shared boundary");var second=spawned.stream().filter(Vindicator.class::isInstance).map(Vindicator.class::cast).skip(1).findFirst().orElseThrow();assertEquals(2,f.bossBars.size());verify(maxHealth).setBaseValue(120);verify(attack).setBaseValue(10);verify(second).setHealth(120);var soloSource=mock(org.bukkit.damage.DamageSource.class);when(soloSource.getCausingEntity()).thenReturn(solo.player);when(soloSource.getDirectEntity()).thenReturn(solo.player);f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(solo.player,second,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,soloSource,1));f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(second,soloSource,new ArrayList<>(),0));await(f,()->directory.active().isEmpty()&&!trophies.trophies(solo.core.record.id()).isEmpty());var savedSolo=storage.find(solo.core.record.id()).get().orElseThrow();assertEquals(soloInitial.experience(),savedSolo.experience());assertEquals(soloInitial.coins(),savedSolo.coins());var soloTrophy=trophies.trophies(solo.core.record.id()).getFirst();assertEquals(new games.dmg.fantasystick.adventure.FishingState.Reward(java.math.BigInteger.valueOf(150),java.math.BigInteger.valueOf(40)),soloTrophy.total());assertEquals(solo.core.record.id(),soloTrophy.carrier());assertEquals(games.dmg.fantasystick.adventure.FishingState.Reward.empty(),trophies.unclaimed(solo.core.record.id()));verify(f.bossBars.get(1),atLeastOnce()).removeAll();
|
||||
PartyBookNativeTest.open(f,actor);var inventoryText=PartyBookNativeTest.text(actor).replace("\n","");assertTrue(inventoryText.contains("Daybreak Champion Head: 1"),inventoryText);assertTrue(inventoryText.contains("Trophy origin: Summit"),inventoryText);
|
||||
var reroll=directory.rerollToday(catalog.revision()).get(5,java.util.concurrent.TimeUnit.SECONDS);assertTrue(reroll.catalog().isPresent());assertTrue(controller.publishPendingReset().toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS));assertTrue(directory.removeSite(site).get(5,java.util.concurrent.TimeUnit.SECONDS).isPresent());assertTrue(parties.disband(actor.core.id,party.id()));doReturn(false).when(member.player).isOnline();
|
||||
var turn=(games.dmg.fantasystick.adventure.FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");turn.open(actor.player);var turnMenu=actor.inventories.getLast();var redeem=menuItem(turnMenu,"Redeem Daybreak Champion Head");assertTrue(redeem.getItemMeta().lore().toString().contains("Beneficiaries: 1"));click(turn,actor,turnMenu,redeem);await(f,()->trophies.trophies(actor.core.record.id()).isEmpty()&&trophies.unclaimed(member.core.record.id()).xp().equals(java.math.BigInteger.valueOf(150)));var memberShare=trophies.unclaimed(member.core.record.id());assertEquals(games.dmg.fantasystick.adventure.FishingState.Reward.empty(),trophies.unclaimed(actor.core.record.id()),"carrier custody does not claim or invent a share");assertEquals(memberShare,trophies.unclaimed(member.core.record.id()),"offline beneficiary retains the full frozen pool");doReturn(true).when(member.player).isOnline();turn.open(member.player);var memberTurn=member.inventories.getLast();click(turn,member,memberTurn,menuItem(memberTurn,"Claim earned quest rewards"));await(f,()->trophies.unclaimed(member.core.record.id()).equals(games.dmg.fantasystick.adventure.FishingState.Reward.empty()));var claimedMember=storage.find(member.core.record.id()).get().orElseThrow();assertEquals(memberInitial.experience().add(java.math.BigInteger.valueOf(150)),claimedMember.experience());assertEquals(memberInitial.coins().add(java.math.BigInteger.valueOf(40)),claimedMember.coins());turn.open(solo.player);var soloTurn=solo.inventories.getLast();click(turn,solo,soloTurn,menuItem(soloTurn,"Claim retained adventure rewards"));await(f,()->directory.unclaimedAll(solo.core.record.id()).equals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward.empty()));assertEquals(1,trophies.trophies(solo.core.record.id()).size(),"legacy claims do not consume an earned trophy");
|
||||
}
|
||||
}
|
||||
private static org.bukkit.inventory.ItemStack menuItem(org.bukkit.inventory.Inventory menu,String name){var plain=net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText();return java.util.stream.IntStream.range(0,menu.getSize()).mapToObj(menu::getItem).filter(java.util.Objects::nonNull).filter(item->item.getItemMeta().displayName()!=null&&plain.serialize(item.getItemMeta().displayName()).equals(name)).findFirst().orElseThrow();}
|
||||
private static void click(games.dmg.fantasystick.adventure.FishingController controller,FantasyStickLifecycleNativeTest.Actor actor,org.bukkit.inventory.Inventory menu,org.bukkit.inventory.ItemStack item){var event=mock(InventoryClickEvent.class);when(event.getWhoClicked()).thenReturn(actor.player);when(event.getInventory()).thenReturn(menu);when(event.getCurrentItem()).thenReturn(item);controller.click(event);}
|
||||
private static void await(FantasyStickLifecycleNativeTest.Fixture f,java.util.function.BooleanSupplier condition)throws Exception{for(int attempt=0;attempt<80&&!condition.getAsBoolean();attempt++){var task=f.tasks.poll(20,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(condition.getAsBoolean());}
|
||||
private static void awaitPulse(FantasyStickLifecycleNativeTest.Fixture f,java.util.function.BooleanSupplier condition)throws Exception{for(int attempt=0;attempt<200&&!condition.getAsBoolean();attempt++){var task=f.tasks.poll(20,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();for(Runnable pulse:List.copyOf(f.pulses))pulse.run();Thread.sleep(5);}assertTrue(condition.getAsBoolean(),"Timed out waiting for the next champion encounter");}
|
||||
private static void drainOne(FantasyStickLifecycleNativeTest.Fixture f)throws Exception{var task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();}
|
||||
}
|
||||
@@ -225,6 +225,29 @@ class CharacterAnvilNamesNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleGenerationCannotRenameOrLaunderARecoveredCharacterSheet() throws Exception {
|
||||
var original = new CharacterFactory().create(ProgressionRules.defaults());
|
||||
var staleSheet = new CharacterSheets().create(original);
|
||||
var fixture = new AnvilFixture();
|
||||
fixture.grid.setItem(0, staleSheet);
|
||||
var output = named(staleSheet, Component.text("Laundered name"));
|
||||
var problems = new CopyOnWriteArrayList<CharacterAnvilNames.Failure>();
|
||||
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
|
||||
storage.create(original).join();
|
||||
var operation = java.util.UUID.randomUUID();
|
||||
var commit = storage.beginRecovery(new CharacterStorage.RecoveryRequest(operation, original.id(),
|
||||
java.util.UUID.randomUUID(), java.util.UUID.randomUUID(), original.revision(),
|
||||
original.sheetGeneration(), false, null, java.math.BigDecimal.valueOf(20))).join();
|
||||
storage.completeRecovery(original.id(), operation).join();
|
||||
listener(storage, problems).onTaken(new AnvilTakeResultEvent(fixture.actor.player, fixture.view, output));
|
||||
var current = storage.find(original.id()).join().orElseThrow();
|
||||
assertEquals(original.name(), current.name());
|
||||
assertEquals(java.math.BigInteger.ONE, current.sheetGeneration());
|
||||
assertEquals(1, problems.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearingACustomNameRespectsAnExplicitNativeItemNameComponent() throws Exception {
|
||||
var original = new CharacterFactory().create(ProgressionRules.defaults());
|
||||
|
||||
@@ -21,6 +21,16 @@ class CharacterBookNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookEventSinkFailureCannotChangeTheAlreadyAuthorizedNativeBookPath() {
|
||||
var player=org.mockito.Mockito.mock(org.bukkit.entity.Player.class);var world=org.mockito.Mockito.mock(org.bukkit.World.class);var record=org.mockito.Mockito.mock(games.dmg.fantasystick.persistence.CharacterRecord.class);var playerId=UUID.randomUUID();var active=new games.dmg.fantasystick.session.EquipmentSessions.Active(playerId,UUID.randomUUID(),UUID.randomUUID());org.mockito.Mockito.when(player.getUniqueId()).thenReturn(playerId);org.mockito.Mockito.when(player.getName()).thenReturn("Player");org.mockito.Mockito.when(world.getUID()).thenReturn(UUID.randomUUID());org.mockito.Mockito.when(world.getName()).thenReturn("world");org.mockito.Mockito.when(player.getLocation()).thenReturn(new org.bukkit.Location(world,1,2,3));org.mockito.Mockito.when(record.name()).thenReturn("Hero");org.mockito.Mockito.when(record.revision()).thenReturn(java.math.BigInteger.ONE);var input=new games.dmg.fantasystick.session.ControlInput(player,active,0,"",games.dmg.fantasystick.session.ControlInput.Kind.BOOK,games.dmg.fantasystick.session.ControlInput.Gesture.USE,null,0,0,()->true);var bridge=new games.dmg.fantasystick.events.BookEventBridge(event->{throw new IllegalStateException("disk");},java.time.Clock.systemUTC());assertDoesNotThrow(()->{var request=bridge.request(input,record,0);bridge.opened(request,record,3,1);bridge.refused(request,"retry");});
|
||||
}
|
||||
|
||||
@Test void registeredOpenReachesOneAcknowledgedFeatureEventAfterAuthoritativeReadAndNativeOpen() throws Exception {
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(a.core.id,a.player);f.activate(a);click(f,a);drainReads(f);runQueued(f);assertEquals(1,a.books.size());f.plugin.setEnabled(false);f.awaitWorkers();}
|
||||
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}
|
||||
var opened=events.stream().filter(event->event.get("type").getAsString().endsWith("character_book.opened")).toList();assertEquals(1,opened.size());var data=opened.getFirst().getAsJsonObject("data");assertEquals("active",data.get("view_kind").getAsString());assertEquals("0",data.get("offset").getAsString());assertTrue(data.get("loaded_revision").getAsBigInteger().signum()>=0);assertTrue(data.get("presented_pages").getAsInt()>0);assertEquals(1,data.get("presented_volumes").getAsInt());assertEquals("acknowledged",data.get("native_open").getAsString());var action=data.get("action_id").getAsString();assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("action.observed")&&event.getAsJsonObject("data").get("action_id").getAsString().equals(action)).count());assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("action.resolved")&&event.getAsJsonObject("data").get("action_id").getAsString().equals(action)).count());
|
||||
}
|
||||
|
||||
@Test void queuedInspectionCannotOpenForAMovedSheetChangedSlotQuitOrDisabledLifetime() throws Exception {
|
||||
for (int route = 0; route < 4; route++) try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve("route-" + route))) {
|
||||
f.plugin.setEnabled(true);
|
||||
@@ -52,6 +62,7 @@ class CharacterBookNativeTest {
|
||||
assertEquals(1, a.books.size());
|
||||
assertTrue(text(a.books.getFirst()).contains("Latest book name"));
|
||||
}
|
||||
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("character_book.opened")).count());assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("action.resolved")&&event.getAsJsonObject("data").has("action_kind")&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("character_book_open")&&event.getAsJsonObject("data").has("reason")&&event.getAsJsonObject("data").get("reason").getAsString().equals("superseded")).count());
|
||||
}
|
||||
|
||||
@Test void cappedProgressAndSavedAbilitiesRenderWithoutCappingStatsOrRewritingProgress() throws Exception {
|
||||
@@ -101,6 +112,7 @@ class CharacterBookNativeTest {
|
||||
assertEquals(saved, characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow(),
|
||||
"A rendered purchase action is single-use");
|
||||
}
|
||||
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}assertEquals(1,events.stream().filter(event->event.get("type").getAsString().endsWith("character_book.opened")).count(),"The automatic post-purchase refresh must not duplicate a user-requested book-open outcome");
|
||||
}
|
||||
|
||||
@Test void conPurchaseKeepsItsOwnMaximumAndOneTimeGainDespiteExternalModifiers() throws Exception {
|
||||
@@ -210,6 +222,7 @@ class CharacterBookNativeTest {
|
||||
}
|
||||
|
||||
@Test void largeRecordsRemainCompleteThroughBoundedSingleUseNavigation() throws Exception {
|
||||
int expectedNavigations;
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
@@ -247,13 +260,14 @@ class CharacterBookNativeTest {
|
||||
drainReads(f); runQueued(f);
|
||||
assertEquals(volumes + 1, a.books.size(), "One native action cannot open two volumes");
|
||||
}
|
||||
assertTrue(volumes > 0); assertTrue(all.toString().contains(digits), "Every digit remains reachable in order");
|
||||
assertTrue(volumes > 0);expectedNavigations=volumes; assertTrue(all.toString().contains(digits), "Every digit remains reachable in order");
|
||||
var previous = PartyBookNativeTest.button(a, "[Previous pages]");
|
||||
a.core.inventory.getInventory().setSelectedSlot(1);
|
||||
PartyBookNativeTest.click(f, a, previous); drainReads(f); runQueued(f);
|
||||
assertEquals(volumes + 1, a.books.size(), "Stale held-slot navigation has no authority");
|
||||
assertArrayEquals(bytes, Files.readAllBytes(data.resolve("characters").resolve(saved.id() + ".yml")));
|
||||
}
|
||||
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}assertEquals(expectedNavigations,events.stream().filter(event->event.get("type").getAsString().endsWith("character_book.navigated")).count());assertEquals(expectedNavigations,events.stream().filter(event->event.get("type").getAsString().endsWith("character_book.navigated")&&event.getAsJsonObject("data").get("native_open").getAsString().equals("acknowledged")&&event.getAsJsonObject("data").get("offset").getAsInt()>0).count());
|
||||
}
|
||||
|
||||
@Test void acknowledgedRenameTransferAndReloadInspectTheCurrentRecordWithoutCarryingTheFormerHoldersView() throws Exception {
|
||||
|
||||
@@ -115,12 +115,16 @@ class CharacterSheetsNativeTest {
|
||||
assertEquals(List.of(Component.text("Character sheet:"), Component.text("Place in off hand"),
|
||||
Component.text("to enter fantasy world.")), sheet.getItemMeta().lore());
|
||||
assertEquals(record.id(), sheets.identify(sheet).orElseThrow());
|
||||
assertEquals(Set.of(new NamespacedKey("fantasystick", "sheet_version"), new NamespacedKey("fantasystick", "character_id")),
|
||||
sheet.getItemMeta().getPersistentDataContainer().getKeys(), "No player owner, equipment or progress snapshot belongs on the sheet");
|
||||
assertEquals(Set.of(new NamespacedKey("fantasystick", "sheet_version"),
|
||||
new NamespacedKey("fantasystick", "character_id"),
|
||||
new NamespacedKey("fantasystick", "sheet_generation")),
|
||||
sheet.getItemMeta().getPersistentDataContainer().getKeys(),
|
||||
"Only character identity and revocable issuance generation belong on the sheet");
|
||||
var ops = RegistryOps.create(NbtOps.INSTANCE, CraftRegistry.getMinecraftRegistry());
|
||||
var encoded = net.minecraft.world.item.ItemStack.CODEC.encodeStart(ops, CraftItemStack.asNMSCopy(sheet)).getOrThrow();
|
||||
var reloaded = CraftItemStack.asBukkitCopy(net.minecraft.world.item.ItemStack.CODEC.parse(ops, encoded).getOrThrow());
|
||||
assertEquals(record.id(), new CharacterSheets().identify(reloaded).orElseThrow());
|
||||
assertEquals(record.sheetGeneration(), new CharacterSheets().identity(reloaded).orElseThrow().generation());
|
||||
assertEquals(sheet, reloaded);
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,23 @@ class CombatExperienceNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void everyManagedEncounterOriginWaivesDistanceWithoutChangingRestrictedHistory() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),"hostile-coin-drop-min: 10\nhostile-coin-drop-max: 10\n");
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);var combat=(games.dmg.fantasystick.party.CombatExperience)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"combatExperience");
|
||||
for(var kind:games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.values()){
|
||||
var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);f.activate(actor);combat.attachManagedOrigins(entity->java.util.Optional.of(new games.dmg.fantasystick.progression.ManagedEncounterOrigin(kind,UUID.nameUUIDFromBytes(entity.getUniqueId().toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)))));var world=mock(org.bukkit.World.class);when(world.getUID()).thenReturn(UUID.randomUUID());var before=record(f,actor);var first=mob(EntityType.ZOMBIE,new Location(world,0,64,0));hit(f,actor.player,first,false);die(f,first);settle(f);var second=mob(EntityType.ZOMBIE,new Location(world,1,64,0));hit(f,actor.player,second,false);die(f,second);settle(f);var saved=record(f,actor);assertTrue(saved.experience().compareTo(before.experience())>0,kind.toString());assertEquals(before.coins().add(java.math.BigInteger.valueOf(20)),saved.coins(),kind.toString());assertEquals(before.restrictedKills(),saved.restrictedKills(),kind+" never consults or advances ordinary spacing history");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void repeatedManagedKillAuthorityCannotPayTwice() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),"hostile-coin-drop-min: 10\nhostile-coin-drop-max: 10\n");
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);var combat=(games.dmg.fantasystick.party.CombatExperience)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"combatExperience");var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);f.activate(actor);var authority=UUID.randomUUID();combat.attachManagedOrigins(entity->java.util.Optional.of(new games.dmg.fantasystick.progression.ManagedEncounterOrigin(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,authority)));var world=mock(org.bukkit.World.class);when(world.getUID()).thenReturn(UUID.randomUUID());var before=record(f,actor);for(int attempt=0;attempt<2;attempt++){var target=mob(EntityType.ZOMBIE,new Location(world,0,64,0));hit(f,actor.player,target,false);die(f,target);settle(f);}var saved=record(f,actor);assertEquals(before.experience().add(java.math.BigInteger.TEN),saved.experience());assertEquals(before.coins().add(java.math.BigInteger.TEN),saved.coins());assertEquals(before.restrictedKills(),saved.restrictedKills());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void everyRestrictedMobCoinClassUsesTheExistingExactAndCrossWorldSpacingGeometry() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"hostile-coin-drop-min: 12\nhostile-coin-drop-max: 12\n"
|
||||
@@ -353,6 +370,7 @@ class CombatExperienceNativeTest {
|
||||
games.dmg.fantasystick.progression.CombatRewardRules.defaults(), sessions,
|
||||
java.time.Clock.fixed(java.time.Instant.parse("2026-09-19T20:00:00Z"), java.time.ZoneOffset.UTC), Runnable::run,
|
||||
ignored -> attacker, failures::add);
|
||||
var authority=new games.dmg.fantasystick.progression.ManagedEncounterOrigin(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,UUID.randomUUID());var settled=new ArrayList<games.dmg.fantasystick.progression.ManagedEncounterOrigin>();service.attachManagedOrigins(ignored->java.util.Optional.of(authority));service.attachManagedSettlement(settled::add);
|
||||
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
var target = mob(EntityType.WITCH, new Location(world, 0, 64, 0));
|
||||
var damageSource = mock(org.bukkit.damage.DamageSource.class);
|
||||
@@ -360,11 +378,11 @@ class CombatExperienceNativeTest {
|
||||
service.damage(new EntityDamageByEntityEvent(attacker, target, DamageCause.ENTITY_ATTACK, damageSource, 1));
|
||||
service.mobDeath(new org.bukkit.event.entity.EntityDeathEvent(target, damageSource, new ArrayList<>(), 0));
|
||||
verify(storage, times(1)).awardCombatReward(eq(original.id()), any(), any(), any(), any(), any(), any(), any());
|
||||
assertEquals(1, failures.size()); verify(attacker, never()).sendMessage(anyString());
|
||||
assertEquals(1, failures.size());assertTrue(settled.isEmpty(),"slot authority remains unsettled while reward persistence is uncertain");verify(attacker, never()).sendMessage(anyString());
|
||||
service.configure(service.rules().withHardPool(java.math.BigInteger.valueOf(25)));
|
||||
service.retryPending();
|
||||
verify(storage, times(2)).awardCombatReward(eq(original.id()), any(), any(), any(), any(), any(), any(), any());
|
||||
verify(attacker, times(1)).sendMessage(contains("character coins"));
|
||||
verify(attacker, times(1)).sendMessage(contains("character coins"));assertEquals(java.util.List.of(authority),settled,"slot defeat may publish only after the captured payout is durable");
|
||||
service.mobDeath(new org.bukkit.event.entity.EntityDeathEvent(target, damageSource, new ArrayList<>(), 0));
|
||||
service.retryPending();
|
||||
verify(storage, times(2)).awardCombatReward(eq(original.id()), any(), any(), any(), any(), any(), any(), any());
|
||||
@@ -470,9 +488,9 @@ class CombatExperienceNativeTest {
|
||||
var source = mock(org.bukkit.damage.DamageSource.class);
|
||||
var defeat = new org.bukkit.event.entity.PlayerDeathEvent(b.player, source, new ArrayList<>(), 0,
|
||||
net.kyori.adventure.text.Component.text("defeat"), true);
|
||||
f.dispatcher.accept(defeat); settle(f); settle(f); settle(f);
|
||||
f.dispatcher.accept(defeat);settle(f);var downed=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&downed.all().isEmpty();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var captured=downed.all().getFirst().attribution();assertTrue(captured.contributors().contains(a.core.record.id()),"Originating character attribution survives the wait");assertEquals(java.math.BigInteger.ZERO,record(f,a).experience(),"Downing alone grants no defeat XP");assertEquals(victimCoins,record(f,b).coins(),"Downing alone loses no victim coins");DownedSessionNativeTest.acceptDeath(f,b);settle(f);settle(f);settle(f);for(int attempt=0;attempt<120&&record(f,a).experience().signum()==0;attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}
|
||||
assertTrue(defeat.isCancelled());
|
||||
assertEquals(java.math.BigInteger.TEN, record(f, a).experience(), "Level-one fantasy defeat supplies a ten-XP pool");
|
||||
assertEquals(java.math.BigInteger.TEN,record(f,a).experience(),"Level-one fantasy defeat supplies a ten-XP pool");
|
||||
assertEquals(attackerCoins.add(victimCoins), record(f, a).coins());
|
||||
assertTrue(record(f, b).dead());
|
||||
assertEquals(java.math.BigInteger.ZERO, record(f, b).coins(), "PvP defeat binds and zeroes the full saved balance");
|
||||
|
||||
@@ -31,10 +31,13 @@ class DefeatHealthDeliveryNativeTest {
|
||||
a.nativePlayer.queueHealthUpdatePacket = queued;
|
||||
var death = death(a);
|
||||
f.dispatcher.accept(death);
|
||||
DownedSessionNativeTest.acceptDeath(f,a);
|
||||
if (a.nativePlayer.queuedHealthUpdatePacket != null) sent.add(a.nativePlayer.queuedHealthUpdatePacket);
|
||||
a.nativePlayer.queueHealthUpdatePacket = false;
|
||||
PartyLifecycleNativeTest.directory(f).defeat(java.util.UUID.randomUUID()).join();
|
||||
f.awaitCharacterAndReceipt(a);
|
||||
PartyBookNativeTest.drain(f);
|
||||
DownedSessionNativeTest.awaitTail(f,a);
|
||||
assertTrue(death.isCancelled());
|
||||
assertEquals(a.maximumHealth.getValue(), a.player.getHealth());
|
||||
assertEquals(entry, a.player.getLocation());
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class DownedSessionNativeTest{
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void registeredAdminConfigurationReadsCompletesValidatesAndPublishesDownedPolicies()throws Exception{
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();assertEquals(java.util.List.of("downed-timeout-seconds"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","downed"}));assertEquals(java.util.List.of("300"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","downed-timeout-seconds",""}));f.command.execute(sender.port,"fantasystick",new String[]{"config","downed-timeout-seconds"});assertTrue(sender.messages.getLast().contains("=300"));int before=sender.messages.size();f.command.execute(sender.port,"fantasystick",new String[]{"config","downed-timeout-seconds","240"});while(sender.messages.size()==before){var task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();}assertTrue(sender.messages.getLast().contains("=240"));var settings=(games.dmg.fantasystick.persistence.DownedSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downedSettings");assertEquals(java.time.Duration.ofSeconds(240),settings.timeout());f.command.execute(sender.port,"fantasystick",new String[]{"config","downed-timeout-seconds","0"});assertTrue(sender.messages.getLast().contains("invalid"));assertEquals(java.time.Duration.ofSeconds(240),settings.timeout());f.command.execute(sender.port,"fantasystick",new String[]{"config","revive-downed-invulnerability-seconds","5","extra"});assertTrue(sender.messages.getLast().contains("Usage"));sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","downed"}).isEmpty());assertTrue(java.nio.file.Files.readString(data.resolve("config.yml")).contains("downed-timeout-seconds: 240"));f.plugin.setEnabled(false);var records=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}assertTrue(records.stream().anyMatch(event->event.get("type").getAsString().endsWith("config.read")&&event.getAsJsonObject("data").get("key").getAsString().equals("downed-timeout-seconds")));var changed=records.stream().filter(event->event.get("type").getAsString().endsWith("config.changed")).map(event->event.getAsJsonObject("data")).filter(event->event.get("key").getAsString().equals("downed-timeout-seconds")).findFirst().orElseThrow();assertEquals("300",changed.get("before").getAsString());assertEquals("240",changed.get("after").getAsString());}
|
||||
}
|
||||
@Test void restartClaimsAnExpiredEpisodeAsOneTimeoutResolutionWithoutInventingDeath() throws Exception {
|
||||
var past=java.time.Instant.now().minus(java.time.Duration.ofMinutes(10));var character=UUID.randomUUID();
|
||||
try(var directory=new games.dmg.fantasystick.session.DownedDirectory(data,java.time.Clock.fixed(past,java.time.ZoneOffset.UTC))){directory.begin(character,UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),0,64,0,0,0,java.time.Duration.ofMinutes(5),new games.dmg.fantasystick.session.DownedEpisode.Attribution(null,"minecraft:fall")).join();}
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&directory.find(character).join().orElseThrow().phase()==games.dmg.fantasystick.session.DownedEpisode.Phase.PENDING;attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertEquals(games.dmg.fantasystick.session.DownedEpisode.Phase.RESOLVED,directory.find(character).join().orElseThrow().phase());for(int attempt=0;attempt<20;attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task==null)break;task.run();}f.plugin.setEnabled(false);}
|
||||
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}
|
||||
var resolved=events.stream().filter(event->event.get("type").getAsString().endsWith("character.downed_resolved")).toList();assertEquals(1,resolved.size());assertEquals("timeout",resolved.getFirst().getAsJsonObject("data").get("outcome").getAsString());assertTrue(events.stream().noneMatch(event->event.get("type").getAsString().endsWith("character.died")));
|
||||
}
|
||||
|
||||
@Test void restartReconcilesStableDownedResolutionAndExpiredProtectionEvents() throws Exception {
|
||||
var character=UUID.randomUUID();
|
||||
try(var directory=new games.dmg.fantasystick.session.DownedDirectory(data,java.time.Clock.systemUTC())){
|
||||
var episode=directory.begin(character,UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),1,64,2,0,0,
|
||||
java.time.Duration.ofMinutes(5),new games.dmg.fantasystick.session.DownedEpisode.Attribution(null,"minecraft:fall")).join();
|
||||
var action=UUID.randomUUID();
|
||||
var resolved=directory.claim(episode.id(),games.dmg.fantasystick.session.DownedEpisode.Outcome.REVIVED,action,
|
||||
java.time.Duration.ZERO).join().episode().orElseThrow();
|
||||
directory.publish(resolved.id(),action).join();
|
||||
}
|
||||
for(int boot=0;boot<2;boot++)try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);f.plugin.setEnabled(false);}
|
||||
var ids=new java.util.ArrayList<String>();
|
||||
try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file)){if(line.isBlank())continue;var event=com.google.gson.JsonParser.parseString(line).getAsJsonObject();if(event.get("type").getAsString().endsWith("effect.ended"))ids.add(event.get("id").getAsString());}}
|
||||
assertEquals(2,ids.size());assertEquals(ids.getFirst(),ids.getLast());
|
||||
}
|
||||
|
||||
@Test void directProjectileOngoingAndEnvironmentalLethalCausesEnterTheSameDurablePhase()throws Exception{
|
||||
var types=java.util.List.of(org.bukkit.damage.DamageType.PLAYER_ATTACK,org.bukkit.damage.DamageType.ARROW,org.bukkit.damage.DamageType.ON_FIRE,org.bukkit.damage.DamageType.FALL);int index=0;for(var type:types){var directory=data.resolve("cause-"+index++);java.nio.file.Files.createDirectories(directory);try(var f=new FantasyStickLifecycleNativeTest.Fixture(directory)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(directory);f.players.put(actor.core.id,actor.player);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);actor.core.health=0;var death=death(actor,type);f.dispatcher.accept(death);var downed=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&(downed.all().isEmpty()||actor.inventories.isEmpty());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(death.isCancelled());assertFalse(actor.inventories.isEmpty());assertEquals(type.getKey().asString(),downed.all().getFirst().attribution().cause());f.plugin.setEnabled(false);var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(directory.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}var recorded=events.stream().filter(event->event.get("type").getAsString().endsWith("character.downed")).map(event->event.getAsJsonObject("data")).findFirst().orElseThrow();assertEquals(type.getKey().asString(),recorded.get("lethal_cause").getAsString());}}
|
||||
}
|
||||
@Test void nativeLethalOutcomeEntersDurableDownedPhaseWithoutFinalDeathOrTail()throws Exception{
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);actor.core.location.add(7,0,3);var anchor=actor.core.location.clone();actor.core.health=0;var death=death(actor);f.dispatcher.accept(death);actor.player.setHealth(death.getReviveHealth());var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&(directory.all().isEmpty()||actor.inventories.isEmpty());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(death.isCancelled());assertEquals(1d,actor.player.getHealth());var episode=directory.all().getFirst();assertEquals(actor.core.record.id(),episode.character());assertEquals(actor.core.id,episode.player());assertEquals(anchor.getWorld().getUID(),episode.world());assertEquals(anchor.getX(),episode.x());assertEquals(games.dmg.fantasystick.session.DownedEpisode.Phase.PENDING,episode.phase());var characters=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(characters.find(actor.core.record.id()).join().orElseThrow().dead());assertNotEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");var active=sessions.active(actor.core.id).orElseThrow();assertFalse(sessions.current(active));assertTrue(sessions.downed(actor.core.id).isPresent());assertEquals(org.bukkit.Material.GRAY_DYE,actor.inventories.getLast().getItem(13).getType());}
|
||||
}
|
||||
@Test void claimedReviveRestoresFullHealthInPlaceAndAddsBoundedDamageProtection()throws Exception{
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);var retainedPhoenix=(games.dmg.fantasystick.shop.RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");retainedPhoenix.add(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();actor.core.location.add(4,0,2);var anchor=actor.core.location.clone();actor.core.health=0;f.dispatcher.accept(death(actor));var controller=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downedController");var outcome=new java.util.concurrent.atomic.AtomicReference<games.dmg.fantasystick.session.DownedController.RescueStatus>();for(int attempt=0;attempt<80&&!controller.downed(actor.player);attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}controller.revive(actor.player,java.time.Duration.ofSeconds(5),outcome::set);for(int attempt=0;attempt<80&&outcome.get()==null;attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertEquals(games.dmg.fantasystick.session.DownedController.RescueStatus.APPLIED,outcome.get());assertEquals(actor.maximumHealth.getValue(),actor.player.getHealth());assertEquals(anchor,actor.player.getLocation());var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");assertTrue(sessions.current(sessions.active(actor.core.id).orElseThrow()));assertTrue(sessions.downedProtected(actor.core.id));assertFalse(new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow().dead());var damage=org.mockito.Mockito.mock(org.bukkit.event.entity.EntityDamageEvent.class);org.mockito.Mockito.when(damage.getEntity()).thenReturn(actor.player);controller.damage(damage);org.mockito.Mockito.verify(damage).setCancelled(true);assertNotEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());sessions.exit(actor.player);f.awaitCharacterAndReceipt(actor);sessions.arrive(actor.player);for(int attempt=0;attempt<120&&sessions.active(actor.core.id).isEmpty();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(sessions.downedProtected(actor.core.id),"Reconnect retains the original absolute protection deadline");f.plugin.setEnabled(false);var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}assertTrue(events.stream().filter(event->event.get("type").getAsString().endsWith("combat.prevented")).map(event->event.getAsJsonObject("data")).anyMatch(event->event.get("reason").getAsString().equals("revive_protection")));assertEquals(java.math.BigInteger.ONE,retainedPhoenix.quantity(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN));}
|
||||
}
|
||||
@Test void downedFreezeCancelsMotionCombatHealingHungerItemsAndInventoryMutation()throws Exception{
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);actor.core.health=0;f.dispatcher.accept(death(actor));var controller=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downedController");for(int attempt=0;attempt<80&&(!controller.downed(actor.player)||actor.inventories.isEmpty());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}org.mockito.Mockito.verify(actor.player).setGravity(false);var from=actor.core.location.clone();var to=from.clone().add(1,0,0);var move=org.mockito.Mockito.mock(org.bukkit.event.player.PlayerMoveEvent.class);org.mockito.Mockito.when(move.getPlayer()).thenReturn(actor.player);org.mockito.Mockito.when(move.getFrom()).thenReturn(from);org.mockito.Mockito.when(move.getTo()).thenReturn(to);controller.move(move);org.mockito.Mockito.verify(move).setTo(from);var velocity=org.mockito.Mockito.mock(org.bukkit.event.player.PlayerVelocityEvent.class);org.mockito.Mockito.when(velocity.getPlayer()).thenReturn(actor.player);controller.velocity(velocity);org.mockito.Mockito.verify(velocity).setCancelled(true);var attack=org.mockito.Mockito.mock(org.bukkit.event.entity.EntityDamageByEntityEvent.class);org.mockito.Mockito.when(attack.getDamager()).thenReturn(actor.player);controller.attack(attack);org.mockito.Mockito.verify(attack).setCancelled(true);var heal=org.mockito.Mockito.mock(org.bukkit.event.entity.EntityRegainHealthEvent.class);org.mockito.Mockito.when(heal.getEntity()).thenReturn(actor.player);controller.heal(heal);org.mockito.Mockito.verify(heal).setCancelled(true);var hunger=org.mockito.Mockito.mock(org.bukkit.event.entity.FoodLevelChangeEvent.class);org.mockito.Mockito.when(hunger.getEntity()).thenReturn(actor.player);controller.hunger(hunger);org.mockito.Mockito.verify(hunger).setCancelled(true);var consume=org.mockito.Mockito.mock(org.bukkit.event.player.PlayerItemConsumeEvent.class);org.mockito.Mockito.when(consume.getPlayer()).thenReturn(actor.player);controller.consume(consume);org.mockito.Mockito.verify(consume).setCancelled(true);var old=actor.core.inventory.getItem(4).clone();actor.core.inventory.setItem(4,new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIRT));var change=new io.papermc.paper.event.player.PlayerInventorySlotChangeEvent(actor.player,4,old,actor.core.inventory.getItem(4));((games.dmg.fantasystick.session.EquipmentSessionEvents)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessionEvents")).changed(change);assertTrue(((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(actor.core.id).isPresent());controller.slot(change);var task=f.tasks.poll(1,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();assertEquals(old,actor.core.inventory.getItem(4));}
|
||||
}
|
||||
@Test void reconnectBeforeExpiryRebindsTheExactSessionAtItsSavedAnchor()throws Exception{
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);var worldId=UUID.randomUUID();org.mockito.Mockito.when(actor.world.getUID()).thenReturn(worldId);f.activate(actor);actor.core.location.add(6,1,-2);var anchor=actor.core.location.clone();actor.core.health=0;f.dispatcher.accept(death(actor));var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&directory.all().isEmpty();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var episode=directory.all().getFirst();var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");sessions.exit(actor.player);f.awaitCharacterAndReceipt(actor);actor.core.location.add(40,0,0);sessions.arrive(actor.player);for(int attempt=0;attempt<160&&(sessions.active(actor.core.id).isEmpty()||sessions.downed(actor.core.id).isEmpty());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(sessions.active(actor.core.id).isPresent(),actor.messages.toString());var rebound=sessions.active(actor.core.id).orElseThrow();assertEquals(episode.session(),rebound.session());assertEquals(episode.id(),sessions.downed(actor.core.id).orElseThrow().id());assertEquals(anchor,actor.player.getLocation());assertFalse(sessions.current(rebound));}
|
||||
}
|
||||
@Test void acknowledgedReviveInterruptedBeforePublicationReconcilesOnceOnReconnect()throws Exception{
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());var characters=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var initial=characters.find(actor.core.record.id()).join().orElseThrow();characters.saveHealth(initial.id(),initial.revision(),java.math.BigDecimal.valueOf(4),initial.maximumHealth()).join();f.activate(actor);actor.core.health=0;f.dispatcher.accept(death(actor));var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&directory.all().isEmpty();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var episode=directory.all().getFirst();var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");sessions.exit(actor.player);f.awaitCharacterAndReceipt(actor);var resolved=directory.claim(episode.id(),games.dmg.fantasystick.session.DownedEpisode.Outcome.REVIVED,UUID.randomUUID(),java.time.Duration.ofSeconds(5)).join().episode().orElseThrow();assertFalse(resolved.published());sessions.arrive(actor.player);for(int attempt=0;attempt<160&&(sessions.active(actor.core.id).isEmpty()||!directory.find(actor.core.record.id()).join().orElseThrow().published());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(sessions.current(sessions.active(actor.core.id).orElseThrow()));assertEquals(actor.maximumHealth.getValue(),actor.player.getHealth());var saved=characters.find(actor.core.record.id()).join().orElseThrow();assertEquals(0,saved.maximumHealth().compareTo(saved.health()));assertTrue(directory.find(actor.core.record.id()).join().orElseThrow().published());assertTrue(sessions.downedProtected(actor.core.id));}
|
||||
}
|
||||
@Test void offlineFinalizationRecordsOriginalTimeAndReconcilesTheExactSheet()throws Exception{
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);actor.core.health=0;f.dispatcher.accept(death(actor));var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&directory.all().isEmpty();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var episode=directory.all().getFirst();var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");sessions.exit(actor.player);var resolved=directory.claim(episode.id(),games.dmg.fantasystick.session.DownedEpisode.Outcome.FINAL_DEATH,UUID.randomUUID(),java.time.Duration.ZERO).join().episode().orElseThrow();assertTrue(sessions.finalizeDowned(resolved));for(int attempt=0;attempt<120&&!new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow().dead();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var dead=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();assertEquals(resolved.resolvedAt(),dead.diedAt());assertEquals(episode.id(),dead.deathId());assertEquals(org.bukkit.Material.PAPER,actor.core.inventory.getItemInOffHand().getType());sessions.arrive(actor.player);for(int attempt=0;attempt<80&&actor.core.inventory.getItemInOffHand().getType()!=org.bukkit.Material.WRITTEN_BOOK;attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType(),actor.messages.toString());for(int attempt=0;attempt<80&&!directory.find(actor.core.record.id()).join().orElseThrow().published();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(directory.find(actor.core.record.id()).join().orElseThrow().published());}
|
||||
}
|
||||
@Test void deathMenuAcceptanceFinalizesTheSameEpisodeAndDeliversOneTail()throws Exception{
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);var retainedPhoenix=(games.dmg.fantasystick.shop.RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");retainedPhoenix.add(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();actor.core.health=0;f.dispatcher.accept(death(actor));var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&(directory.all().isEmpty()||actor.inventories.isEmpty());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var episode=directory.all().getFirst();var menu=actor.inventories.getLast();assertTrue(plain(menu.getItem(11)).contains("Accept Death"));assertTrue(plain(menu.getItem(13)).contains("Phoenix Down"));assertTrue(plain(menu.getItem(15)).contains("Wait for Revive"));var view=org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);org.mockito.Mockito.when(view.getTopInventory()).thenReturn(menu);var click=org.mockito.Mockito.mock(org.bukkit.event.inventory.InventoryClickEvent.class);org.mockito.Mockito.when(click.getView()).thenReturn(view);org.mockito.Mockito.when(click.getWhoClicked()).thenReturn(actor.player);org.mockito.Mockito.when(click.getRawSlot()).thenReturn(11);var controller=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downedController");controller.click(click);controller.click(click);for(int attempt=0;attempt<120&&!new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow().dead();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}PartyLifecycleNativeTest.directory(f).defeat(UUID.randomUUID()).join();f.awaitCharacterAndReceipt(actor);PartyBookNativeTest.drain(f);var dead=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();assertTrue(dead.dead());assertEquals(episode.id(),dead.deathId());assertEquals(games.dmg.fantasystick.session.DownedEpisode.Outcome.FINAL_DEATH,directory.find(dead.id()).join().orElseThrow().outcome());for(int attempt=0;attempt<80&&!directory.find(dead.id()).join().orElseThrow().published();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(directory.find(dead.id()).join().orElseThrow().published());assertEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());assertEquals(java.math.BigInteger.ONE,retainedPhoenix.quantity(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN));}
|
||||
}
|
||||
@Test void explicitPhoenixDownChoiceConsumesOneAndRescuesTheExactDownedCharacter()throws Exception{
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);var inventory=(games.dmg.fantasystick.shop.RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");inventory.add(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();inventory.add(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();PartyBookNativeTest.open(f,actor);String inventoryText=PartyBookNativeTest.text(actor);assertTrue(inventoryText.contains("Phoenix Down: 2"),inventoryText);assertTrue(inventoryText.contains("Use Phoenix Down"),inventoryText);assertFalse(inventoryText.contains("[Use]"),inventoryText);actor.core.health=0;var death=death(actor);f.dispatcher.accept(death);var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&(directory.all().isEmpty()||actor.inventories.isEmpty());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var episode=directory.all().getFirst();var menu=actor.inventories.getLast();assertEquals(org.bukkit.Material.FEATHER,menu.getItem(13).getType());assertTrue(plain(menu.getItem(13)).contains("Use Phoenix Down"));assertTrue(plain(menu.getItem(13)).contains("2"));var view=org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);org.mockito.Mockito.when(view.getTopInventory()).thenReturn(menu);var click=org.mockito.Mockito.mock(org.bukkit.event.inventory.InventoryClickEvent.class);org.mockito.Mockito.when(click.getView()).thenReturn(view);org.mockito.Mockito.when(click.getWhoClicked()).thenReturn(actor.player);org.mockito.Mockito.when(click.getRawSlot()).thenReturn(13);var controller=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downedController");controller.click(click);controller.click(click);for(int attempt=0;attempt<160&&(!directory.find(actor.core.record.id()).join().orElseThrow().published()||inventory.quantity(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN).compareTo(java.math.BigInteger.ONE)!=0||actor.player.getHealth()!=actor.maximumHealth.getValue());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var resolved=directory.find(actor.core.record.id()).join().orElseThrow();assertEquals(games.dmg.fantasystick.session.DownedEpisode.Outcome.REVIVED,resolved.outcome());assertEquals("phoenix_down",resolved.decision().source());assertEquals(episode.id(),resolved.id());assertEquals(java.math.BigInteger.ONE,inventory.quantity(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN));assertEquals(actor.maximumHealth.getValue(),actor.player.getHealth());assertTrue(((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).downedProtected(actor.core.id));assertFalse(new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow().dead());assertNotEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());var protectedDamage=org.mockito.Mockito.mock(org.bukkit.event.entity.EntityDamageEvent.class);org.mockito.Mockito.when(protectedDamage.getEntity()).thenReturn(actor.player);org.mockito.Mockito.when(protectedDamage.getFinalDamage()).thenReturn(3d);controller.damage(protectedDamage);org.mockito.Mockito.verify(protectedDamage).setCancelled(true);f.plugin.setEnabled(false);var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}var phoenixEvents=events.stream().filter(event->event.get("type").getAsString().endsWith("character.downed_resolved")&&event.getAsJsonObject("data").get("outcome").getAsString().equals("phoenix_down")).toList();assertFalse(phoenixEvents.isEmpty());assertEquals(1,phoenixEvents.stream().map(event->event.get("id").getAsString()).distinct().count());var phoenixData=phoenixEvents.getFirst().getAsJsonObject("data");assertEquals("downed_menu",phoenixData.get("selection_entry_point").getAsString());assertEquals("2",phoenixData.get("quantity_before").getAsString());assertEquals("1",phoenixData.get("quantity_after").getAsString());assertEquals("10.0",phoenixData.get("maximum_health").getAsString());assertTrue(events.stream().filter(event->event.get("type").getLine truncated
|
||||
}
|
||||
static void awaitTail(FantasyStickLifecycleNativeTest.Fixture fixture,FantasyStickLifecycleNativeTest.Actor actor)throws Exception{for(int attempt=0;attempt<160&&actor.core.inventory.getItemInOffHand().getType()!=org.bukkit.Material.WRITTEN_BOOK;attempt++){var task=fixture.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());}
|
||||
static void acceptDeath(FantasyStickLifecycleNativeTest.Fixture fixture,FantasyStickLifecycleNativeTest.Actor actor)throws Exception{var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin,"downed");for(int attempt=0;attempt<80&&(directory.all().stream().noneMatch(value->value.player().equals(actor.core.id)&&value.phase()==games.dmg.fantasystick.session.DownedEpisode.Phase.PENDING)||actor.inventories.isEmpty());attempt++){var task=fixture.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var menu=actor.inventories.getLast();var view=org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);org.mockito.Mockito.when(view.getTopInventory()).thenReturn(menu);var click=org.mockito.Mockito.mock(org.bukkit.event.inventory.InventoryClickEvent.class);org.mockito.Mockito.when(click.getView()).thenReturn(view);org.mockito.Mockito.when(click.getWhoClicked()).thenReturn(actor.player);org.mockito.Mockito.when(click.getRawSlot()).thenReturn(11);((games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin,"downedController")).click(click);for(int attempt=0;attempt<120&&directory.all().stream().anyMatch(value->value.player().equals(actor.core.id)&&value.phase()==games.dmg.fantasystick.session.DownedEpisode.Phase.PENDING);attempt++){var task=fixture.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin,"sessions");for(int attempt=0;attempt<80&&sessions.active(actor.core.id).isPresent();attempt++){var task=fixture.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}}
|
||||
private static String plain(org.bukkit.inventory.ItemStack item){return net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(item.getItemMeta().displayName());}
|
||||
private static org.bukkit.event.entity.PlayerDeathEvent death(FantasyStickLifecycleNativeTest.Actor actor){return death(actor,org.bukkit.damage.DamageType.GENERIC);}
|
||||
private static org.bukkit.event.entity.PlayerDeathEvent death(FantasyStickLifecycleNativeTest.Actor actor,org.bukkit.damage.DamageType type){var source=new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(type)));return new org.bukkit.event.entity.PlayerDeathEvent(actor.player,source,new ArrayList<>(),0,net.kyori.adventure.text.Component.empty(),true);}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ class DungeonNativeTest {
|
||||
var enabled=directory.create("Enabled Depths",targetWorld,-20.5,55.25,-33.75,-135f,22f).get(5,TimeUnit.SECONDS);
|
||||
var room=directory.addRadiusRoom(enabled.id(),"Boss",-20,54,-34,2,3).get(5,TimeUnit.SECONDS);
|
||||
directory.setBoss(enabled.id(),room.id(),EntityType.HUSK,"Keeper",-20,55,-34,0,0).get(5,TimeUnit.SECONDS);
|
||||
directory.setCompletionReward(enabled.id(),BigInteger.ZERO,BigInteger.ZERO).get(5,TimeUnit.SECONDS);
|
||||
enabled=directory.enable(enabled.id(),List.of()).get(5,TimeUnit.SECONDS);
|
||||
var before=directory.snapshot();var teleports=new ArrayList<Location>();
|
||||
doReturn(java.util.concurrent.CompletableFuture.completedFuture(mock(Chunk.class))).when(destination.world).getChunkAtAsync(anyInt(),anyInt());
|
||||
@@ -60,30 +61,30 @@ class DungeonNativeTest {
|
||||
}
|
||||
|
||||
@SuppressWarnings({"unchecked","rawtypes"})
|
||||
@Test void registeredAuthoringQuestAcceptanceRoomRewardAndDirectBossClearAreConnected() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
|
||||
@Test void registeredAuthoringKillRewardsAndCompletionTrophiesAreConnected() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\nhostile-coin-drop-min: 6\nhostile-coin-drop-max: 6\n");
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);doReturn(true).when(actor.player).hasPermission("fantasystick.admin");
|
||||
var actorEffects=new HashMap<org.bukkit.potion.PotionEffectType,org.bukkit.potion.PotionEffect>();doAnswer(call->Set.copyOf(actorEffects.values())).when(actor.player).getActivePotionEffects();doAnswer(call->{var effect=(org.bukkit.potion.PotionEffect)call.getArgument(0);actorEffects.put(effect.getType(),effect);return true;}).when(actor.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());doAnswer(call->{actorEffects.remove((org.bukkit.potion.PotionEffectType)call.getArgument(0));return null;}).when(actor.player).removePotionEffect(any(org.bukkit.potion.PotionEffectType.class));UUID worldId=UUID.randomUUID();when(actor.world.getUID()).thenReturn(worldId);when(actor.world.getName()).thenReturn("Dungeon Realm");when(actor.world.isChunkLoaded(anyInt(),anyInt())).thenReturn(true);
|
||||
var spawned=new ArrayList<LivingEntity>();
|
||||
doAnswer(call->{Class<?> type=call.getArgument(1);LivingEntity entity=mock(type.asSubclass(LivingEntity.class));var pdc=new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());when(entity.getPersistentDataContainer()).thenReturn(pdc);when(entity.getUniqueId()).thenReturn(UUID.randomUUID());when(entity.getWorld()).thenReturn(actor.world);when(entity.getLocation()).thenReturn(((Location)call.getArgument(0)).clone());((java.util.function.Consumer<LivingEntity>)call.getArgument(2)).accept(entity);spawned.add(entity);return entity;}).when(actor.world).spawn(any(Location.class),any(Class.class),any(java.util.function.Consumer.class));
|
||||
doAnswer(call->{Class<?> type=call.getArgument(1);LivingEntity entity=mock(type.asSubclass(LivingEntity.class));var pdc=new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());when(entity.getPersistentDataContainer()).thenReturn(pdc);when(entity.getUniqueId()).thenReturn(UUID.randomUUID());when(entity.getType()).thenReturn(type==Husk.class?EntityType.HUSK:EntityType.VINDICATOR);when(entity.getWorld()).thenReturn(actor.world);when(entity.getLocation()).thenReturn(((Location)call.getArgument(0)).clone());((java.util.function.Consumer<LivingEntity>)call.getArgument(2)).accept(entity);spawned.add(entity);return entity;}).when(actor.world).spawn(any(Location.class),any(Class.class),any(java.util.function.Consumer.class));
|
||||
when(actor.world.getEntities()).thenAnswer(call->new ArrayList<Entity>(spawned));
|
||||
assertEquals(List.of("dungeon"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"dung"}));var denied=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(denied.core.id,denied.player);doReturn(false).when(denied.player).hasPermission("fantasystick.admin");f.command.execute(denied.player,"fantasystick",new String[]{"dungeon","create","Forbidden"});assertTrue(denied.messages.getLast().contains("Permission denied"));assertTrue(f.command.tabComplete(denied.player,"fantasystick",new String[]{"dungeon",""}).isEmpty());
|
||||
command(f,actor,"dungeon","create","Sunken","Keep");var dungeons=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var dungeon=dungeons.snapshot().dungeons().getFirst();
|
||||
command(f,actor,"dungeon","create","Sunken","Keep");var dungeons=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var dungeon=dungeons.snapshot().dungeons().getFirst();command(f,actor,"dungeon","reward",dungeon.id().toString());assertTrue(actor.messages.getLast().contains("not configured"));
|
||||
command(f,actor,"dungeon","edit","start",dungeon.id().toString());var dungeonController=(games.dmg.fantasystick.dungeon.DungeonController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeonController");await(f,()->dungeonController.editing(actor.core.id).isPresent(),actor.messages);
|
||||
command(f,actor,"dungeon","room","create","corner","4","4","3","Entry");await(f,()->!dungeons.find(dungeon.id()).orElseThrow().rooms().isEmpty(),actor.messages);var first=dungeons.find(dungeon.id()).orElseThrow().rooms().getFirst();
|
||||
command(f,actor,"dungeon","room","mob","add","husk","Guard A");command(f,actor,"dungeon","room","mob","add","husk","Guard B");command(f,actor,"dungeon","room","effect","add","players","speed","1","false");command(f,actor,"dungeon","room","reward","set","50","10");
|
||||
command(f,actor,"dungeon","room","mob","add","husk","Guard A");command(f,actor,"dungeon","room","mob","add","husk","Guard B");command(f,actor,"dungeon","room","effect","add","players","speed","1","false");
|
||||
actor.core.location.setX(actor.core.location.getBlockX()+4.2);
|
||||
command(f,actor,"dungeon","room","create","corner","3","4","3","Boss");var bossRoom=dungeons.find(dungeon.id()).orElseThrow().rooms().getLast();
|
||||
command(f,actor,"dungeon","room","reward","set","30","5");command(f,actor,"dungeon","room","boss","mob","vindicator","Keeper");
|
||||
actor.particleCalls.clear();pulse(f);assertFalse(actor.particleCalls.isEmpty());assertTrue(actor.particleCalls.size()<=1024);assertTrue(actor.particleCalls.stream().allMatch(call->call[0]==Particle.DUST));var particlePreferences=(games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"feedbackPreferences");assertTrue(particlePreferences.publish(particlePreferences.setAllParticles(actor.core.id,false).toCompletableFuture().get(5,TimeUnit.SECONDS)));actor.particleCalls.clear();pulse(f);assertTrue(actor.particleCalls.isEmpty(),"All-particle opt-out suppresses dungeon-editor boundaries");assertTrue(particlePreferences.publish(particlePreferences.setAllParticles(actor.core.id,true).toCompletableFuture().get(5,TimeUnit.SECONDS)));var outsider=new FantasyStickLifecycleNativeTest.Actor(data);outsider.core.location.setWorld(actor.world);f.players.put(outsider.core.id,outsider.player);doReturn(true).when(outsider.player).hasPermission("fantasystick.admin");command(f,outsider,"dungeon","edit","start",dungeon.id().toString());assertTrue(outsider.messages.getLast().contains("editing lease"));assertTrue(outsider.particleCalls.isEmpty());
|
||||
command(f,actor,"dungeon","reward",dungeon.id().toString(),"30","5");command(f,actor,"dungeon","reward",dungeon.id().toString());assertTrue(actor.messages.getLast().contains("30 XP and 5 coins"));assertEquals(List.of("30"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"dungeon","reward",dungeon.id().toString(),"3"}));assertEquals(List.of("5"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"dungeon","reward",dungeon.id().toString(),"30","5"}));f.command.execute(actor.player,"fantasystick",new String[]{"dungeon","room","reward","set","99","99"});assertTrue(actor.messages.getLast().contains("Usage:"));assertEquals(BigInteger.valueOf(30),dungeons.find(dungeon.id()).orElseThrow().completionReward().xp());command(f,actor,"dungeon","room","boss","mob","vindicator","Keeper");actor.core.location.setX(actor.core.location.getX()+3.2);command(f,actor,"dungeon","room","create","corner","2","4","3","Skipped");var skipped=dungeons.find(dungeon.id()).orElseThrow().rooms().getLast();
|
||||
actor.particleCalls.clear();pulse(f);assertFalse(actor.particleCalls.isEmpty());assertTrue(actor.particleCalls.size()<=1024);assertTrue(actor.particleCalls.stream().allMatch(call->call[0]==Particle.DUST));var particlePreferences=(games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"feedbackPreferences");assertTrue(particlePreferences.publish(particlePreferences.setAllParticles(actor.core.id,false).toCompletableFuture().get(5,TimeUnit.SECONDS)));actor.particleCalls.clear();pulse(f);assertTrue(actor.particleCalls.isEmpty(),"All-particle opt-out suppresses dungeon-editor boundaries");assertTrue(particlePreferences.publish(particlePreferences.setAllParticles(actor.core.id,true).toCompletableFuture().get(5,TimeUnit.SECONDS)));var outsider=new FantasyStickLifecycleNativeTest.Actor(data);outsider.core.location.setWorld(actor.world);f.players.put(outsider.core.id,outsider.player);doReturn(true).when(outsider.player).hasPermission("fantasystick.admin");command(f,outsider,"dungeon","edit","start",dungeon.id().toString());assertTrue(outsider.messages.getLast().contains("editing lease"));f.command.execute(outsider.player,"fantasystick",new String[]{"dungeon","reward",dungeon.id().toString(),"90","90"});assertTrue(outsider.messages.getLast().contains("another administrator"));assertEquals(BigInteger.valueOf(30),dungeons.find(dungeon.id()).orElseThrow().completionReward().xp());assertTrue(outsider.particleCalls.isEmpty());
|
||||
command(f,actor,"dungeon","edit","done");actor.particleCalls.clear();pulse(f);assertTrue(actor.particleCalls.isEmpty());command(f,actor,"dungeon","enable",dungeon.id().toString());command(f,actor,"dungeon","open",dungeon.id().toString());
|
||||
var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");assertTrue(adventures.snapshot().catalog().sites().contains(dungeon.id()));assertEquals(new AdventureQuestDirectory.Reward(BigInteger.valueOf(80),BigInteger.valueOf(15)),adventures.reward(adventures.findSite(dungeon.id()).orElseThrow()));
|
||||
f.activate(actor);var questController=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");questController.open(actor.player);drain(f);var menu=actor.inventories.getLast();assertEquals(Material.PAPER,menu.getItem(0).getType());var click=mock(InventoryClickEvent.class);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getInventory()).thenReturn(menu);when(click.getCurrentItem()).thenReturn(menu.getItem(0));questController.click(click);drain(f);assertEquals(dungeon.id(),adventures.active().getFirst().site());var partyA=new FantasyStickLifecycleNativeTest.Actor(data);var partyB=new FantasyStickLifecycleNativeTest.Actor(data);for(var member:List.of(partyA,partyB)){member.core.location.setWorld(actor.world);member.core.location.setX(100);f.players.put(member.core.id,member.player);doReturn(java.util.Set.of()).when(member.player).getActivePotionEffects();doReturn(true).when(member.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());f.activate(member);}var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(partyA.core.id).orElseThrow();assertTrue(parties.accept(partyB.core.id,parties.invite(partyA.core.id,party.id(),partyB.core.id).orElseThrow().id()));var catalog=adventures.snapshot().catalog();assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.PARTY,party.id(),Set.of(partyA.core.record.id(),partyB.core.record.id())).get(5,TimeUnit.SECONDS).status());
|
||||
var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");assertTrue(adventures.snapshot().catalog().sites().contains(dungeon.id()));assertEquals(new AdventureQuestDirectory.Reward(BigInteger.valueOf(30),BigInteger.valueOf(5)),adventures.reward(adventures.findSite(dungeon.id()).orElseThrow()));
|
||||
f.activate(actor);var questController=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");questController.open(actor.player);drain(f);var menu=actor.inventories.getLast();assertEquals(Material.PAPER,menu.getItem(0).getType());var offerLore=menu.getItem(0).getItemMeta().lore().toString();assertTrue(offerLore.contains("Completion trophy pool: 30 XP, 5 coins"));assertTrue(offerLore.contains("additional variable XP, coins and native loot"));var click=mock(InventoryClickEvent.class);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getInventory()).thenReturn(menu);when(click.getCurrentItem()).thenReturn(menu.getItem(0));questController.click(click);drain(f);assertEquals(dungeon.id(),adventures.active().getFirst().site());var partyA=new FantasyStickLifecycleNativeTest.Actor(data);var partyB=new FantasyStickLifecycleNativeTest.Actor(data);for(var member:List.of(partyA,partyB)){member.core.location.setWorld(actor.world);member.core.location.setX(100);f.players.put(member.core.id,member.player);doReturn(java.util.Set.of()).when(member.player).getActivePotionEffects();doReturn(true).when(member.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());f.activate(member);}var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(partyA.core.id).orElseThrow();assertTrue(parties.accept(partyB.core.id,parties.invite(partyA.core.id,party.id(),partyB.core.id).orElseThrow().id()));var catalog=adventures.snapshot().catalog();assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.PARTY,party.id(),Set.of(partyA.core.record.id(),partyB.core.record.id())).get(5,TimeUnit.SECONDS).status());
|
||||
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var before=storage.find(actor.core.record.id()).get().orElseThrow();var partyABefore=storage.find(partyA.core.record.id()).get().orElseThrow();var partyBBefore=storage.find(partyB.core.record.id()).get().orElseThrow();for(var member:List.of(actor,partyA)){member.core.location.setX(first.bounds().minX()+.5);member.core.location.setY(first.bounds().minY()+1);member.core.location.setZ(first.bounds().minZ()+.5);}
|
||||
pulse(f);drain(f);pulse(f);drain(f);var initialMobs=spawned.stream().filter(Husk.class::isInstance).toList();assertEquals(2,initialMobs.size());var ordinary=initialMobs.getFirst();var survivor=initialMobs.getLast();var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);var partySource=mock(org.bukkit.damage.DamageSource.class);when(partySource.getCausingEntity()).thenReturn(partyA.player);when(partySource.getDirectEntity()).thenReturn(partyA.player);f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,ordinary,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,ordinary,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));var drops=new ArrayList<ItemStack>();drops.add(new ItemStack(Material.ROTTEN_FLESH));var ordinaryDeath=new EntityDeathEvent(ordinary,source,drops,7);f.dispatcher.accept(ordinaryDeath);drain(f);assertEquals(7,ordinaryDeath.getDroppedExp());assertFalse(ordinaryDeath.getDrops().isEmpty());var partial=dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow();assertEquals(1,partial.defeatedSlots().size());assertFalse(partial.clearedRooms().contains(first.id()));for(var member:List.of(actor,partyA))member.core.location.setX(100);pulse(f);verify(survivor,atLeastOnce()).remove();assertFalse(actorEffects.containsKey(org.bukkit.potion.PotionEffectType.SPEED));for(var member:List.of(actor,partyA)){member.core.location.setX(first.bounds().minX()+.5);member.core.location.setY(first.bounds().minY()+1);member.core.location.setZ(first.bounds().minZ()+.5);}pulse(f);drain(f);var respawned=spawned.stream().filter(Husk.class::isInstance).toList().getLast();assertNotSame(survivor,respawned);f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,respawned,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,respawned,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));f.dispatcher.accept(new EntityDeathEvent(respawned,source,new ArrayList<>(),0));await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow().clearedRooms().contains(first.id()),actor.messages);assertEquals(partyBBefore.experience(),storage.find(partyB.core.record.id()).get().orElseThrow().experience(),"Late party member receives no retroactive room reward");
|
||||
for(var member:List.of(actor,partyA,partyB)){member.core.location.setX(bossRoom.bounds().minX()+.5);member.core.location.setY(bossRoom.bounds().minY()+1);member.core.location.setZ(bossRoom.bounds().minZ()+.5);}pulse(f);drain(f);var firstBoss=spawned.stream().filter(Vindicator.class::isInstance).findFirst().orElseThrow();for(var member:List.of(actor,partyA,partyB))member.core.location.setX(100);pulse(f);verify(firstBoss,atLeastOnce()).remove();for(var member:List.of(actor,partyA,partyB)){member.core.location.setX(bossRoom.bounds().minX()+.5);member.core.location.setY(bossRoom.bounds().minY()+1);member.core.location.setZ(bossRoom.bounds().minZ()+.5);}pulse(f);drain(f);var boss=spawned.stream().filter(Vindicator.class::isInstance).toList().getLast();assertNotSame(firstBoss,boss);f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,boss,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,boss,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDeathEvent(boss,source,new ArrayList<>(),0));
|
||||
await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).map(DungeonState.Run::defeated).orElse(false)&&adventures.active().isEmpty(),actor.messages);var run=dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow();assertTrue(run.defeated(),"dungeon run not defeated");assertTrue(adventures.active().isEmpty(),"boss did not close all dungeon quests");assertTrue(adventures.characterClaimed(catalog.revision(),dungeon.id(),actor.core.record.id()),"solo completion claim missing");assertTrue(adventures.characterClaimed(catalog.revision(),dungeon.id(),partyA.core.record.id()),"represented party member claim missing");assertTrue(adventures.characterClaimed(catalog.revision(),dungeon.id(),partyB.core.record.id()),"present snapshotted party member claim missing");var after=storage.find(actor.core.record.id()).get().orElseThrow();assertEquals(before.experience(),after.experience());assertEquals(before.coins(),after.coins());assertEquals(new AdventureQuestDirectory.Reward(BigInteger.valueOf(80),BigInteger.valueOf(15)),adventures.unclaimed(catalog.revision(),dungeon.id(),actor.core.record.id()));var partyAAfter=storage.find(partyA.core.record.id()).get().orElseThrow();var partyBAfter=storage.find(partyB.core.record.id()).get().orElseThrow();assertEquals(partyABefore.experience(),partyAAfter.experience());assertEquals(partyBBefore.experience(),partyBAfter.experience());var bankedA=adventures.unclaimed(catalog.revision(),dungeon.id(),partyA.core.record.id());var bankedB=adventures.unclaimed(catalog.revision(),dungeon.id(),partyB.core.record.id());assertEquals(BigInteger.valueOf(80),bankedA.xp().add(bankedB.xp()));assertEquals(BigInteger.valueOf(15),bankedA.coins().add(bankedB.coins()));assertTrue(bankedA.xp().signum()>0&&bankedB.xp().signum()>0,"both present party members require banked shares");var defeatedGeneration=adventures.snapshot().catalog().revision();command(f,actor,"dungeon","close",dungeon.id().toString(),"confirm");command(f,actor,"dungeon","open",dungeon.id().toString());assertTrue(actor.messages.getLast().contains("open refused"),actor.messages.toString());assertTrue(dungeons.runView(dungeon.id(),defeatedGeneration).orElseThrow().defeated(),"defeated generation changed");command(f,actor,"adventure","reroll","confirm");await(f,()->adventures.snapshot().reset()==null&&!adventures.snapshot().catalog().revision().equals(defeatedGeneration),actor.messages);assertTrue(dungeons.snapshot().runs().isEmpty(),"full reset retained dungeon run");command(f,actor,"dungeon","open",dungeon.id().toString());var resetCatalog=adventures.snapshot().catalog();assertTrue(resetCatalog.sites().contains(dungeon.id()),"reopened dungeon absent from replacement catalog");assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(resetCatalog.revision(),dungeon.id(),AdventureQuestState.UnitType.SOLO,actor.core.record.id(),Set.of(actor.core.record.id())).get(5,TimeUnit.SECONDS).status());command(f,actor,"dungeon","edit","start",dungeon.id().toString());assertTrue(actor.messages.getLast().contains("exact confirm"),actor.messages.toString());assertFalse(adventures.active().isEmpty(),"fresh dungeon quest missing");command(f,actor,"dungeon","edit","start",dungeon.id().toString(),"confirm");await(f,()->dungeonController.editing(actor.core.id).isPresent(),actor.messages);assertTrue(adventures.active().isEmpty(),"confirmed edit retained accepted quest");assertFalse(dungeons.find(dungeon.id()).orElseThrow().enabled());
|
||||
pulse(f);drain(f);pulse(f);drain(f);var initialMobs=spawned.stream().filter(Husk.class::isInstance).toList();assertEquals(2,initialMobs.size());var ordinary=initialMobs.getFirst();assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,dungeonController.encounterOrigin(ordinary).orElseThrow().kind());var survivor=initialMobs.getLast();assertNotEquals(dungeonController.encounterOrigin(ordinary),dungeonController.encounterOrigin(survivor),"each slot has independent kill authority");var survivorOrigin=dungeonController.encounterOrigin(survivor);var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);var partySource=mock(org.bukkit.damage.DamageSource.class);when(partySource.getCausingEntity()).thenReturn(partyA.player);when(partySource.getDirectEntity()).thenReturn(partyA.player);f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,ordinary,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,ordinary,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));var drops=new ArrayList<ItemStack>();drops.add(new ItemStack(Material.ROTTEN_FLESH));var ordinaryDeath=new EntityDeathEvent(ordinary,source,drops,7);f.dispatcher.accept(ordinaryDeath);await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).map(run->run.defeatedSlots().size()==1).orElse(false),actor.messages);assertEquals(7,ordinaryDeath.getDroppedExp());assertFalse(ordinaryDeath.getDrops().isEmpty());var partial=dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow();assertEquals(1,partial.defeatedSlots().size());assertFalse(partial.clearedRooms().contains(first.id()));for(var member:List.of(actor,partyA))member.core.location.setX(100);pulse(f);verify(survivor,atLeastOnce()).remove();assertFalse(actorEffects.containsKey(org.bukkit.potion.PotionEffectType.SPEED));for(var member:List.of(actor,partyA)){member.core.location.setX(first.bounds().minX()+.5);member.core.location.setY(first.bounds().minY()+1);member.core.location.setZ(first.bounds().minZ()+.5);}pulse(f);drain(f);var respawned=spawned.stream().filter(Husk.class::isInstance).toList().getLast();assertNotSame(survivor,respawned);assertEquals(survivorOrigin,dungeonController.encounterOrigin(respawned),"respawned slot retains duplicate-safe kill authority");f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,respawned,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,respawned,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));f.dispatcher.accept(new EntityDeathEvent(respawned,source,new ArrayList<>(),0));await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow().clearedRooms().contains(first.id()),actor.messages);assertTrue(storage.find(partyB.core.record.id()).get().orElseThrow().experience().compareTo(partyBBefore.experience())>0,"ordinary party combat sharing remains active");
|
||||
var actorAfterKills=storage.find(actor.core.record.id()).get().orElseThrow();var partyAAfterKills=storage.find(partyA.core.record.id()).get().orElseThrow();var partyBAfterKills=storage.find(partyB.core.record.id()).get().orElseThrow();for(var member:List.of(actor,partyA,partyB)){member.core.location.setX(bossRoom.bounds().minX()+.5);member.core.location.setY(bossRoom.bounds().minY()+1);member.core.location.setZ(bossRoom.bounds().minZ()+.5);}pulse(f);drain(f);var firstBoss=spawned.stream().filter(Vindicator.class::isInstance).findFirst().orElseThrow();for(var member:List.of(actor,partyA,partyB))member.core.location.setX(100);pulse(f);verify(firstBoss,atLeastOnce()).remove();for(var member:List.of(actor,partyA,partyB)){member.core.location.setX(bossRoom.bounds().minX()+.5);member.core.location.setY(bossRoom.bounds().minY()+1);member.core.location.setZ(bossRoom.bounds().minZ()+.5);}pulse(f);drain(f);var boss=spawned.stream().filter(Vindicator.class::isInstance).toList().getLast();assertNotSame(firstBoss,boss);f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,boss,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,boss,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDeathEvent(boss,source,new ArrayList<>(),0));assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,dungeonController.encounterOrigin(boss).orElseThrow().kind(),"boss cleanup retains immutable managed origin authority");
|
||||
await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).map(DungeonState.Run::defeated).orElse(false)&&adventures.active().isEmpty()&&storage.find(actor.core.record.id()).join().orElseThrow().experience().compareTo(actorAfterKills.experience())>0,actor.messages);var run=dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow();assertTrue(run.defeated(),"dungeon run not defeated");assertFalse(run.clearedRooms().contains(skipped.id()),"boss death completes without clearing every room");assertTrue(adventures.active().isEmpty(),"boss did not close all dungeon quests");assertTrue(adventures.characterClaimed(catalog.revision(),dungeon.id(),actor.core.record.id()),"solo completion claim missing");assertTrue(adventures.characterClaimed(catalog.revision(),dungeon.id(),partyA.core.record.id()),"represented party member claim missing");assertTrue(adventures.characterClaimed(catalog.revision(),dungeon.id(),partyB.core.record.id()),"present snapshotted party member claim missing");var after=storage.find(actor.core.record.id()).get().orElseThrow();assertTrue(after.experience().compareTo(before.experience())>0,"ordinary dungeon kills award standard combat XP");assertTrue(after.coins().compareTo(before.coins())>0,"ordinary dungeon kills award standard character coins");assertTrue(after.experience().compareTo(actorAfterKills.experience())>0,"genuinely killed dungeon boss grants native-classified per-kill XP");assertTrue(after.coins().compareTo(actorAfterKills.coins())>0,"genuinely killed dungeon boss grants native-classified per-kill coins");assertEquals(AdventureQuestDirectory.Reward.empty(),adventures.unclaimed(catalog.revision(),dungeon.id(),actor.core.record.id()));var partyAAfter=storage.find(partyA.core.record.id()).get().orElseThrow();var partyBAfter=storage.find(partyB.core.record.id()).get().orElseThrow();assertTrue(partyAAfter.experience().compareTo(partyABefore.experience())>0,"party contributor receives normal kill XP");assertTrue(partyBAfter.experience().compareTo(partyBBefore.experience())>0);assertTrue(partyAAfter.experience().compareTo(partyAAfterKills.experience())>0);assertTrue(partyBAfter.experience().compareTo(partyBAfterKills.experience())>0);var trophies=(games.dmg.fantasystick.adventure.FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");var soloTrophy=trophies.trophies(actor.core.record.id()).getFirst();var partyTrophy=trophies.trophies(partyA.core.record.id()).getFirst();assertEquals(games.dmg.fantasystick.adventure.FishingState.TrophyType.DUNGEON,soloTrophy.type());assertEquals("Trophy of Sunken Keep",soloTrophy.name());assertEquals(new games.dmg.fantasystick.adventure.FishingState.Reward(BigInteger.valueOf(30),BigInteger.valueOf(5)),soloTrophy.total());assertEquals(new games.dmg.fantasystick.adventure.FishingState.Reward(BigInteger.valueOf(30),BigInteger.valueOf(5)),partyTrophy.total());assertEquals(Set.of(partyA.core.record.id(),partyB.core.record.id()),partyTrophy.rewards().keySet());assertEquals(AdventureQuestDirectory.Reward.empty(),adventures.unclaimed(catalog.revision(),dungeon.id(),partyA.core.record.id()));var defeatedGeneration=adventures.snapshot().catalog().revision();command(f,actor,"dungeon","close",dungeon.id().toString(),"confirm");command(f,actor,"dungeon","open",dungeon.id().toString());assertTrue(actor.messages.getLast().contains("open refused"),actor.messages.toString());assertTrue(dungeons.runView(dungeon.id(),defeatedGeneration).orElseThrow().defeated(),"defeated generation changed");command(f,actor,"adventure","reroll","confirm");await(f,()->adventures.snapshot().reset()==null&&!adventures.snapshot().catalog().revision().equals(defeatedGeneration),actor.messages);assertEquals(1,trophies.trophies(actor.core.record.id()).size(),"reroll preserves earned dungeon trophy");var claimBase=storage.find(actor.core.record.id()).get().orElseThrow();var turn=(games.dmg.fantasystick.adventure.FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");turn.open(actor.player);var trophyMenu=actor.inventories.getLast();var plain=net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText();var redeem=java.util.stream.IntStream.range(0,trophyMenu.getSize()).mapToObj(trophyMenu::getItem).filter(Objects::nonNull).filter(item->item.getItemMeta().displayName()!=null&&plain.serialize(item.getItemMeta().displayName()).equals("Redeem Trophy of Sunken Keep")).findFirst().orElseThrow();var redeemClick=mock(InventoryClickEvent.class);when(redeemClick.getWhoClicked()).thenReturn(actor.player);when(redeemClick.getInventory()).thenReturn(trophyMenu);when(redeemClick.getCurrentItem()).thenReturn(redeem);turn.click(redeemClick);await(f,()->trophies.trophies(actor.core.record.id()).isEmpty()&&trophies.unclaimed(actor.core.record.id()).equals(new games.dmg.fantasystick.adventure.FishingState.Reward(BigInteger.valueOf(30),BigInteger.valueOf(5))),actor.meLine truncated
|
||||
|
||||
}
|
||||
}
|
||||
@@ -91,7 +92,7 @@ class DungeonNativeTest {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\ndungeon-room-link-distance: 10\n");
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);doReturn(true).when(actor.player).hasPermission("fantasystick.admin");when(actor.world.getUID()).thenReturn(UUID.randomUUID());
|
||||
command(f,actor,"dungeon","create","Feedback");var dungeons=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var dungeon=dungeons.snapshot().dungeons().getFirst();command(f,actor,"dungeon","edit","start",dungeon.id().toString());var controller=(games.dmg.fantasystick.dungeon.DungeonController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeonController");await(f,()->controller.editing(actor.core.id).isPresent(),actor.messages);
|
||||
command(f,actor,"dungeon","create","Feedback");var dungeons=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");await(f,()->!dungeons.snapshot().dungeons().isEmpty(),actor.messages);var dungeon=dungeons.snapshot().dungeons().getFirst();command(f,actor,"dungeon","edit","start",dungeon.id().toString());var controller=(games.dmg.fantasystick.dungeon.DungeonController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeonController");await(f,()->controller.editing(actor.core.id).isPresent(),actor.messages);
|
||||
command(f,actor,"dungeon","room","create","corner","1","1","1","First");await(f,()->dungeons.find(dungeon.id()).orElseThrow().rooms().size()==1,actor.messages);var first=dungeons.find(dungeon.id()).orElseThrow().rooms().getFirst();
|
||||
command(f,actor,"dungeon","room","create","corner","1","1","1","Overlap");await(f,()->actor.messages.stream().anyMatch(message->message.contains("overlaps an existing room")),actor.messages);
|
||||
actor.core.location.setX(first.bounds().maxX()+11.1);actor.core.location.setY(first.bounds().minY()+1);actor.core.location.setZ(first.bounds().minZ()+.5);command(f,actor,"dungeon","room","create","corner","1","1","1","Too Far");await(f,()->actor.messages.stream().anyMatch(message->message.contains("configured 10-block link distance")),actor.messages);
|
||||
@@ -115,10 +116,10 @@ class DungeonNativeTest {
|
||||
var admin=new FantasyStickLifecycleNativeTest.Actor(root);f.players.put(admin.core.id,admin.player);doReturn(true).when(admin.player).hasPermission("fantasystick.admin");var bossStats=new java.util.EnumMap<games.dmg.fantasystick.progression.Stat,BigInteger>(games.dmg.fantasystick.progression.Stat.class);for(var stat:games.dmg.fantasystick.progression.Stat.values())bossStats.put(stat,BigInteger.valueOf(30));var abilities=List.of("fireball","frost_nova","hex","second_wind","purify","stone_skin","guardian_bond");var configuredBoss=new games.dmg.fantasystick.persistence.CharacterRecord(admin.core.record.id(),admin.core.record.name(),BigInteger.ONE,bossStats,admin.core.record.level(),admin.core.record.experience(),admin.core.record.unspentPoints(),java.math.BigDecimal.valueOf(40),Set.copyOf(abilities),abilities,List.of("speed_i","low_light_vision","quicker_regeneration_i"),Map.of("fireball",java.time.Instant.parse("2099-01-01T00:00:00Z")),Map.of(),false,null,null,admin.core.record.coins(),admin.core.record.coinsInitialized(),admin.core.record.rewardReceipts(),admin.core.record.pvpCoinEscrow(),new games.dmg.fantasystick.persistence.CharacterGear(games.dmg.fantasystick.persistence.GearTier.COPPER,games.dmg.fantasystick.persistence.GearTier.GOLD,games.dmg.fantasystick.persistence.GearTier.IRON));new games.dmg.fantasystick.persistence.YamlCharacterRepository(root).replace(configuredBoss,BigInteger.ZERO);
|
||||
UUID worldId=UUID.randomUUID();when(admin.world.getUID()).thenReturn(worldId);when(admin.world.isChunkLoaded(anyInt(),anyInt())).thenReturn(true);
|
||||
var spawned=new ArrayList<Vindicator>();var maximumBases=new IdentityHashMap<Vindicator,double[]>();var attackBases=new IdentityHashMap<Vindicator,double[]>();var bossEffects=new IdentityHashMap<Vindicator,Set<org.bukkit.potion.PotionEffect>>();var bossEquipment=new IdentityHashMap<Vindicator,org.bukkit.inventory.EntityEquipment>();
|
||||
doAnswer(call->{var mob=mock(Vindicator.class);var pdc=new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());var maximum=mock(org.bukkit.attribute.AttributeInstance.class);var damage=mock(org.bukkit.attribute.AttributeInstance.class);var equipment=mock(org.bukkit.inventory.EntityEquipment.class);var maximumBase=new double[]{20};var attackBase=new double[]{1};var health=new double[]{20};var effects=new LinkedHashSet<org.bukkit.potion.PotionEffect>();effects.add(new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.WEAKNESS,200,0));maximumBases.put(mob,maximumBase);attackBases.put(mob,attackBase);bossEffects.put(mob,effects);bossEquipment.put(mob,equipment);when(mob.getPersistentDataContainer()).thenReturn(pdc);when(mob.getUniqueId()).thenReturn(UUID.randomUUID());when(mob.getLocation()).thenReturn(((Location)call.getArgument(0)).clone());when(mob.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).thenReturn(maximum);when(mob.getAttribute(org.bukkit.attribute.Attribute.ATTACK_DAMAGE)).thenReturn(damage);when(maximum.getValue()).thenAnswer(ignored->maximumBase[0]);doAnswer(invocation->{maximumBase[0]=(Double)invocation.getArgument(0);return null;}).when(maximum).setBaseValue(anyDouble());doAnswer(invocation->{attackBase[0]=(Double)invocation.getArgument(0);return null;}).when(damage).setBaseValue(anyDouble());when(mob.getHealth()).thenAnswer(ignored->health[0]);doAnswer(invocation->{health[0]=(Double)invocation.getArgument(0);return null;}).when(mob).setHealth(anyDouble());when(mob.getEquipment()).thenReturn(equipment);when(mob.hasLineOfSight(any(Entity.class))).thenReturn(true);when(mob.getActivePotionEffects()).thenAnswer(ignored->Set.copyOf(effects));doAnswer(invocation->{effects.add(invocation.getArgument(0));return true;}).when(mob).addPotionEffect(any(org.bukkit.potion.PotionEffect.class));doAnswer(invocation->{effects.add(invocation.getArgument(0));return true;}).when(mob).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());doAnswer(invocation->{effects.removeIf(effect->effect.getType().equals(invocation.getArgument(0)));return null;}).when(mob).removePotionEffect(any(org.bukkit.potion.PotionEffectType.class));((java.util.function.Consumer<Vindicator>)call.getArgument(2)).accept(mob);spawned.add(mob);return mob;}).when(admin.world).spawn(any(Location.class),eq(Vindicator.class),any(java.util.function.Consumer.class));
|
||||
doAnswer(call->{var mob=mock(Vindicator.class);var pdc=new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());var maximum=mock(org.bukkit.attribute.AttributeInstance.class);var damage=mock(org.bukkit.attribute.AttributeInstance.class);var equipment=mock(org.bukkit.inventory.EntityEquipment.class);var maximumBase=new double[]{20};var attackBase=new double[]{1};var health=new double[]{20};var effects=new LinkedHashSet<org.bukkit.potion.PotionEffect>();effects.add(new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.WEAKNESS,200,0));maximumBases.put(mob,maximumBase);attackBases.put(mob,attackBase);bossEffects.put(mob,effects);bossEquipment.put(mob,equipment);when(mob.getPersistentDataContainer()).thenReturn(pdc);when(mob.getUniqueId()).thenReturn(UUID.randomUUID());when(mob.getType()).thenReturn(EntityType.VINDICATOR);when(mob.getWorld()).thenReturn(admin.world);when(mob.getLocation()).thenReturn(((Location)call.getArgument(0)).clone());when(mob.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).thenReturn(maximum);when(mob.getAttribute(org.bukkit.attribute.Attribute.ATTACK_DAMAGE)).thenReturn(damage);when(maximum.getValue()).thenAnswer(ignored->maximumBase[0]);doAnswer(invocation->{maximumBase[0]=(Double)invocation.getArgument(0);return null;}).when(maximum).setBaseValue(anyDouble());doAnswer(invocation->{attackBase[0]=(Double)invocation.getArgument(0);return null;}).when(damage).setBaseValue(anyDouble());when(mob.getHealth()).thenAnswer(ignored->health[0]);doAnswer(invocation->{health[0]=(Double)invocation.getArgument(0);return null;}).when(mob).setHealth(anyDouble());when(mob.getEquipment()).thenReturn(equipment);when(mob.hasLineOfSight(any(Entity.class))).thenReturn(true);when(mob.getActivePotionEffects()).thenAnswer(ignored->Set.copyOf(effects));doAnswer(invocation->{effects.add(invocation.getArgument(0));return true;}).when(mob).addPotionEffect(any(org.bukkit.potion.PotionEffect.class));doAnswer(invocation->{effects.add(invocation.getArgument(0));return true;}).when(mob).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());doAnswer(invocation->{effects.removeIf(effect->effect.getType().equals(invocation.getArgument(0)));return null;}).when(mob).removePotionEffect(any(org.bukkit.potion.PotionEffectType.class));((java.util.function.Consumer<Vindicator>)call.getArgument(2)).accept(mob);spawned.add(mob);return mob;}).when(admin.world).spawn(any(Location.class),eq(Vindicator.class),any(java.util.function.Consumer.class));
|
||||
when(admin.world.getEntities()).thenAnswer(ignored->new ArrayList<Entity>(spawned));
|
||||
var dungeons=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var dungeon=dungeons.create("Mirror",worldId,0,64,0,0,0).get(5,TimeUnit.SECONDS);var room=dungeons.addRadiusRoom(dungeon.id(),"Boss",0,64,0,3,3).get(5,TimeUnit.SECONDS);dungeons.setBoss(dungeon.id(),room.id(),EntityType.HUSK,"Temporary",0,65,0,0,0).get(5,TimeUnit.SECONDS);command(f,admin,"dungeon","edit","start",dungeon.id().toString());var controller=(games.dmg.fantasystick.dungeon.DungeonController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeonController");await(f,()->controller.editing(admin.core.id).isPresent(),admin.messages);admin.core.location.setX(.5);admin.core.location.setY(65);admin.core.location.setZ(.5);var sheet=admin.core.inventory.getItemInOffHand();admin.core.inventory.setItemInOffHand(null);admin.core.inventory.setItemInMainHand(sheet);command(f,admin,"dungeon","room","boss","assign-sheet","2");await(f,()->dungeons.custody(admin.core.record.id()).isPresent(),admin.messages);command(f,admin,"dungeon","edit","done");command(f,admin,"dungeon","enable",dungeon.id().toString());command(f,admin,"dungeon","open",dungeon.id().toString());
|
||||
var fighter=new FantasyStickLifecycleNativeTest.Actor(root);fighter.core.location.setWorld(admin.world);fighter.core.location.setX(.5);fighter.core.location.setY(65);fighter.core.location.setZ(.5);f.players.put(fighter.core.id,fighter.player);doReturn(false).when(fighter.player).isDead();doReturn(true).when(fighter.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());var abilityHits=new ArrayList<Double>();fighter.damage=(amount,source)->abilityHits.add(amount);f.activate(fighter);var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");var catalog=adventures.snapshot().catalog();assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.SOLO,fighter.core.record.id(),Set.of(fighter.core.record.id())).get(5,TimeUnit.SECONDS).status());var challenger=new FantasyStickLifecycleNativeTest.Actor(root);challenger.core.location.setWorld(admin.world);challenger.core.location.setX(1.5);challenger.core.location.setY(65);challenger.core.location.setZ(.5);f.players.put(challenger.core.id,challenger.player);doReturn(false).when(challenger.player).isDead();doReturn(true).when(challenger.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());challenger.damage=(amount,sourceEntity)->{};f.activate(challenger);assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.SOLO,challenger.core.record.id(),Set.of(challenger.core.record.id())).get(5,TimeUnit.SECONDS).status());pulse(f);drain(f);pulse(f);drain(f);assertFalse(spawned.isEmpty());var boss=spawned.getLast();assertEquals(70d,maximumBases.get(boss)[0]);assertEquals(26d,attackBases.get(boss)[0]);var equipment=bossEquipment.get(boss);verify(equipment).setChestplate(argThat(item->item!=null&&item.getType()==Material.COPPER_CHESTPLATE));verify(equipment).setLeggings(argThat(item->item!=null&&item.getType()==Material.GOLDEN_LEGGINGS));verify(equipment).setBoots(argThat(item->item!=null&&item.getType()==Material.IRON_BOOTS));assertTrue(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.SPEED)),bossEffects.get(boss)::toString);assertTrue(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.NIGHT_VISION)),bossEffects.get(boss)::toString);boss.setHealth(50);var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(fighter.player);when(source.getDirectEntity()).thenReturn(fighter.player);f.dispatcher.accept(new EntityDamageByEntityEvent(fighter.player,boss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3));pulse(f);verify(boss,atLeastOnce()).setTarget(fighter.player);assertTrue(abilityHits.size()>=2,"Every ready offensive slot casts in the same pulse");int firstCasts=abilityHits.size();pulse(f);assertEquals(firstCasts,abilityHits.size(),"Boss-only cooldowns suppress immediate recasts");assertFalse(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.WEAKNESS)),"Purify removes a real harmful effect");var challengerSource=mock(org.bukkit.damage.DamageSource.class);when(challengerSource.getCausingEntity()).thenReturn(challenger.player);when(challengerSource.getDirectEntity()).thenReturn(challenger.player);f.dispatcher.accept(new EntityDamageByEntityEvent(challenger.player,boss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,challengerSource,10));pulse(f);var targets=org.mockito.ArgumentCaptor.forClass(LivingEntity.class);verify(boss,atLeastOnce()).setTarget(targets.capture());assertSame(fighter.player,targets.getAllValues().getLast(),"Higher later threat cannot break fixation");fighter.core.location.setX(100);pulse(f);targets=org.mockito.ArgumentCaptor.forClass(LivingEntity.class);verify(boss,atLeastOnce()).setTarget(targets.capture());assertSame(challenger.player,targets.getAllValues().getLast(),"Ineligible fixation retargets highest remaining threat");
|
||||
var dungeons=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var dungeon=dungeons.create("Mirror",worldId,0,64,0,0,0).get(5,TimeUnit.SECONDS);var room=dungeons.addRadiusRoom(dungeon.id(),"Boss",0,64,0,3,3).get(5,TimeUnit.SECONDS);dungeons.setBoss(dungeon.id(),room.id(),EntityType.HUSK,"Temporary",0,65,0,0,0).get(5,TimeUnit.SECONDS);dungeons.setCompletionReward(dungeon.id(),BigInteger.ZERO,BigInteger.ZERO).get(5,TimeUnit.SECONDS);command(f,admin,"dungeon","edit","start",dungeon.id().toString());var controller=(games.dmg.fantasystick.dungeon.DungeonController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeonController");await(f,()->controller.editing(admin.core.id).isPresent(),admin.messages);admin.core.location.setX(.5);admin.core.location.setY(65);admin.core.location.setZ(.5);var sheet=admin.core.inventory.getItemInOffHand();admin.core.inventory.setItemInOffHand(null);admin.core.inventory.setItemInMainHand(sheet);command(f,admin,"dungeon","room","boss","assign-sheet","2");await(f,()->dungeons.custody(admin.core.record.id()).isPresent(),admin.messages);command(f,admin,"dungeon","edit","done");command(f,admin,"dungeon","enable",dungeon.id().toString());command(f,admin,"dungeon","open",dungeon.id().toString());var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");await(f,()->adventures.snapshot().catalog()!=null&&adventures.snapshot().catalog().sites().contains(dungeon.id()),admin.messages);
|
||||
var fighter=new FantasyStickLifecycleNativeTest.Actor(root);fighter.core.location.setWorld(admin.world);fighter.core.location.setX(.5);fighter.core.location.setY(65);fighter.core.location.setZ(.5);f.players.put(fighter.core.id,fighter.player);doReturn(false).when(fighter.player).isDead();doReturn(true).when(fighter.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());var abilityHits=new ArrayList<Double>();fighter.damage=(amount,source)->abilityHits.add(amount);f.activate(fighter);var catalog=adventures.snapshot().catalog();assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.SOLO,fighter.core.record.id(),Set.of(fighter.core.record.id())).get(5,TimeUnit.SECONDS).status());var challenger=new FantasyStickLifecycleNativeTest.Actor(root);challenger.core.location.setWorld(admin.world);challenger.core.location.setX(1.5);challenger.core.location.setY(65);challenger.core.location.setZ(.5);f.players.put(challenger.core.id,challenger.player);doReturn(false).when(challenger.player).isDead();doReturn(true).when(challenger.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());challenger.damage=(amount,sourceEntity)->{};f.activate(challenger);assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.SOLO,challenger.core.record.id(),Set.of(challenger.core.record.id())).get(5,TimeUnit.SECONDS).status());pulse(f);drain(f);pulse(f);drain(f);assertFalse(spawned.isEmpty());var boss=spawned.getLast();assertEquals(70d,maximumBases.get(boss)[0]);assertEquals(26d,attackBases.get(boss)[0]);var equipment=bossEquipment.get(boss);verify(equipment).setChestplate(argThat(item->item!=null&&item.getType()==Material.COPPER_CHESTPLATE));verify(equipment).setLeggings(argThat(item->item!=null&&item.getType()==Material.GOLDEN_LEGGINGS));verify(equipment).setBoots(argThat(item->item!=null&&item.getType()==Material.IRON_BOOTS));assertTrue(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.SPEED)),bossEffects.get(boss)::toString);assertTrue(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.NIGHT_VISION)),bossEffects.get(boss)::toString);boss.setHealth(50);var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(fighter.player);when(source.getDirectEntity()).thenReturn(fighter.player);f.dispatcher.accept(new EntityDamageByEntityEvent(fighter.player,boss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3));pulse(f);verify(boss,atLeastOnce()).setTarget(fighter.player);assertTrue(abilityHits.size()>=2,"Every ready offensive slot casts in the same pulse");int firstCasts=abilityHits.size();pulse(f);assertEquals(firstCasts,abilityHits.size(),"Boss-only cooldowns suppress immediate recasts");assertFalse(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.WEAKNESS)),"Purify removes a real harmful effect");var challengerSource=mock(org.bukkit.damage.DamageSource.class);when(challengerSource.getCausingEntity()).thenReturn(challenger.player);when(challengerSource.getDirectEntity()).thenReturn(challenger.player);f.dispatcher.accept(new EntityDamageByEntityEvent(challenger.player,boss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,challengerSource,10));pulse(f);var targets=org.mockito.ArgumentCaptor.forClass(LivingEntity.class);verify(boss,atLeastOnce()).setTarget(targets.capture());assertSame(fighter.player,targets.getAllValues().getLast(),"Higher later threat cannot break fixation");fighter.core.location.setX(100);pulse(f);targets=org.mockito.ArgumentCaptor.forClass(LivingEntity.class);verify(boss,atLeastOnce()).setTarget(targets.capture());assertSame(challenger.player,targets.getAllValues().getLast(),"Ineligible fixation retargets highest remaining threat");
|
||||
challenger.core.location.setX(100);pulse(f);verify(boss,atLeastOnce()).remove();challenger.core.location.setX(.5);pulse(f);drain(f);var freshBoss=spawned.getLast();assertNotSame(boss,freshBoss);assertEquals(70d,maximumBases.get(freshBoss)[0]);f.dispatcher.accept(new EntityDamageByEntityEvent(challenger.player,freshBoss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,challengerSource,2));f.dispatcher.accept(new EntityDeathEvent(freshBoss,challengerSource,new ArrayList<>(),0));await(f,()->dungeons.runView(dungeon.id(),catalog.revision()).map(DungeonState.Run::defeated).orElse(false),admin.messages);assertTrue(dungeons.custody(admin.core.record.id()).isPresent(),"Boss defeat keeps character custody for later days");challenger.core.location.setX(100);command(f,admin,"dungeon","disable",dungeon.id().toString(),"confirm");admin.core.location.setX(100);command(f,admin,"dungeon","boss","recover-sheet",dungeon.id().toString());await(f,()->dungeons.custody(admin.core.record.id()).isEmpty(),admin.messages);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(configuredBoss,storage.find(configuredBoss.id()).get(5,TimeUnit.SECONDS).orElseThrow());assertTrue(Arrays.stream(admin.core.inventory.getContents()).filter(Objects::nonNull).anyMatch(item->new games.dmg.fantasystick.items.CharacterSheets().identify(item).filter(configuredBoss.id()::equals).isPresent()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,34 @@ class EquipmentAdminCommandNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void registeredRootsKeepPlayerAndAdministratorGuidanceSeparated() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var administrator = new Sender();
|
||||
f.adminCommand.execute(administrator.port, "fstickadmin", new String[]{});
|
||||
assertTrue(administrator.messages.getLast().startsWith("Usage: /fstickadmin"));
|
||||
assertTrue(administrator.messages.getLast().contains("adventure-site"));
|
||||
assertTrue(administrator.messages.getLast().contains("character view"));
|
||||
administrator.messages.clear();
|
||||
f.adminCommand.execute(administrator.port, "fstickadmin", new String[]{"inspect"});
|
||||
assertTrue(administrator.messages.getLast().startsWith("Usage: /fstickadmin"));
|
||||
|
||||
var player = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(player.core.id, player.player);
|
||||
assertEquals(List.of("adventure", "feedback", "particles"),
|
||||
f.playerCommand.tabComplete(player.player, "fstick", new String[]{""}));
|
||||
assertEquals(List.of("reroll"),
|
||||
f.adminCommand.tabComplete(administrator.port, "fstickadmin", new String[]{"adventure", ""}));
|
||||
assertFalse(f.adminCommand.tabComplete(administrator.port, "fstickadmin", new String[]{""})
|
||||
.stream().anyMatch(route -> route.equals("feedback") || route.equals("particles")));
|
||||
assertTrue(f.playerCommand.tabComplete(player.player, "fstick", new String[]{"adventure", "r"}).isEmpty());
|
||||
assertTrue(f.playerCommand.tabComplete(player.player, "fstick", new String[]{"config", ""}).isEmpty());
|
||||
assertTrue(f.playerCommand.execute(player.player, "fstick", new String[]{"adventure", "reroll", "confirm"}));
|
||||
assertTrue(player.messages.getLast().startsWith("Usage: /fstick adventure"));
|
||||
assertFalse(String.join("\n", player.messages).contains("fstickadmin"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void registeredGivePersistsOneFreshSheetThenDeliversActivatesAndReloads() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
@@ -53,7 +81,7 @@ class EquipmentAdminCommandNativeTest {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var target = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(target.core.id,target.player);
|
||||
var sender=new Sender();
|
||||
assertEquals(List.of("adventure-site","config","dungeon","experience","give","inspect","party-registrar","quest-giver","recover","shop","training-dummy"),f.command.tabComplete(sender.port,"fantasystick",new String[]{""}));
|
||||
assertEquals(List.of("adventure-site","config","dungeon","experience","give","inspect","party-registrar","quest-giver","rare-shop","recover","shop","training-dummy"),f.command.tabComplete(sender.port,"fantasystick",new String[]{""}));
|
||||
assertEquals(List.of("give"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"g"}));
|
||||
assertEquals(List.of("Account"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"give",""}));
|
||||
assertEquals(List.of("Account"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"give","Acc"}));
|
||||
@@ -94,7 +122,7 @@ class EquipmentAdminCommandNativeTest {
|
||||
f.players.put(first.core.id, first.player); f.players.put(second.core.id, second.player); f.players.put(offline.core.id, offline.player);
|
||||
var sender = new Sender();
|
||||
var expected = java.util.stream.Stream.of(first.core.id.toString(), second.core.id.toString()).sorted().toList();
|
||||
assertEquals(List.of("adventure-site", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "recover", "shop", "training-dummy"), f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
|
||||
assertEquals(List.of("adventure-site", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "rare-shop", "recover", "shop", "training-dummy"), f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
|
||||
assertEquals(List.of("inspect"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"in"}));
|
||||
assertEquals(List.of("recover"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"rec"}));
|
||||
assertEquals(expected, f.command.tabComplete(sender.port, "fantasystick", new String[]{"inspect", ""}));
|
||||
@@ -109,7 +137,7 @@ class EquipmentAdminCommandNativeTest {
|
||||
assertEquals(List.of("true"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "adventure-mode", "t"}));
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "adventure-mode", "true", ""}).isEmpty());
|
||||
sender.allowed = false;
|
||||
assertEquals(List.of("adventure"), f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
|
||||
assertTrue(f.command.tabComplete(sender.port, "fstickadmin", new String[]{""}).isEmpty());
|
||||
for (String[] args : List.of(new String[]{"inspect", ""}, new String[]{"config", ""},
|
||||
new String[]{"config", "adventure-mode", ""})) {
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", args).isEmpty());
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.events.EventCaptureListener;
|
||||
import games.dmg.fantasystick.events.LocalEventLog;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
final class EventCaptureNativeTest {
|
||||
@TempDir Path data;
|
||||
@org.junit.jupiter.api.BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void meaningfulNativeActionFamiliesProduceOutcomesWhileMovementTicksHaveNoHandler() throws Exception {
|
||||
var clock=Clock.fixed(Instant.parse("2026-09-24T03:31:00Z"),ZoneOffset.UTC);var sessions=mock(EquipmentSessions.class);var player=mock(Player.class);var world=mock(World.class);var playerId=UUID.randomUUID();var active=new EquipmentSessions.Active(playerId,UUID.randomUUID(),UUID.randomUUID());var character=mock(CharacterRecord.class);when(player.getUniqueId()).thenReturn(playerId);when(player.getName()).thenReturn("Player");when(world.getUID()).thenReturn(UUID.randomUUID());when(world.getName()).thenReturn("world");when(player.getLocation()).thenReturn(new Location(world,1,64,2));when(sessions.active(playerId)).thenReturn(Optional.of(active));when(sessions.currentRecord(active)).thenReturn(Optional.of(character));when(character.name()).thenReturn("Hero");
|
||||
try(var log=LocalEventLog.open(data,clock,new LocalEventLog.RuntimeInfo("Season 4","26.2","test"),64,134_217_728L,message->fail(message))){log.ready().toCompletableFuture().get();var listener=new EventCaptureListener(log,sessions,clock);
|
||||
var target=mock(org.bukkit.entity.Entity.class);when(target.getUniqueId()).thenReturn(UUID.randomUUID());when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);var entity=mock(org.bukkit.event.player.PlayerInteractEntityEvent.class);when(entity.getPlayer()).thenReturn(player);when(entity.getHand()).thenReturn(EquipmentSlot.HAND);when(entity.getRightClicked()).thenReturn(target);listener.observe(entity);listener.resolve(entity);
|
||||
var inventory=mock(org.bukkit.event.inventory.InventoryClickEvent.class);when(inventory.getWhoClicked()).thenReturn(player);when(inventory.getRawSlot()).thenReturn(4);when(inventory.getClick()).thenReturn(org.bukkit.event.inventory.ClickType.LEFT);when(inventory.getAction()).thenReturn(org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);listener.observe(inventory);listener.resolve(inventory);
|
||||
var item=mock(org.bukkit.entity.Item.class);when(item.getUniqueId()).thenReturn(UUID.randomUUID());when(item.getItemStack()).thenReturn(new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE));var drop=mock(org.bukkit.event.player.PlayerDropItemEvent.class);when(drop.getPlayer()).thenReturn(player);when(drop.getItemDrop()).thenReturn(item);listener.observe(drop);listener.resolve(drop);
|
||||
var pickup=mock(org.bukkit.event.entity.EntityPickupItemEvent.class);when(pickup.getEntity()).thenReturn(player);when(pickup.getItem()).thenReturn(item);when(pickup.getRemaining()).thenReturn(0);listener.observe(pickup);listener.resolve(pickup);
|
||||
var held=mock(org.bukkit.event.player.PlayerItemHeldEvent.class);when(held.getPlayer()).thenReturn(player);when(held.getPreviousSlot()).thenReturn(0);when(held.getNewSlot()).thenReturn(1);listener.observe(held);listener.resolve(held);
|
||||
var swap=mock(org.bukkit.event.player.PlayerSwapHandItemsEvent.class);when(swap.getPlayer()).thenReturn(player);listener.observe(swap);listener.resolve(swap);
|
||||
var teleport=mock(org.bukkit.event.player.PlayerTeleportEvent.class);when(teleport.getPlayer()).thenReturn(player);when(teleport.getFrom()).thenReturn(new Location(world,1,64,2));when(teleport.getTo()).thenReturn(new Location(world,3,65,4));when(teleport.getCause()).thenReturn(org.bukkit.event.player.PlayerTeleportEvent.TeleportCause.PLUGIN);listener.observe(teleport);listener.resolve(teleport);
|
||||
var vehicle=mock(org.bukkit.entity.Vehicle.class);when(vehicle.getUniqueId()).thenReturn(UUID.randomUUID());when(vehicle.getType()).thenReturn(org.bukkit.entity.EntityType.MINECART);var enter=mock(org.bukkit.event.vehicle.VehicleEnterEvent.class);when(enter.getEntered()).thenReturn(player);when(enter.getVehicle()).thenReturn(vehicle);listener.observe(enter);listener.resolve(enter);var exit=mock(org.bukkit.event.vehicle.VehicleExitEvent.class);when(exit.getExited()).thenReturn(player);when(exit.getVehicle()).thenReturn(vehicle);listener.observe(exit);listener.resolve(exit);
|
||||
}
|
||||
String joined;try(var files=Files.walk(data.resolve("events"))){var text=new StringBuilder();for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())text.append(Files.readString(file));joined=text.toString();}
|
||||
for(String kind:java.util.List.of("interact_entity","inventory_click","drop_item","pickup_item","held_slot","swap_hands","teleport","vehicle_enter","vehicle_exit"))assertEquals(2,count(joined,"\"action_kind\":\""+kind+"\""),kind);
|
||||
assertTrue(joined.contains("\"from_location\""));assertTrue(joined.contains("\"to_location\""));assertTrue(java.util.Arrays.stream(EventCaptureListener.class.getDeclaredMethods()).noneMatch(method->java.util.Arrays.asList(method.getParameterTypes()).contains(org.bukkit.event.player.PlayerMoveEvent.class)));
|
||||
}
|
||||
|
||||
@Test void protectedDragAndConsumeCaptureExactItemDowningAndFinalCancellationContext() throws Exception {
|
||||
var clock=Clock.fixed(Instant.parse("2026-09-25T01:20:00Z"),ZoneOffset.UTC);var sessions=mock(EquipmentSessions.class);var player=mock(Player.class);var world=mock(World.class);var playerId=UUID.randomUUID();var active=new EquipmentSessions.Active(playerId,UUID.randomUUID(),UUID.randomUUID());var character=mock(CharacterRecord.class);var episode=mock(games.dmg.fantasystick.session.DownedEpisode.class);var episodeId=UUID.randomUUID();when(player.getUniqueId()).thenReturn(playerId);when(player.getName()).thenReturn("Guarded");when(world.getUID()).thenReturn(UUID.randomUUID());when(world.getName()).thenReturn("world");when(player.getLocation()).thenReturn(new Location(world,2,70,3));when(sessions.active(playerId)).thenReturn(Optional.of(active));when(sessions.currentRecord(active)).thenReturn(Optional.of(character));when(character.name()).thenReturn("Frozen Hero");when(sessions.downed(playerId)).thenReturn(Optional.of(episode));when(episode.id()).thenReturn(episodeId);
|
||||
var control=new org.bukkit.inventory.ItemStack(org.bukkit.Material.STICK);var meta=control.getItemMeta();meta.getPersistentDataContainer().set(new org.bukkit.NamespacedKey("fantasystick","control_version"),org.bukkit.persistence.PersistentDataType.INTEGER,1);meta.getPersistentDataContainer().set(new org.bukkit.NamespacedKey("fantasystick","control_kind"),org.bukkit.persistence.PersistentDataType.STRING,"basic_attack");meta.getPersistentDataContainer().set(new org.bukkit.NamespacedKey("fantasystick","control_owner_id"),org.bukkit.persistence.PersistentDataType.STRING,playerId.toString());meta.getPersistentDataContainer().set(new org.bukkit.NamespacedKey("fantasystick","control_session_id"),org.bukkit.persistence.PersistentDataType.STRING,active.session().toString());control.setItemMeta(meta);
|
||||
try(var log=LocalEventLog.open(data,clock,new LocalEventLog.RuntimeInfo("Season 4","26.2","test"),16,134_217_728L,message->fail(message))){log.ready().toCompletableFuture().get();var listener=new EventCaptureListener(log,sessions,clock);
|
||||
var drag=mock(org.bukkit.event.inventory.InventoryDragEvent.class);when(drag.getWhoClicked()).thenReturn(player);when(drag.getOldCursor()).thenReturn(control);when(drag.getNewItems()).thenReturn(java.util.Map.of(4,control));listener.observe(drag);when(drag.isCancelled()).thenReturn(true);listener.resolve(drag);
|
||||
var consume=mock(org.bukkit.event.player.PlayerItemConsumeEvent.class);when(consume.getPlayer()).thenReturn(player);when(consume.getItem()).thenReturn(control);listener.observe(consume);when(consume.isCancelled()).thenReturn(true);listener.resolve(consume);
|
||||
var craft=mock(org.bukkit.event.inventory.CraftItemEvent.class);when(craft.getWhoClicked()).thenReturn(player);when(craft.getRawSlot()).thenReturn(0);when(craft.getClick()).thenReturn(org.bukkit.event.inventory.ClickType.LEFT);when(craft.getAction()).thenReturn(org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);when(craft.getCurrentItem()).thenReturn(control);listener.observe(craft);when(craft.isCancelled()).thenReturn(true);listener.resolve(craft);
|
||||
var armor=mock(org.bukkit.event.block.BlockDispenseArmorEvent.class);when(armor.getTargetEntity()).thenReturn(player);when(armor.getItem()).thenReturn(control);listener.observe(armor);when(armor.isCancelled()).thenReturn(true);listener.resolve(armor);
|
||||
var rod=control.clone();rod.setType(org.bukkit.Material.FISHING_ROD);var rodMeta=rod.getItemMeta();rodMeta.getPersistentDataContainer().set(games.dmg.fantasystick.adventure.AdventureQuestNavigation.FISHING_QUEST,org.bukkit.persistence.PersistentDataType.STRING,UUID.randomUUID().toString());rod.setItemMeta(rodMeta);var launch=mock(com.destroystokyo.paper.event.player.PlayerLaunchProjectileEvent.class);when(launch.getPlayer()).thenReturn(player);when(launch.getItemStack()).thenReturn(rod);listener.observe(launch);when(launch.isCancelled()).thenReturn(true);listener.resolve(launch);
|
||||
var duplicate=mock(PlayerInteractEvent.class);when(duplicate.getPlayer()).thenReturn(player);when(duplicate.getHand()).thenReturn(EquipmentSlot.OFF_HAND);listener.observe(duplicate);listener.resolve(duplicate);
|
||||
}
|
||||
var records=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}
|
||||
var guarded=records.stream().filter(event->event.get("type").getAsString().contains("action.")).toList();assertEquals(10,guarded.size());for(var event:guarded){var value=event.getAsJsonObject("data");assertEquals(episodeId.toString(),value.get("downing_id").getAsString());assertEquals("downed",value.get("guard_reason").getAsString());}assertEquals(java.util.Set.of("inventory_drag","consume_item","inventory_click","dispense_armor","launch_projectile"),guarded.stream().map(event->event.getAsJsonObject("data").get("action_kind").getAsString()).collect(java.util.stream.Collectors.toSet()));assertTrue(guarded.stream().map(event->event.getAsJsonObject("data")).anyMatch(value->value.get("item_kind").getAsString().equals("quest_fishing_rod")));assertTrue(guarded.stream().map(event->event.getAsJsonObject("data")).filter(value->value.get("item_kind").getAsString().equals("control")).allMatch(value->value.get("control_kind").getAsString().equals("basic_attack")));
|
||||
}
|
||||
|
||||
private static int count(String value,String sought){int count=0;for(int at=0;(at=value.indexOf(sought,at))>=0;at+=sought.length())count++;return count;}
|
||||
|
||||
@Test void activeCharacterNativeInteractionProducesCorrelatedObservedAndCancelledRecords() throws Exception {
|
||||
var clock = Clock.fixed(Instant.parse("2026-09-24T03:30:00Z"), ZoneOffset.UTC);
|
||||
var sessions = mock(EquipmentSessions.class);
|
||||
var player = mock(Player.class);
|
||||
var world = mock(World.class);
|
||||
var block = mock(Block.class);
|
||||
var event = mock(PlayerInteractEvent.class);
|
||||
var playerId = UUID.randomUUID();
|
||||
var characterId = UUID.randomUUID();
|
||||
var sessionId = UUID.randomUUID();
|
||||
var active = new EquipmentSessions.Active(playerId, sessionId, characterId);
|
||||
var character = mock(CharacterRecord.class);
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(player.getName()).thenReturn("NativePlayer");
|
||||
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
when(world.getName()).thenReturn("world");
|
||||
when(player.getLocation()).thenReturn(new Location(world, 1.5, 65, -2.5));
|
||||
when(block.getType()).thenReturn(org.bukkit.Material.CHEST);
|
||||
when(event.getPlayer()).thenReturn(player);
|
||||
when(event.getHand()).thenReturn(EquipmentSlot.HAND);
|
||||
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
|
||||
when(event.getClickedBlock()).thenReturn(block);
|
||||
when(sessions.active(playerId)).thenReturn(Optional.of(active));
|
||||
when(sessions.currentRecord(active)).thenReturn(Optional.of(character));
|
||||
when(character.name()).thenReturn("Native Hero");
|
||||
|
||||
try (var log = LocalEventLog.open(data, clock,
|
||||
new LocalEventLog.RuntimeInfo("Season 4", "26.2", "test"), 16, 134_217_728L,
|
||||
message -> fail(message))) {
|
||||
log.ready().toCompletableFuture().get();
|
||||
var listener = new EventCaptureListener(log, sessions, clock);
|
||||
when(event.isCancelled()).thenReturn(false);
|
||||
listener.observe(event);
|
||||
when(event.isCancelled()).thenReturn(true);
|
||||
listener.resolve(event);
|
||||
verify(event,never()).setCancelled(anyBoolean());
|
||||
}
|
||||
|
||||
String joined;
|
||||
try (var files = Files.walk(data.resolve("events"))) {
|
||||
var builder = new StringBuilder();
|
||||
for (var file : files.filter(path -> path.getFileName().toString().endsWith(".jsonl")).toList()) {
|
||||
builder.append(Files.readString(file, StandardCharsets.UTF_8));
|
||||
}
|
||||
joined = builder.toString();
|
||||
}
|
||||
assertTrue(joined.contains("games.dmg.minecraft.fantasystick.action.observed"));
|
||||
assertTrue(joined.contains("games.dmg.minecraft.fantasystick.action.resolved"));
|
||||
assertTrue(joined.contains("\"action_kind\":\"interact_block\""));
|
||||
assertTrue(joined.contains("\"target_type\":\"CHEST\""));
|
||||
assertTrue(joined.contains("\"outcome\":\"cancelled\""));
|
||||
assertTrue(joined.contains("\"characterid\":\"" + characterId + "\""));
|
||||
var actionIds = java.util.regex.Pattern.compile("\\\"action_id\\\":\\\"([^\\\"]+)").matcher(joined).results()
|
||||
.map(result -> result.group(1)).toList();
|
||||
assertTrue(actionIds.stream().anyMatch(id -> java.util.Collections.frequency(actionIds, id) == 2));
|
||||
}
|
||||
}
|
||||
@@ -49,10 +49,10 @@ class FantasyFeedbackCommandNativeTest {
|
||||
assertTrue(fixture.feedbackCommand.tabComplete(console.port, "fantasyfeedback", new String[]{""}).isEmpty());
|
||||
var player = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(player.core.id, player.player);
|
||||
assertFalse(fixture.feedbackCommand.getExecutor().onCommand(player.player, fixture.feedbackCommand,
|
||||
"fantasyfeedback", new String[]{"OFF"}));
|
||||
assertFalse(fixture.feedbackCommand.getExecutor().onCommand(player.player, fixture.feedbackCommand,
|
||||
"fantasyfeedback", new String[]{"off", "extra"}));
|
||||
assertFalse(fixture.playerCommand.getExecutor().onCommand(player.player, fixture.playerCommand,
|
||||
"fstick", new String[]{"feedback", "OFF"}));
|
||||
assertFalse(fixture.playerCommand.getExecutor().onCommand(player.player, fixture.playerCommand,
|
||||
"fstick", new String[]{"feedback", "off", "extra"}));
|
||||
assertTrue(((games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences"))
|
||||
.enabled(player.core.id));
|
||||
|
||||
@@ -85,8 +85,8 @@ class FantasyHealthNativeTest {
|
||||
org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(org.bukkit.damage.DamageType.OUT_OF_WORLD)));
|
||||
var death = new org.bukkit.event.entity.PlayerDeathEvent(a.player, source, new java.util.ArrayList<>(), 0,
|
||||
net.kyori.adventure.text.Component.empty(), true);
|
||||
f.dispatcher.accept(death); assertTrue(death.isCancelled());
|
||||
PartyBookNativeTest.drain(f);
|
||||
f.dispatcher.accept(death); DownedSessionNativeTest.acceptDeath(f,a); assertTrue(death.isCancelled());
|
||||
for(int attempt=0;attempt<80&&a.core.inventory.getItemInOffHand().getType()!=org.bukkit.Material.WRITTEN_BOOK;attempt++){var task=f.tasks.poll(20,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}
|
||||
assertEquals(1, receivedDust(a, org.bukkit.Color.RED), "Aura opt-out must not hide the defeat burst");
|
||||
assertTrue(a.particleCalls.stream().anyMatch(call -> java.util.Arrays.asList(call).contains(defeatLocation)));
|
||||
assertEquals(entry, a.player.getLocation()); assertEquals(28, a.player.getHealth());
|
||||
@@ -110,13 +110,13 @@ class FantasyHealthNativeTest {
|
||||
viewer.core.inventory.setItemInOffHand(null); viewer.core.location.setWorld(defeated.world);
|
||||
f.players.put(defeated.core.id, defeated.player); f.players.put(viewer.core.id, viewer.player);
|
||||
f.activate(defeated);
|
||||
f.particlesCommand.execute(defeated.player, "fantasyparticles", new String[]{"all", "off"}); f.tick();
|
||||
PartyBookNativeTest.disableAllParticles(f,defeated);
|
||||
defeated.particleCalls.clear(); viewer.particleCalls.clear(); defeated.messages.clear();
|
||||
var source = new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(
|
||||
org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(org.bukkit.damage.DamageType.OUT_OF_WORLD)));
|
||||
var death = new org.bukkit.event.entity.PlayerDeathEvent(defeated.player, source,
|
||||
new java.util.ArrayList<>(), 0, net.kyori.adventure.text.Component.empty(), true);
|
||||
f.dispatcher.accept(death); PartyBookNativeTest.drain(f);
|
||||
f.dispatcher.accept(death); DownedSessionNativeTest.acceptDeath(f,defeated); awaitTail(f, defeated);
|
||||
assertTrue(death.isCancelled());
|
||||
assertEquals(0, receivedDust(defeated, org.bukkit.Color.RED));
|
||||
assertEquals(1, receivedDust(viewer, org.bukkit.Color.RED));
|
||||
@@ -125,6 +125,8 @@ class FantasyHealthNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static void awaitTail(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor)throws Exception{for(int attempt=0;attempt<20;attempt++){PartyBookNativeTest.drain(f);var item=actor.core.inventory.getItemInOffHand();if(item!=null&&item.getType()==org.bukkit.Material.WRITTEN_BOOK)return;Thread.sleep(5);}fail("Timed out waiting for durable Tail publication");}
|
||||
|
||||
@Test void uncappedConstitutionEntersAtTheNativeCeilingAndPersistsOnlyBoundedRemainingHealth() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
@@ -329,7 +331,7 @@ class FantasyHealthNativeTest {
|
||||
assertEquals(44, a.player.getHealthScale()); assertTrue(a.player.isHealthScaled());
|
||||
assertEquals(org.bukkit.Material.DIAMOND_SWORD, a.core.inventory.getItem(0).getType());
|
||||
var saved = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data.resolve("lifecycle-" + quit)).load(a.core.record.id()).orElseThrow();
|
||||
assertFalse(saved.dead()); assertEquals(0, saved.health().compareTo(java.math.BigDecimal.valueOf(4))); assertEquals(0, a.core.teleports);
|
||||
assertFalse(saved.dead());assertEquals(0,saved.health().compareTo(java.math.BigDecimal.valueOf(4)),"quit="+quit+", health="+saved.health());assertEquals(0,a.core.teleports,"quit="+quit);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,8 +65,10 @@ class FantasyParticlesNativeTest {
|
||||
for (String[] invalid : List.of(new String[]{}, new String[]{"unknown"},
|
||||
new String[]{"players", "ON"}, new String[]{"all", "ON"},
|
||||
new String[]{"players", "on", "extra"}, new String[]{"all", "off", "extra"})) {
|
||||
assertFalse(fixture.particlesCommand.getExecutor().onCommand(player.player,
|
||||
fixture.particlesCommand, "fantasyparticles", invalid));
|
||||
var routed = java.util.stream.Stream.concat(java.util.stream.Stream.of("particles"),
|
||||
java.util.Arrays.stream(invalid)).toArray(String[]::new);
|
||||
assertFalse(fixture.playerCommand.getExecutor().onCommand(player.player,
|
||||
fixture.playerCommand, "fstick", routed));
|
||||
}
|
||||
|
||||
fixture.particlesCommand.execute(player.player, "fantasyparticles",
|
||||
|
||||
@@ -60,8 +60,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertTrue(fixture.order.indexOf("CharacterChatNames") >= 0);
|
||||
assertTrue(fixture.order.indexOf("CharacterChatNames") < recipe);
|
||||
assertNotNull(fixture.packetListener);
|
||||
assertEquals(15, fixture.timers.size(),
|
||||
"Presentation, ability/bond/rally/banner cleanup, provenance, reward retry, recovery, passive effects, armor/adventure/dungeon refresh and session particles");
|
||||
assertEquals(19, fixture.timers.size(),
|
||||
"Presentation, ability/bond/rally/banner cleanup, provenance, reward retry, recovery, passive effects, armor/rare/adventure/fishing/dungeon/downed refresh and session particles");
|
||||
assertTrue(fixture.order.indexOf("PartyCombat") >= 0);
|
||||
assertTrue(fixture.order.indexOf("PartyCombat") < recipe);
|
||||
assertTrue(fixture.order.indexOf("CombatExperience") >= 0);
|
||||
@@ -78,12 +78,59 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertTrue(fixture.order.indexOf("OutOfCombatRecovery") < recipe);
|
||||
assertTrue(fixture.order.indexOf("RecipeDiscovery") > recipe,
|
||||
"Discovery must not become active before successful recipe registration");
|
||||
assertNotNull(fixture.command);
|
||||
assertEquals("EquipmentAdminCommand", fixture.command.getExecutor().getClass().getSimpleName());
|
||||
assertNotNull(fixture.playerCommand);
|
||||
assertNotNull(fixture.adminCommand);
|
||||
assertEquals("FantasyPlayerCommand", fixture.playerCommand.getExecutor().getClass().getSimpleName());
|
||||
assertEquals("EquipmentAdminCommand", fixture.adminCommand.getExecutor().getClass().getSimpleName());
|
||||
assertNull(fixture.server.getPluginCommand("fantasystick"));
|
||||
assertNull(fixture.server.getPluginCommand("fantasyfeedback"));
|
||||
assertNull(fixture.server.getPluginCommand("fantasyparticles"));
|
||||
assertTrue(fixture.tasks.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unavailableEventStorageDoesNotChangeGameplayStartupOrWriteOutsideDataFolder() throws Exception {
|
||||
var outside=data.resolveSibling(data.getFileName()+"-outside");java.nio.file.Files.createDirectories(outside);java.nio.file.Files.createSymbolicLink(data.resolve("events"),outside);
|
||||
try(var f=new Fixture(data)){f.plugin.setEnabled(true);assertTrue(f.plugin.isEnabled());assertTrue(f.recipes.containsKey(CharacterSheets.RECIPE));assertFalse(f.order.contains("EventCaptureListener"));f.plugin.setEnabled(false);}
|
||||
try(var files=java.nio.file.Files.list(outside)){assertEquals(0,files.count());}
|
||||
}
|
||||
|
||||
@Test void packagedLifecycleWritesGenericCharacterActionCloudEventsWithoutAnotherPlugin() throws Exception {
|
||||
try (var f = new Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
assertTrue(f.order.contains("EventCaptureListener"));
|
||||
assertTrue(f.order.indexOf("EventCaptureListener") < f.order.indexOf("recipe"));
|
||||
var actor = new Actor(data); f.players.put(actor.core.id, actor.player); f.activate(actor);
|
||||
var event = new org.bukkit.event.player.PlayerInteractEvent(actor.player,
|
||||
org.bukkit.event.block.Action.LEFT_CLICK_AIR, actor.core.inventory.getItem(0), null,
|
||||
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
|
||||
f.dispatcher.accept(event);
|
||||
var duplicate = new org.bukkit.event.player.PlayerInteractEvent(actor.player,
|
||||
org.bukkit.event.block.Action.LEFT_CLICK_AIR, actor.core.inventory.getItem(0), null,
|
||||
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.OFF_HAND);
|
||||
f.dispatcher.accept(duplicate);
|
||||
f.plugin.setEnabled(false);
|
||||
String records;
|
||||
try (var files = java.nio.file.Files.walk(data.resolve("events"))) {
|
||||
var text = new StringBuilder();
|
||||
for (var file : files.filter(path -> path.getFileName().toString().endsWith(".jsonl")).toList()) {
|
||||
text.append(java.nio.file.Files.readString(file));
|
||||
}
|
||||
records = text.toString();
|
||||
}
|
||||
assertTrue(records.contains("games.dmg.minecraft.fantasystick.game.started"));
|
||||
assertTrue(records.contains("games.dmg.minecraft.fantasystick.action.observed"));
|
||||
assertTrue(records.contains("games.dmg.minecraft.fantasystick.action.resolved"));
|
||||
assertTrue(records.contains("games.dmg.minecraft.fantasystick.game.stopped"));
|
||||
assertTrue(records.contains("\"characterid\":\"" + actor.core.record.id() + "\""));
|
||||
assertTrue(records.contains("\"item_kind\":\"control\""));
|
||||
assertTrue(records.contains("\"control_kind\":\"book\""));
|
||||
assertTrue(records.contains("\"guard_reason\":\"protected_item\""));
|
||||
assertEquals(1, records.split("games.dmg.minecraft.fantasystick.action.observed", -1).length - 1,
|
||||
"The duplicate offhand callback must not create another logical observation");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void registeredBookInputOpensAuthoritativeNativePagesWithoutGivingOrChangingItems() throws Exception {
|
||||
try (var f = new Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
@@ -388,7 +435,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void packagedDeathRegistrationAndReentrantDisableKeepAlreadyOwnedCharacterSave() throws Exception {
|
||||
@Test void packagedDowningRegistrationAndReentrantDisableRetainDurableCountdown() throws Exception {
|
||||
for (boolean disable : new boolean[]{false, true}) {
|
||||
var directory = data.resolve("death-" + disable);
|
||||
try (var f = new Fixture(directory)) {
|
||||
@@ -407,30 +454,44 @@ class FantasyStickLifecycleNativeTest {
|
||||
});
|
||||
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
if (disable) a.core.mutation = () -> { a.core.mutation = () -> {}; f.plugin.setEnabled(false); };
|
||||
if(disable)a.core.mutation=()->{};
|
||||
var source = new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(
|
||||
org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(org.bukkit.damage.DamageType.OUT_OF_WORLD)));
|
||||
var death = new org.bukkit.event.entity.PlayerDeathEvent(a.player, source, new ArrayList<>(), 7,
|
||||
net.kyori.adventure.text.Component.text("ordinary death"), true);
|
||||
try {
|
||||
f.dispatcher.accept(death);
|
||||
if(disable&&f.plugin.isEnabled())f.plugin.setEnabled(false);
|
||||
if(!disable)DownedSessionNativeTest.acceptDeath(f,a);
|
||||
assertTrue(death.isCancelled()); assertFalse(death.getKeepInventory());
|
||||
assertRestored(f, a, viewer);
|
||||
assertEquals(a.core.normalMaximum, a.core.health);
|
||||
assertEquals(org.bukkit.Material.DIAMOND_SWORD, a.core.inventory.getItem(0).getType());
|
||||
if(!disable)assertRestored(f,a,viewer);
|
||||
assertEquals(disable?20:a.core.normalMaximum,a.core.health,"disable="+disable+", messages="+a.messages);
|
||||
if(!disable)assertEquals(org.bukkit.Material.DIAMOND_SWORD,a.core.inventory.getItem(0).getType());
|
||||
assertEquals(sheet, a.core.inventory.getItemInOffHand());
|
||||
if (disable) assertFalse(sessions.drained().toCompletableFuture().isDone());
|
||||
// Downing skips an ordinary character-health save, so shutdown may already be drained.
|
||||
} finally { release.countDown(); }
|
||||
gate.get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
if (!disable) {
|
||||
f.tick(); assertEquals(org.bukkit.Material.WRITTEN_BOOK, a.core.inventory.getItemInOffHand().getType());
|
||||
f.tick();DownedSessionNativeTest.awaitTail(f,a);
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.startsWith("§c")), "Red defeat feedback port");
|
||||
f.plugin.setEnabled(false);
|
||||
var records=new java.util.ArrayList<com.google.gson.JsonObject>();
|
||||
try(var files=java.nio.file.Files.walk(directory.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList()){for(var line:java.nio.file.Files.readAllLines(file)){if(line.isBlank())continue;records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}}}
|
||||
var types=records.stream().map(event->event.get("type").getAsString()).toList();
|
||||
assertTrue(types.contains("games.dmg.minecraft.fantasystick.character.downed"),types.toString());
|
||||
assertEquals(1,types.stream().filter("games.dmg.minecraft.fantasystick.character.downed_resolved"::equals).count(),types.toString());
|
||||
assertEquals(1,types.stream().filter("games.dmg.minecraft.fantasystick.character.died"::equals).count(),types.toString());
|
||||
assertTrue(types.contains("games.dmg.minecraft.fantasystick.action.resolved"),types.toString());
|
||||
var resolution=records.stream().filter(event->event.get("type").getAsString().endsWith("character.downed_resolved")).findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals("accept_death",resolution.get("outcome").getAsString());
|
||||
assertEquals(a.core.record.id().toString(),resolution.get("actor_character_id").getAsString());
|
||||
assertTrue(resolution.has("deadline"));assertTrue(resolution.has("lethal_cause"));
|
||||
assertFalse(types.stream().anyMatch(type->type.contains("phoenix")),types.toString());
|
||||
}
|
||||
f.awaitWorkers(); sessions.drained().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertTrue(new YamlCharacterRepository(directory).load(a.core.record.id()).orElseThrow().dead());
|
||||
assertEquals(1, a.core.teleports);
|
||||
if (disable) assertEquals(sheet, a.core.inventory.getItemInOffHand(), "No replacement on shutdown");
|
||||
assertEquals(!disable,new YamlCharacterRepository(directory).load(a.core.record.id()).orElseThrow().dead());
|
||||
assertEquals(disable?0:1, a.core.teleports);
|
||||
if (disable){assertEquals(sheet,a.core.inventory.getItemInOffHand(),"No replacement on shutdown");var retained=new games.dmg.fantasystick.session.DownedDirectory(directory,java.time.Clock.systemUTC());try{assertEquals(games.dmg.fantasystick.session.DownedEpisode.Phase.PENDING,retained.find(a.core.record.id()).join().orElseThrow().phase());}finally{retained.close();}}
|
||||
assertFalse(java.util.Arrays.stream(org.bukkit.event.entity.PlayerDeathEvent.getHandlerList().getRegisteredListeners())
|
||||
.anyMatch(listener -> listener.getPlugin() == f.plugin));
|
||||
}
|
||||
@@ -549,7 +610,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
f.plugin.setEnabled(true);
|
||||
var actor = new Actor(data); f.players.put(actor.core.id, actor.player);
|
||||
actor.core.inventory.setItem(4, new org.bukkit.inventory.ItemStack(org.bukkit.Material.SHULKER_BOX));
|
||||
f.particlesCommand.execute(actor.player, "fantasyparticles", new String[]{"all", "off"}); f.tick();
|
||||
PartyBookNativeTest.disableAllParticles(f,actor);
|
||||
actor.messages.clear(); actor.particles.clear(); actor.particleCalls.clear();
|
||||
var before = actor.core.snapshot(); f.changed(actor); f.tick(); f.tick();
|
||||
assertArrayEquals(before, actor.core.snapshot()); assertNull(actor.core.marker());
|
||||
@@ -815,7 +876,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(org.bukkit.damage.DamageType.GENERIC)));
|
||||
var death = new org.bukkit.event.entity.PlayerDeathEvent(a.player, source, new ArrayList<>(), 0,
|
||||
net.kyori.adventure.text.Component.empty(), true);
|
||||
f.dispatcher.accept(death); assertTrue(death.isCancelled());
|
||||
f.dispatcher.accept(death); DownedSessionNativeTest.acceptDeath(f,a); assertTrue(death.isCancelled());
|
||||
}
|
||||
default -> throw new AssertionError(route);
|
||||
}
|
||||
@@ -1144,8 +1205,10 @@ class FantasyStickLifecycleNativeTest {
|
||||
final Thread main = Thread.currentThread();
|
||||
final Map<java.util.UUID, org.bukkit.entity.Player> players = new HashMap<>();
|
||||
org.bukkit.command.PluginCommand command;
|
||||
org.bukkit.command.PluginCommand feedbackCommand;
|
||||
org.bukkit.command.PluginCommand particlesCommand;
|
||||
org.bukkit.command.PluginCommand playerCommand;
|
||||
org.bukkit.command.PluginCommand adminCommand;
|
||||
RoutedPlayerCommand feedbackCommand;
|
||||
RoutedPlayerCommand particlesCommand;
|
||||
int playerLookups;
|
||||
String failRegistrationAt;
|
||||
boolean failRemoval;
|
||||
@@ -1222,6 +1285,9 @@ class FantasyStickLifecycleNativeTest {
|
||||
case "getItemFactory" -> org.bukkit.craftbukkit.inventory.CraftItemFactory.instance();
|
||||
case "getScheduler" -> scheduler;
|
||||
case "getLogger" -> Logger.getLogger("FantasyStick lifecycle fixture");
|
||||
case "getName" -> "Purpur";
|
||||
case "getVersion" -> "Purpur 2618 (MC: 26.2)";
|
||||
case "getMinecraftVersion" -> "26.2";
|
||||
case "isPrimaryThread" -> Thread.currentThread() == main;
|
||||
case "getOnlinePlayers" -> List.copyOf(players.values());
|
||||
case "getWorlds" -> players.values().stream().map(org.bukkit.entity.Player::getWorld).distinct().toList();
|
||||
@@ -1229,9 +1295,9 @@ class FantasyStickLifecycleNativeTest {
|
||||
.filter(world -> java.util.Objects.equals(world.getUID(), args[0])).findFirst().orElse(null);
|
||||
case "getPlayer" -> { assertSame(main, Thread.currentThread()); playerLookups++; yield players.get(args[0]); }
|
||||
case "getPluginCommand" -> switch ((String) args[0]) {
|
||||
case "fantasyfeedback" -> feedbackCommand;
|
||||
case "fantasyparticles" -> particlesCommand;
|
||||
default -> command;
|
||||
case "fstick" -> playerCommand;
|
||||
case "fstickadmin" -> adminCommand;
|
||||
default -> null;
|
||||
};
|
||||
case "createInventory" -> args[1] instanceof Integer size
|
||||
? new org.bukkit.craftbukkit.inventory.CraftInventoryCustom((org.bukkit.inventory.InventoryHolder) args[0], size, (net.kyori.adventure.text.Component) args[2])
|
||||
@@ -1256,28 +1322,44 @@ class FantasyStickLifecycleNativeTest {
|
||||
plugin.getLogger().addHandler(logHandler);
|
||||
var constructor = org.bukkit.command.PluginCommand.class.getDeclaredConstructor(String.class, org.bukkit.plugin.Plugin.class);
|
||||
constructor.setAccessible(true);
|
||||
var definition = plugin.getDescription().getCommands().get("fantasystick");
|
||||
if (definition != null) {
|
||||
// Bukkit normally constructs this from the packaged descriptor during plugin loading.
|
||||
command = constructor.newInstance("fantasystick", plugin);
|
||||
command.setPermission((String) definition.get("permission"));
|
||||
// Explicit server-message port: native permission dispatch otherwise reads global Bukkit.server.
|
||||
command.permissionMessage(net.kyori.adventure.text.Component.text("Permission denied"));
|
||||
command.setUsage((String) definition.get("usage"));
|
||||
var playerDefinition = plugin.getDescription().getCommands().get("fstick");
|
||||
if (playerDefinition != null) {
|
||||
// Bukkit normally constructs these from the packaged descriptor during plugin loading.
|
||||
playerCommand = constructor.newInstance("fstick", plugin);
|
||||
playerCommand.setPermission((String) playerDefinition.get("permission"));
|
||||
playerCommand.permissionMessage(net.kyori.adventure.text.Component.text("Permission denied"));
|
||||
playerCommand.setUsage((String) playerDefinition.get("usage"));
|
||||
}
|
||||
var feedbackDefinition = plugin.getDescription().getCommands().get("fantasyfeedback");
|
||||
if (feedbackDefinition != null) {
|
||||
feedbackCommand = constructor.newInstance("fantasyfeedback", plugin);
|
||||
feedbackCommand.setPermission((String) feedbackDefinition.get("permission"));
|
||||
feedbackCommand.permissionMessage(net.kyori.adventure.text.Component.text("Permission denied"));
|
||||
feedbackCommand.setUsage((String) feedbackDefinition.get("usage"));
|
||||
var adminDefinition = plugin.getDescription().getCommands().get("fstickadmin");
|
||||
if (adminDefinition != null) {
|
||||
adminCommand = constructor.newInstance("fstickadmin", plugin);
|
||||
adminCommand.setPermission((String) adminDefinition.get("permission"));
|
||||
adminCommand.permissionMessage(net.kyori.adventure.text.Component.text("Permission denied"));
|
||||
adminCommand.setUsage((String) adminDefinition.get("usage"));
|
||||
}
|
||||
var particlesDefinition = plugin.getDescription().getCommands().get("fantasyparticles");
|
||||
if (particlesDefinition != null) {
|
||||
particlesCommand = constructor.newInstance("fantasyparticles", plugin);
|
||||
particlesCommand.setPermission((String) particlesDefinition.get("permission"));
|
||||
particlesCommand.permissionMessage(net.kyori.adventure.text.Component.text("Permission denied"));
|
||||
particlesCommand.setUsage((String) particlesDefinition.get("usage"));
|
||||
command = adminCommand;
|
||||
feedbackCommand = new RoutedPlayerCommand(playerCommand, "feedback");
|
||||
particlesCommand = new RoutedPlayerCommand(playerCommand, "particles");
|
||||
}
|
||||
|
||||
static final class RoutedPlayerCommand {
|
||||
private final org.bukkit.command.PluginCommand command;
|
||||
private final String route;
|
||||
RoutedPlayerCommand(org.bukkit.command.PluginCommand command, String route) {
|
||||
this.command = command;
|
||||
this.route = route;
|
||||
}
|
||||
boolean execute(org.bukkit.command.CommandSender sender, String label, String[] args) {
|
||||
return command.execute(sender, "fstick", prefixed(args));
|
||||
}
|
||||
java.util.List<String> tabComplete(org.bukkit.command.CommandSender sender, String label, String[] args) {
|
||||
return command.tabComplete(sender, "fstick", prefixed(args));
|
||||
}
|
||||
private String[] prefixed(String[] args) {
|
||||
var routed = new String[args.length + 1];
|
||||
routed[0] = route;
|
||||
System.arraycopy(args, 0, routed, 1, args.length);
|
||||
return routed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1380,6 +1462,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
final net.minecraft.world.food.FoodData food = new net.minecraft.world.food.FoodData();
|
||||
boolean healthScaled;
|
||||
boolean sprinting;
|
||||
boolean gravity=true;
|
||||
boolean allowed = true;
|
||||
boolean visible = true;
|
||||
String accountName = "Account";
|
||||
@@ -1449,6 +1532,11 @@ class FantasyStickLifecycleNativeTest {
|
||||
case "isSneaking", "isInvisible", "isDead" -> false;
|
||||
case "isSprinting" -> sprinting;
|
||||
case "setSprinting" -> { sprinting = (Boolean) args[0]; yield null; }
|
||||
case "hasGravity" -> gravity;
|
||||
case "setGravity" -> {gravity=(Boolean)args[0];yield null;}
|
||||
case "setVelocity","setFallDistance" -> null;
|
||||
case "isInsideVehicle" -> false;
|
||||
case "leaveVehicle" -> true;
|
||||
case "getScoreboard" -> craftBoard;
|
||||
case "sendMessage" -> {
|
||||
if (args[0] instanceof net.kyori.adventure.text.Component component) {
|
||||
@@ -1493,6 +1581,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
var level = mock(net.minecraft.server.level.ServerLevel.class);
|
||||
when(nativePlayer.level()).thenReturn(level);
|
||||
when(world.getHandle()).thenReturn(level);
|
||||
when(world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(world.getName()).thenReturn("world");
|
||||
when(level.dimension()).thenReturn(net.minecraft.world.level.Level.OVERWORLD);
|
||||
var worldConfig = mock(io.papermc.paper.configuration.WorldConfiguration.class);
|
||||
worldConfig.entities = allocate(io.papermc.paper.configuration.WorldConfiguration.Entities.class);
|
||||
|
||||
@@ -52,7 +52,7 @@ class FireballNativeTest {
|
||||
var viewer = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
viewer.core.inventory.setItemInOffHand(null); viewer.core.location.setWorld(a.world);
|
||||
f.players.put(viewer.core.id, viewer.player);
|
||||
f.particlesCommand.execute(a.player, "fantasyparticles", new String[]{"all", "off"}); PartyBookNativeTest.drain(f);
|
||||
PartyBookNativeTest.disableAllParticles(f,a);
|
||||
a.particleCalls.clear(); viewer.particleCalls.clear();
|
||||
f.dispatcher.accept(cast); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertTrue(a.particleCalls.isEmpty(), "The opted-out caster receives no Fireball particles");
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.adventure.*;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.entity.*;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.event.player.*;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
final class FishingQuestNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void registeredFishingDefinitionCatchInventorySubmissionResetAndClaimUseConnectedPath() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 1\nfishing-catch-xp-min: 2\nfishing-catch-xp-max: 2\n");
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);doReturn(true).when(actor.player).hasPermission("fantasystick.admin");UUID world=UUID.randomUUID();when(actor.world.getUID()).thenReturn(world);when(actor.world.getName()).thenReturn("Fishing Realm");f.activate(actor);doReturn(null).when(actor.player).getFishHook();
|
||||
assertEquals(List.of("fishing"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure-site","place","f"}));
|
||||
assertTrue(f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure-site","place","fishing",""}).contains("--name"));
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","place","fishing","--name","\"Moonlit Pond\"","--item","\"Silver Scale\"","--required","1","--chance","100","--xp","10","--coins","4","--radius","16","--mode","individual-only"});drain(f);
|
||||
var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");var fishing=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");var site=adventures.snapshot().sites().getFirst();assertEquals(AdventureQuestState.SiteType.FISHING,site.type());assertEquals("Moonlit Pond",site.name());assertEquals("Silver Scale",site.fishing().itemName());assertEquals(AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY,site.participationMode());
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","fishing",site.id().toString()});assertTrue(actor.messages.getLast().contains("Silver Scale"));assertEquals(List.of(site.id().toString()),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure-site","fishing",""}));
|
||||
int before=adventures.snapshot().sites().size();f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","place","fishing","--name","Broken","--item","X","--required","0","--chance","101","--xp","1","--coins","1"});assertEquals(before,adventures.snapshot().sites().size());
|
||||
var oldCatalog=adventures.snapshot().catalog();f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll","confirm"});await(f,()->adventures.snapshot().reset()==null&&adventures.snapshot().catalog()!=null&&(oldCatalog==null||!adventures.snapshot().catalog().revision().equals(oldCatalog.revision())));var catalog=adventures.snapshot().catalog();assertTrue(catalog.sites().contains(site.id()));
|
||||
var controller=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");controller.open(actor.player);drain(f);var offerMenu=actor.inventories.getLast();var offer=offer(offerMenu,"Moonlit Pond");var lore=plain(offer);assertTrue(lore.stream().anyMatch(line->line.contains("Mode: individual-only")));assertTrue(lore.stream().anyMatch(line->line.contains("Silver Scale × 1")));assertTrue(lore.stream().anyMatch(line->line.contains("Catch chance: 100%")));assertTrue(lore.stream().anyMatch(line->line.contains("10 XP, 4 coins")));click(controller,actor,offerMenu,offer);drain(f);var quest=adventures.active().getFirst();assertNotNull(quest.fishing());UUID stableItem=quest.fishing().itemId();f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","fishing",site.id().toString(),"--item","\"Golden Scale\"","--required","2","--chance","0","--xp","20","--coins","8","--radius","4"});drain(f);var updatedSite=adventures.findSite(site.id()).orElseThrow();assertEquals(stableItem,updatedSite.fishing().itemId());assertEquals("Golden Scale",updatedSite.fishing().itemName());assertEquals("Silver Scale",quest.fishing().itemName(),"accepted terms remain immutable");assertEquals(16,quest.fishing().radius());actor.core.location.setX(10);
|
||||
for(int retry=0;retry<20&&actor.core.inventory.getItem(0).getType()!=Material.FISHING_ROD;retry++){pulse(f);drain(f);}var rod=actor.core.inventory.getItem(0);assertEquals(Material.FISHING_ROD,rod.getType());assertEquals(quest.id(),AdventureQuestNavigation.fishingQuest(rod));assertTrue(rod.getEnchantments().isEmpty());
|
||||
var use=new PlayerInteractEvent(actor.player,Action.RIGHT_CLICK_AIR,rod,null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND);f.dispatcher.accept(use);assertNotEquals(org.bukkit.event.Event.Result.DENY,use.useItemInHand(),"owned quest rod keeps native cast/reel mechanics");
|
||||
var ordinaryHook=mock(FishHook.class);when(ordinaryHook.getUniqueId()).thenReturn(UUID.randomUUID());var ordinaryEvent=new PlayerFishEvent(actor.player,null,ordinaryHook,EquipmentSlot.HAND,PlayerFishEvent.State.REEL_IN);actor.core.inventory.setItem(0,new ItemStack(Material.FISHING_ROD));f.dispatcher.accept(ordinaryEvent);assertFalse(ordinaryEvent.isCancelled(),"ordinary fishing remains unchanged");actor.core.inventory.setItem(0,rod);
|
||||
var forgedHook=mock(FishHook.class);when(forgedHook.getUniqueId()).thenReturn(UUID.randomUUID());when(forgedHook.getLocation()).thenReturn(actor.core.location.clone());var forgedCatch=mock(Item.class);var forgedEvent=new PlayerFishEvent(actor.player,forgedCatch,forgedHook,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH);f.dispatcher.accept(forgedEvent);assertTrue(forgedEvent.isCancelled());assertEquals(BigInteger.ZERO,fishing.quantity(actor.core.record.id(),site.fishing().itemId()));
|
||||
var entityHook=mock(FishHook.class);when(entityHook.getUniqueId()).thenReturn(UUID.randomUUID());when(entityHook.getLocation()).thenReturn(actor.core.location.clone());f.dispatcher.accept(new PlayerFishEvent(actor.player,null,entityHook,EquipmentSlot.HAND,PlayerFishEvent.State.FISHING));var hookedEntity=mock(Zombie.class);var entityCatch=new PlayerFishEvent(actor.player,hookedEntity,entityHook,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_ENTITY);f.dispatcher.accept(entityCatch);assertTrue(entityCatch.isCancelled());verify(entityHook).remove();
|
||||
var hook=mock(FishHook.class);when(hook.getUniqueId()).thenReturn(UUID.randomUUID());when(hook.getLocation()).thenReturn(actor.core.location.clone());var caught=mock(Item.class);var caughtStack=new ItemStack(Material.COD);when(caught.getItemStack()).thenReturn(caughtStack);when(caught.getLocation()).thenReturn(actor.core.location.clone());
|
||||
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,hook,EquipmentSlot.HAND,PlayerFishEvent.State.FISHING));var caughtEvent=new PlayerFishEvent(actor.player,caught,hook,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH);caughtEvent.setExpToDrop(7);f.dispatcher.accept(caughtEvent);assertTrue(caughtEvent.isCancelled());assertEquals(0,caughtEvent.getExpToDrop());verify(caught).remove();await(f,()->fishing.quantity(actor.core.record.id(),site.fishing().itemId()).equals(BigInteger.ONE));var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");await(f,()->storage.find(actor.core.record.id()).join().orElseThrow().experience().equals(BigInteger.TWO));assertTrue(actor.messages.stream().anyMatch(message->message.contains("Caught Silver Scale")));
|
||||
doReturn(true).when(actor.player).isSneaking();int books=actor.books.size();var sneak=new PlayerInteractEvent(actor.player,Action.RIGHT_CLICK_AIR,actor.core.inventory.getItem(0),null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND);f.dispatcher.accept(sneak);await(f,()->actor.books.size()>books);var pages=((org.bukkit.inventory.meta.BookMeta)actor.books.getLast().getItemMeta()).pages().stream().map(PlainTextComponentSerializer.plainText()::serialize).toList();assertTrue(String.join("\n",pages).contains("Inventory"),pages.toString());assertTrue(String.join("\n",pages).contains("Golden Scale: 1"),pages.toString());assertTrue(String.join("",pages).replace("\n","").contains(site.id().toString()),pages.toString());doReturn(false).when(actor.player).isSneaking();
|
||||
controller.open(actor.player);drain(f);var offers=actor.inventories.getLast();assertTrue(plain(offer(offers,"Moonlit Pond")).stream().anyMatch(line->line.contains("Held: 1 / 1")));var turnIn=java.util.stream.IntStream.range(0,offers.getSize()).mapToObj(offers::getItem).filter(Objects::nonNull).filter(item->item.getType()==Material.ENDER_CHEST).findFirst().orElseThrow();click(controller,actor,offers,turnIn);var turnMenu=actor.inventories.getLast();var submit=offer(turnMenu,"Submit Silver Scale");assertTrue(plain(submit).stream().anyMatch(line->line.contains("Beneficiaries: 1")));var fishingController=(FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");clickFishing(fishingController,actor,turnMenu,submit);clickFishing(fishingController,actor,turnMenu,submit);assertTrue(actor.messages.getLast().contains("menu, character or session changed"));await(f,()->adventures.active().isEmpty()&&fishing.quantity(actor.core.record.id(),site.fishing().itemId()).signum()==0);assertEquals(new FishingState.Reward(BigInteger.TEN,BigInteger.valueOf(4)),fishing.unclaimed(actor.core.record.id()));
|
||||
var completedGeneration=catalog.revision();f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll","confirm"});await(f,()->adventures.snapshot().reset()==null&&!adventures.snapshot().catalog().revision().equals(completedGeneration));assertEquals(new FishingState.Reward(BigInteger.TEN,BigInteger.valueOf(4)),fishing.unclaimed(actor.core.record.id()),"full reroll preserves earned shares");
|
||||
controller.open(actor.player);drain(f);var emptyOffers=actor.inventories.getLast();turnIn=java.util.stream.IntStream.range(0,emptyOffers.getSize()).mapToObj(emptyOffers::getItem).filter(Objects::nonNull).filter(item->item.getType()==Material.ENDER_CHEST).findFirst().orElseThrow();click(controller,actor,emptyOffers,turnIn);turnMenu=actor.inventories.getLast();var claim=offer(turnMenu,"Claim earned quest rewards");clickFishing((FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController"),actor,turnMenu,claim);await(f,()->fishing.unclaimed(actor.core.record.id()).equals(FishingState.Reward.empty()));var saved=storage.find(actor.core.record.id()).join().orElseThrow();assertEquals(BigInteger.valueOf(12),saved.experience());assertEquals(actor.core.record.coins().add(BigInteger.valueOf(4)),saved.coins());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nonleaderMemberSuppliesWholeRequirementAndOfflinePartySharePersists() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 1\n");
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);var leader=new FantasyStickLifecycleNativeTest.Actor(data);var member=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(leader.core.id,leader.player);f.players.put(member.core.id,member.player);UUID world=UUID.randomUUID();when(leader.world.getUID()).thenReturn(world);member.core.location.setWorld(leader.world);f.activate(leader);f.activate(member);doReturn(null).when(leader.player).getFishHook();doReturn(null).when(member.player).getFishHook();var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(leader.core.id).orElseThrow();assertTrue(parties.accept(member.core.id,parties.invite(leader.core.id,party.id(),member.core.id).orElseThrow().id()));
|
||||
var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");var fishing=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");var site=adventures.placeFishingSite("Party Pond","Party Pearl",world,0,64,0,16,java.math.BigDecimal.valueOf(100),BigInteger.ONE,BigInteger.valueOf(100),BigInteger.valueOf(20),AdventureQuestState.ParticipationMode.PARTY_REQUIRED).get(5,TimeUnit.SECONDS);var old=adventures.snapshot().catalog();var result=adventures.rerollToday(old==null?null:old.revision()).get(5,TimeUnit.SECONDS);var reset=adventures.snapshot().reset();if(reset!=null)adventures.completeReset(reset.operation()).get(5,TimeUnit.SECONDS);var catalog=result.catalog().orElseThrow();var accepted=adventures.accept(catalog.revision(),site.id(),AdventureQuestState.UnitType.PARTY,party.id(),Set.of(leader.core.record.id(),member.core.record.id())).get(5,TimeUnit.SECONDS).quest().orElseThrow();var catchPlan=new FishingState.Catch(UUID.randomUUID(),member.core.record.id(),accepted.id(),site.id(),site.fishing().itemId(),site.fishing().itemName(),BigInteger.ZERO,true);fishing.prepareCatch(catchPlan).get();fishing.completeCatch(catchPlan.id()).get();doReturn(false).when(leader.player).isOnline();
|
||||
var controller=(FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");controller.open(member.player);var menu=member.inventories.getLast();var submit=offer(menu,"Submit Party Pearl");clickFishing(controller,member,menu,submit);await(f,()->adventures.findQuest(accepted.id()).isEmpty());var leaderReward=fishing.unclaimed(leader.core.record.id());var memberReward=fishing.unclaimed(member.core.record.id());assertEquals(BigInteger.valueOf(100),leaderReward.xp().add(memberReward.xp()));assertEquals(BigInteger.valueOf(20),leaderReward.coins().add(memberReward.coins()));assertTrue(leaderReward.xp().signum()>0&&memberReward.xp().signum()>0,"offline accepted leader retains a frozen share");assertEquals(BigInteger.ZERO,fishing.quantity(member.core.record.id(),site.fishing().itemId()));
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> plain(ItemStack item){return item.getItemMeta().lore().stream().map(PlainTextComponentSerializer.plainText()::serialize).toList();}
|
||||
private static ItemStack offer(org.bukkit.inventory.Inventory menu,String name){return java.util.stream.IntStream.range(0,menu.getSize()).mapToObj(menu::getItem).filter(Objects::nonNull).filter(item->item.getItemMeta().displayName()!=null&&PlainTextComponentSerializer.plainText().serialize(item.getItemMeta().displayName()).equals(name)).findFirst().orElseThrow();}
|
||||
private static void click(AdventureQuestController controller,FantasyStickLifecycleNativeTest.Actor actor,org.bukkit.inventory.Inventory menu,ItemStack item){var event=mock(InventoryClickEvent.class);when(event.getWhoClicked()).thenReturn(actor.player);when(event.getInventory()).thenReturn(menu);when(event.getCurrentItem()).thenReturn(item);controller.click(event);}
|
||||
private static void clickFishing(FishingController controller,FantasyStickLifecycleNativeTest.Actor actor,org.bukkit.inventory.Inventory menu,ItemStack item){var event=mock(InventoryClickEvent.class);when(event.getWhoClicked()).thenReturn(actor.player);when(event.getInventory()).thenReturn(menu);when(event.getCurrentItem()).thenReturn(item);controller.click(event);}
|
||||
private static void pulse(FantasyStickLifecycleNativeTest.Fixture f){for(var pulse:List.copyOf(f.pulses))pulse.run();}
|
||||
private static void drain(FantasyStickLifecycleNativeTest.Fixture f)throws Exception{for(int attempt=0;attempt<30;attempt++){var task=f.tasks.poll(50,TimeUnit.MILLISECONDS);if(task==null)break;task.run();}}
|
||||
private static void await(FantasyStickLifecycleNativeTest.Fixture f,java.util.function.BooleanSupplier condition)throws Exception{for(int attempt=0;attempt<80&&!condition.getAsBoolean();attempt++){drain(f);Thread.sleep(5);}assertTrue(condition.getAsBoolean());}
|
||||
}
|
||||
@@ -80,7 +80,7 @@ class FrostNovaNativeTest {
|
||||
var viewer = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
viewer.core.inventory.setItemInOffHand(null); viewer.core.location.setWorld(caster.world);
|
||||
fixture.players.put(viewer.core.id, viewer.player);
|
||||
fixture.particlesCommand.execute(caster.player, "fantasyparticles", new String[]{"all", "off"}); PartyBookNativeTest.drain(fixture);
|
||||
PartyBookNativeTest.disableAllParticles(fixture,caster);
|
||||
caster.particleCalls.clear(); viewer.particleCalls.clear();
|
||||
use(fixture, caster); drain(fixture, 2);
|
||||
assertTrue(caster.particleCalls.isEmpty(), "The opted-out caster receives no Frost Nova particles");
|
||||
|
||||
@@ -55,7 +55,7 @@ class FrostShardNativeTest {
|
||||
var viewer = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
viewer.core.inventory.setItemInOffHand(null); viewer.core.location.setWorld(a.world);
|
||||
f.players.put(viewer.core.id, viewer.player);
|
||||
f.particlesCommand.execute(a.player, "fantasyparticles", new String[]{"all", "off"}); PartyBookNativeTest.drain(f);
|
||||
PartyBookNativeTest.disableAllParticles(f,a);
|
||||
a.particleCalls.clear(); viewer.particleCalls.clear();
|
||||
f.dispatcher.accept(cast); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertTrue(a.particleCalls.isEmpty(), "The opted-out caster receives no Frost Shard particles");
|
||||
|
||||
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user