feat(rare-items): add explicit Phoenix Down rescue
Release / release (push) Successful in 13m18s

This commit is contained in:
dmg committed 2026-09-24 21:05:41 -04:00
1 parent 689877403f
commit fa917e57ba
25 files changed
+330 -42

No files matched your search

+2 -2
View File
@@ -22,7 +22,7 @@ Build output is `build/libs/purpur-fantasy-stick-<version>.jar`. Server APIs, te
## Local event files
Fantasy Stick independently writes compact CloudEvents 1.0 JSON objects as UTF-8 JSONL under `<plugin data directory>/events/YYYY-MM-DD/events-<boot UUID>-<segment>.jsonl`. The initial foundation records plugin lifecycle/recovery and correlated observed/resolved native interactions for active characters: block/entity/item/inventory/vehicle/control use, item pickup/drop, held-slot changes and discrete teleports. The downed feature additionally records durable downing/resolution facts, learned combat-Revive casts and results, accepted final-death/Tail transitions, prevented waiting/protection damage, protection expiry, and authorized downed-policy reads/changes. Stable episode/action identities and original deadlines, attribution, custody, locations, frozen rewards, caster cooldown/range/line-of-sight context survive recovery without inferring Phoenix Down use. It intentionally excludes movement/look ticks, rendering, particles and unrelated ordinary-player activity. It has no dependency on Event Producer, no network endpoint and no player-facing log reader.
Fantasy Stick independently writes compact CloudEvents 1.0 JSON objects as UTF-8 JSONL under `<plugin data directory>/events/YYYY-MM-DD/events-<boot UUID>-<segment>.jsonl`. The initial foundation records plugin lifecycle/recovery and correlated observed/resolved native interactions for active characters: block/entity/item/inventory/vehicle/control use, item pickup/drop, held-slot changes and discrete teleports. The downed feature additionally records durable downing/resolution facts, learned combat-Revive casts and results, accepted final-death/Tail transitions, prevented waiting/protection damage, protection expiry, and authorized downed-policy reads/changes. Stable episode/action identities and original deadlines, attribution, custody, locations, frozen rewards, caster cooldown/range/line-of-sight context survive recovery. Phoenix Down purchase, explicit downed-menu use, exact item/health proof, protection/prevention and expiry records are emitted only from their acknowledged feature boundaries. It intentionally excludes movement/look ticks, rendering, particles and unrelated ordinary-player activity. It has no dependency on Event Producer, no network endpoint and no player-facing log reader.
A stable plugin-owned server UUID and per-lifetime boot/sequence identity distinguish records. Events rotate at UTC midnight, on each writer lifetime and before 128 MiB; an oversized record stays whole in its own segment. Accepted work is first retained in internal pending files and exported at least once with unchanged IDs, so consumers must deduplicate by `(source, id)`. Startup recovers pending work, quarantines malformed/interrupted tails outside `*.jsonl`, and reports incomplete prior lifetimes without inventing a shutdown. Queue overload and pre-durable native-observation crashes remain truthful loss windows; logging never rolls back or replays gameplay.
@@ -183,7 +183,7 @@ Offhand entry applies actual native maximum health `min(10 + CON, native health
Ordinary health, food, saturation, exhaustion and health-display state belong to the holder/session, not the character sheet. The session receipt acknowledges this snapshot before native mutation; versioned player PDC records its application/restoration phase. Voluntary exit restores ordinary gear/vitals at the current location, with ordinary health capped by the current legitimate maximum. Character damage is saved on the worker, and the character claim/receipt cannot complete ahead of that acknowledgment. Quit and disable perform the same best-effort cleanup. Failed or uncertain saves retain the receipt and acknowledged character record instead of permitting a stale-health reentry. Missing/ambiguous native proof never authorizes a snapshot heal; process-owned maximum overrides are still retired independently of equipment/client-write failures.
An intercepted active-character lethal outcome now creates one durable downed episode rather than immediately recording death. The episode captures its original session, holder, lethal attribution, downed anchor, entry-return location and absolute deadline. While it remains pending, native damage, healing, movement, displacement, inventory/control changes and ordinary gameplay actions are contained; logout and shutdown restore only safe ordinary-holder state while retaining the original countdown. Reconnect rebinds the same proved session at its saved anchor without resetting time. The bounded death menu can accept death or wait for the learned, aimed, any-party Revive; Phoenix Down remains unavailable until its separate complete capability is delivered.
An intercepted active-character lethal outcome now creates one durable downed episode rather than immediately recording death. The episode captures its original session, holder, lethal attribution, downed anchor, entry-return location and absolute deadline. While it remains pending, native damage, healing, movement, displacement, inventory/control changes and ordinary gameplay actions are contained; logout and shutdown restore only safe ordinary-holder state while retaining the original countdown. Reconnect rebinds the same proved session at its saved anchor without resetting time. The bounded death menu can accept death, explicitly consume one character-owned Phoenix Down, or wait for the learned, aimed, any-party Revive. Phoenix Down is a persistent Rare Items product with global midnight-UTC stock 0–2, base price 1000 and the shared ±10% pricing policy; a newly delivered product appears only at the next scheduled or authorized restock. Purchase has no immediate effect and the character Inventory instructs use only while downed. An enabled exact-menu choice reserves one item against that episode, competes through the same single-outcome authority, restores full current fantasy health in place and applies the captured `rare-phoenix-down-invulnerability-seconds` deadline (default 5, zero allowed). Logout, restart, waiting, menu reopen, Accept Death, timeout and ordinary Inventory use never consume one automatically. A learned Revive or final death that wins leaves the item intact.
Accept Death and timeout compete with Revive for one persisted outcome. Successful Revive saves full current fantasy health, resumes the same active character in place and uses one absolute configurable protection deadline. Final death records the decision time—or the original expiry for delayed offline reconciliation—then applies frozen PvP reward/coin shares and party consequences exactly once before guarded Tail conversion. Ordinary health is restored before health-scale setters can send or queue client health packets, and return uses only the captured session-entry location. Refused or unavailable destinations retain explicit recovery evidence. New Tails retain the existing signed, authoritative death identity and readable static character history; stale or ambiguous conversion never overwrites another item or automatically reissues a book.
@@ -61,6 +61,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.shop.RarePotionService rarePotions;
private games.dmg.fantasystick.shop.RareReskillService rareReskills;
private games.dmg.fantasystick.shop.RareMagicCoreService rareMagicCores;
private games.dmg.fantasystick.shop.PhoenixDownService phoenixDowns;
private org.bukkit.scheduler.BukkitTask rareMarketPulse,rareMenuPulse;
private games.dmg.fantasystick.party.PartyCombat partyCombat;
private EquipmentAdminCommand admin;
@@ -164,6 +165,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
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 java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
@Override public void onEnable() {
@@ -367,6 +369,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
sessions.attachSwitchDelay(switchDelaySettings::delay);
sessions.attachRoleplayDisabled(roleplaySettings::disabled);
downedController=new games.dmg.fantasystick.session.DownedController(this,sessions,downed,Clock.systemUTC(),failure->getLogger().log(Level.WARNING,"Downed transition failed",failure));
phoenixDowns=new games.dmg.fantasystick.shop.PhoenixDownService(rareInventory,rareSettings,downed,sessions,Clock.systemUTC(),nextTick,downedController::revive,failure->getLogger().log(Level.WARNING,"Phoenix Down use failed",failure));
downedController.attachPhoenix(phoenixDowns);phoenixDowns.install();
sessions.attachDowned(downed,downedSettings::timeout,downedController::open);
combatFeedback = new games.dmg.fantasystick.presentation.CombatFeedback(feedbackPreferences, sessions);
trainingDummyController = new games.dmg.fantasystick.training.TrainingDummyController(this, trainingDummies);
@@ -590,9 +594,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
if(lifetime==generation)localArmorMenus.refreshOpen();});}
catch(RuntimeException failure){if(lifetime==generation)getLogger().log(Level.WARNING,"Fantasy Armorer menu refresh failed",failure);}});
armorerController=new games.dmg.fantasystick.shop.FantasyArmorerController(this,armorers,armorShopMenus::open);
rareShop=new games.dmg.fantasystick.shop.RareShop(rareMerchants,sessions,rareMarket,nextTick,failure->getLogger().log(Level.WARNING,"Rare Items purchase failed",failure));
rareShop=new games.dmg.fantasystick.shop.RareShop(rareMerchants,sessions,rareMarket,rareInventory,nextTick,failure->getLogger().log(Level.WARNING,"Rare Items purchase failed",failure));
rareShopMenus=new games.dmg.fantasystick.shop.RareShopMenus(this,rareShop);var localRareMenus=rareShopMenus;
rareMarket.attachListener(catalog->{if(lifetime!=generation)return;try{nextTick.accept(()->{if(lifetime==generation)localRareMenus.refreshOpen();});}catch(RuntimeException failure){if(lifetime==generation)getLogger().log(Level.WARNING,"Rare Items menu refresh failed",failure);}});
rareMarket.attachListener(catalog->{if(lifetime!=generation)return;if(rareEvents!=null)rareEvents.catalogRefreshed(catalog);try{nextTick.accept(()->{if(lifetime==generation)localRareMenus.refreshOpen();});}catch(RuntimeException failure){if(lifetime==generation)getLogger().log(Level.WARNING,"Rare Items menu refresh failed",failure);}});
rareMerchantController=new games.dmg.fantasystick.shop.RareMerchantController(this,rareMerchants,rareShopMenus::open);
rarePotions=new games.dmg.fantasystick.shop.RarePotionService(rareInventory,rareSettings,characters,sessions,event->getServer().getPluginManager().callEvent(event),nextTick,failure->getLogger().log(Level.WARNING,"Rare potion use failed",failure));
rareReskills=new games.dmg.fantasystick.shop.RareReskillService(rareInventory,characters,sessions,nextTick,record->{for(String ability:record.learned())abilityEngine.remove(record.id(),ability);speedI.sweep();lowLightVision.sweep();},failure->getLogger().log(Level.WARNING,"Rare Reskill use failed",failure));
@@ -698,13 +702,14 @@ public final class FantasyStickPlugin extends JavaPlugin {
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);
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;
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null;
getLogger().log(Level.WARNING,"Fantasy Stick local events are unavailable; gameplay remains enabled",failure);
}
getServer().getPluginManager().registerEvents(crafting, this);
@@ -807,7 +812,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
combatFeedback = null;
var closingEvents = sessionEvents; sessionEvents = null;
var closingEventLog = eventLog; eventLog = null;
var closingEventCapture = eventCapture; eventCapture = null; downedEvents = null;
var closingEventCapture = eventCapture; eventCapture = null; downedEvents = null; rareEvents = null;
var closingBooks = books; books = null;
var closingPartyEvents = partyEvents; partyEvents = null;
var closingPartyCombat = partyCombat; partyCombat = null;
@@ -821,7 +826,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingAdventureQuestController = adventureQuestController; adventureQuestController = null;
adventureQuestCommands = null;
var closingFishing=fishing;fishing=null;var closingFishingController=fishingController;fishingController=null;
var closingDungeons=dungeons;dungeons=null;var closingDowned=downed;downed=null;var closingDownedController=downedController;downedController=null;var closingDungeonController=dungeonController;dungeonController=null;dungeonCommands=null;
var closingDungeons=dungeons;dungeons=null;var closingDowned=downed;downed=null;var closingDownedController=downedController;downedController=null;phoenixDowns=null;var closingDungeonController=dungeonController;dungeonController=null;dungeonCommands=null;
var closingPartyRegistrarMenus = partyRegistrarMenus; partyRegistrarMenus = null;
var closingPartyManagement = partyManagement; partyManagement = null;
var closingArmorers=armorers;armorers=null;var closingArmorerController=armorerController;armorerController=null;
@@ -412,7 +412,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
return item;
}
private java.util.List<Component> inventoryPages(UUID character,ControlInput input){var directory=fishing;var rare=rareInventory;if(directory==null&&rare==null)return java.util.List.of();var values=directory==null?java.util.List.<games.dmg.fantasystick.adventure.FishingState.ItemStack>of():directory.inventory(character);var trophies=directory==null?java.util.List.<games.dmg.fantasystick.adventure.FishingState.Trophy>of():directory.trophies(character);var goods=rare==null?java.util.List.<games.dmg.fantasystick.shop.RareInventoryDirectory.Item>of():rare.inventory(character);RareView view=input==null?null:new RareView(UUID.randomUUID().toString(),input,goods.stream().filter(games.dmg.fantasystick.shop.RareInventoryDirectory.Item::available).collect(java.util.stream.Collectors.toUnmodifiableMap(games.dmg.fantasystick.shop.RareInventoryDirectory.Item::id,games.dmg.fantasystick.shop.RareInventoryDirectory.Item::quantity)));if(view!=null&&!view.quantities().isEmpty())rareViews.put(input.session().player(),view);var builder=new PageBuilder();builder.text("Inventory");builder.blank();if(values.isEmpty()&&trophies.isEmpty()&&goods.isEmpty())builder.text("No items.");else{int shown=0;for(var value:values){if(shown++>=256)break;builder.text(value.name()+": "+value.amount());builder.text("Fishing origin: "+value.site());}for(var trophy:trophies){if(shown++>=256)break;builder.text(trophy.name()+": 1");builder.text("Trophy origin: "+trophy.origin());}for(var good:goods){if(shown++>=256)break;builder.inline(good.name()+": "+good.quantity(),view!=null&&good.available()?rareButton(view,good.id()):null);if(!good.available())builder.text("Unavailable item type");}if(values.size()+trophies.size()+goods.size()>256)builder.text("More items are retained.");}return builder.pages();}
private java.util.List<Component> inventoryPages(UUID character,ControlInput input){var directory=fishing;var rare=rareInventory;if(directory==null&&rare==null)return java.util.List.of();var values=directory==null?java.util.List.<games.dmg.fantasystick.adventure.FishingState.ItemStack>of():directory.inventory(character);var trophies=directory==null?java.util.List.<games.dmg.fantasystick.adventure.FishingState.Trophy>of():directory.trophies(character);var goods=rare==null?java.util.List.<games.dmg.fantasystick.shop.RareInventoryDirectory.Item>of():rare.inventory(character);RareView view=input==null?null:new RareView(UUID.randomUUID().toString(),input,goods.stream().filter(games.dmg.fantasystick.shop.RareInventoryDirectory.Item::available).collect(java.util.stream.Collectors.toUnmodifiableMap(games.dmg.fantasystick.shop.RareInventoryDirectory.Item::id,games.dmg.fantasystick.shop.RareInventoryDirectory.Item::quantity)));if(view!=null&&!view.quantities().isEmpty())rareViews.put(input.session().player(),view);var builder=new PageBuilder();builder.text("Inventory");builder.blank();if(values.isEmpty()&&trophies.isEmpty()&&goods.isEmpty())builder.text("No items.");else{int shown=0;for(var value:values){if(shown++>=256)break;builder.text(value.name()+": "+value.amount());builder.text("Fishing origin: "+value.site());}for(var trophy:trophies){if(shown++>=256)break;builder.text(trophy.name()+": 1");builder.text("Trophy origin: "+trophy.origin());}for(var good:goods){if(shown++>=256)break;boolean phoenix=games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN.id().equals(good.id());builder.inline(good.name()+": "+good.quantity(),view!=null&&good.available()&&!phoenix?rareButton(view,good.id()):null);if(phoenix&&good.available())builder.text("Use Phoenix Down while downed.");if(!good.available())builder.text("Unavailable item type");}if(values.size()+trophies.size()+goods.size()>256)builder.text("More items are retained.");}return builder.pages();}
private static Component rareButton(RareView view,String product){return Component.text("[Use]",net.kyori.adventure.text.format.NamedTextColor.DARK_GREEN).clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(RARE_USE,net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\""+view.nonce()+":"+product+"\"")));}
private Component gearPage(CharacterRecord record){return Component.text("Gear\n\nHead: Cosmetic\nChestplate: "+tier(record.gear().chestplate())+"\nLeggings: "+tier(record.gear().leggings())+"\nBoots: "+tier(record.gear().boots()));}
@@ -30,7 +30,7 @@ public final class DownedEventBridge {
if (episode.decision() != null && "revive".equals(episode.decision().source())) {
abilityCast(episode, episode.decision());
}
resolved(episode);
if (!"phoenix_down".equals(decisionSource(episode)) || episode.published()) resolved(episode);
if (episode.decision() != null && "revive".equals(episode.decision().source())) {
abilityResolved(episode);
}
@@ -82,6 +82,12 @@ public final class DownedEventBridge {
if (decision.cooldownUntil() != null) data.put("cooldown_until", decision.cooldownUntil());
if (decision.finalRange() != null) data.put("final_range", decision.finalRange());
if (decision.lineOfSight() != null) data.put("line_of_sight", decision.lineOfSight());
if (decision.phoenix() != null) {
var phoenix=decision.phoenix();data.put("selection_entry_point",phoenix.entryPoint());data.put("item_id","phoenix-down");
data.put("quantity_before",phoenix.quantityBefore().toString());data.put("quantity_after",phoenix.quantityAfter().toString());
data.put("health_before",phoenix.healthBefore().toPlainString());data.put("health_after",phoenix.healthAfter().toPlainString());data.put("maximum_health",phoenix.maximum().toPlainString());
data.put("item_use_operation_id",decision.actionId().toString());
}
}
emit(stable(episode.id(), "resolved"), episode.outcomeOperation(), episode.resolvedAt(),
"character.downed_resolved", actor(decision), data);
@@ -108,9 +114,10 @@ public final class DownedEventBridge {
}
public void outcomePublished(DownedEpisode episode) {
if(episode.decision()!=null&&"phoenix_down".equals(episode.decision().source()))resolved(episode);
var data = common(episode);
data.put("action_kind", episode.outcome() == DownedEpisode.Outcome.REVIVED
? "downed_revival_native_application" : "final_death_tail_delivery");
? episode.decision()!=null&&"phoenix_down".equals(episode.decision().source())?"phoenix_down_native_application":"downed_revival_native_application" : "final_death_tail_delivery");
data.put("stage", "native_effect_acknowledged");
data.put("outcome", "completed");
emit(stable(episode.id(), "published"), episode.outcomeOperation(), episode.resolvedAt(),
@@ -132,7 +139,7 @@ public final class DownedEventBridge {
public void protectionEnded(DownedEpisode episode) {
if (episode.outcome() != DownedEpisode.Outcome.REVIVED || episode.protectionUntil() == null) return;
var data = common(episode);
data.put("effect_id", "revive_downed_invulnerability");
data.put("effect_id", episode.decision()!=null&&"phoenix_down".equals(episode.decision().source())?"phoenix_down_invulnerability":"revive_downed_invulnerability");
data.put("stage", "expiry");
data.put("outcome", "expired");
emit(stable(episode.id(), "protection-ended"), episode.outcomeOperation(), episode.protectionUntil(),
@@ -197,7 +204,7 @@ public final class DownedEventBridge {
data.put("key", Objects.requireNonNull(key));
if (before != null) data.put("before", before);
data.put("after", Objects.requireNonNull(after));
data.put("application", "future_downed_outcomes");
data.put("application", key.equals("rare-phoenix-down-invulnerability-seconds")?"future_phoenix_rescues":key.startsWith("rare-price-phoenix-down")||key.startsWith("rare-stock-phoenix-down")?"next_rare_restock":"future_downed_outcomes");
data.put("outcome", "acknowledged");
EventInput.Context context=player==null?null:new EventInput.Context(player,playerName,null,null,null,null);
emit(UUID.randomUUID(), action, now, type, context, data);
@@ -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));}
}
@@ -17,17 +17,20 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Save-before-publication configuration for rare catalogs and future potion uses. */
public final class RareSettings implements AutoCloseable {
public static final String VARIANCE="rare-price-variance-percent";
public static final String PHOENIX_PROTECTION="rare-phoenix-down-invulnerability-seconds";
private static final int MAX_DEADLINE_SECONDS=365*24*60*60;
public record Update(String key,String value,Map<String,String> values,long sequence){public Update{values=Map.copyOf(values);}}
private static final Map<String,String> DEFAULTS=defaults();private static final List<String> KEYS=DEFAULTS.keySet().stream().sorted().toList();
private final Path directory;private final ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-rare-configuration"));private volatile Map<String,String> effective;private Map<String,String> persisted;private long savedSequence,publishedSequence;private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
public RareSettings(Path directory,Map<String,String> initial){this.directory=directory.toAbsolutePath();var values=new LinkedHashMap<String,String>();for(var key:KEYS)values.put(key,Objects.requireNonNull(initial.getOrDefault(key,DEFAULTS.get(key))));validate(values);effective=Map.copyOf(values);persisted=effective;}
public static List<String> keys(){return KEYS;}public static String defaultValue(String key){return DEFAULTS.get(key);}public String value(String key){return effective.get(key);}
public RareMarketRules rules(){var values=effective;var prices=new EnumMap<RareProduct,BigInteger>(RareProduct.class);var stock=new EnumMap<RareProduct,RareMarketRules.StockRange>(RareProduct.class);var healing=new EnumMap<RareProduct,Integer>(RareProduct.class);for(var product:RareProduct.values()){prices.put(product,new BigInteger(values.get(priceKey(product))));stock.put(product,new RareMarketRules.StockRange(whole(values.get(stockKey(product,true)),0,Integer.MAX_VALUE),whole(values.get(stockKey(product,false)),0,Integer.MAX_VALUE)));if(product.heals())healing.put(product,whole(values.get(healingKey(product)),0,100));}return new RareMarketRules(prices,whole(values.get(VARIANCE),0,100),stock,healing);}
public CompletionStage<Update> set(String key,String raw){if(!KEYS.contains(key))throw new IllegalArgumentException("Unknown rare setting");String accepted=Integer.toString(whole(raw,0,key.equals(VARIANCE)||key.endsWith("-healing-percent")?100:Integer.MAX_VALUE));var preview=new LinkedHashMap<>(effective);preview.put(key,accepted);validate(preview);synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Configuration closed"));pending++;}return CompletableFuture.supplyAsync(()->{try{var next=new LinkedHashMap<>(persisted);next.put(key,accepted);validate(next);save(key,accepted);persisted=Map.copyOf(next);return new Update(key,accepted,persisted,++savedSequence);}catch(IOException failure){throw new CompletionException(failure);}finally{finished();}},worker).minimalCompletionStage();}
public java.time.Duration phoenixProtection(){return java.time.Duration.ofSeconds(whole(effective.get(PHOENIX_PROTECTION),0,MAX_DEADLINE_SECONDS));}
public CompletionStage<Update> set(String key,String raw){if(!KEYS.contains(key))throw new IllegalArgumentException("Unknown rare setting");int maximum=key.equals(VARIANCE)||key.endsWith("-healing-percent")?100:key.equals(PHOENIX_PROTECTION)?MAX_DEADLINE_SECONDS:Integer.MAX_VALUE;String accepted=Integer.toString(whole(raw,0,maximum));var preview=new LinkedHashMap<>(effective);preview.put(key,accepted);validate(preview);synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Configuration closed"));pending++;}return CompletableFuture.supplyAsync(()->{try{var next=new LinkedHashMap<>(persisted);next.put(key,accepted);validate(next);save(key,accepted);persisted=Map.copyOf(next);return new Update(key,accepted,persisted,++savedSequence);}catch(IOException failure){throw new CompletionException(failure);}finally{finished();}},worker).minimalCompletionStage();}
public synchronized boolean publish(Update update){if(update.sequence()<=publishedSequence)return false;validate(update.values());effective=update.values();publishedSequence=update.sequence();return true;}
public static String priceKey(RareProduct product){return "rare-price-"+product.id();}public static String stockKey(RareProduct product,boolean minimum){return "rare-stock-"+product.id()+(minimum?"-min":"-max");}public static String healingKey(RareProduct product){return "rare-"+product.id()+"-healing-percent";}
private static Map<String,String> defaults(){var values=new LinkedHashMap<String,String>();values.put(VARIANCE,"10");for(var product:RareProduct.values()){values.put(priceKey(product),Integer.toString(product.basePrice()));values.put(stockKey(product,true),"0");values.put(stockKey(product,false),Integer.toString(product.maximumStock()));if(product.heals())values.put(healingKey(product),Integer.toString(product.defaultHealingPercent()));}return Map.copyOf(values);}
private static void validate(Map<String,String> values){whole(values.get(VARIANCE),0,100);for(var product:RareProduct.values()){whole(values.get(priceKey(product)),0,Integer.MAX_VALUE);int min=whole(values.get(stockKey(product,true)),0,Integer.MAX_VALUE),max=whole(values.get(stockKey(product,false)),0,Integer.MAX_VALUE);if(min>max)throw new IllegalArgumentException("Stock minimum exceeds maximum");if(product.heals())whole(values.get(healingKey(product)),0,100);}}
private static Map<String,String> defaults(){var values=new LinkedHashMap<String,String>();values.put(VARIANCE,"10");for(var product:RareProduct.values()){values.put(priceKey(product),Integer.toString(product.basePrice()));values.put(stockKey(product,true),"0");values.put(stockKey(product,false),Integer.toString(product.maximumStock()));if(product.heals())values.put(healingKey(product),Integer.toString(product.defaultHealingPercent()));}values.put(PHOENIX_PROTECTION,"5");return Map.copyOf(values);}
private static void validate(Map<String,String> values){whole(values.get(VARIANCE),0,100);for(var product:RareProduct.values()){whole(values.get(priceKey(product)),0,Integer.MAX_VALUE);int min=whole(values.get(stockKey(product,true)),0,Integer.MAX_VALUE),max=whole(values.get(stockKey(product,false)),0,Integer.MAX_VALUE);if(min>max)throw new IllegalArgumentException("Stock minimum exceeds maximum");if(product.heals())whole(values.get(healingKey(product)),0,100);}whole(values.get(PHOENIX_PROTECTION),0,MAX_DEADLINE_SECONDS);}
private static int whole(String value,int minimum,int maximum){var number=new BigInteger(Objects.requireNonNull(value));if(number.compareTo(BigInteger.valueOf(minimum))<0||number.compareTo(BigInteger.valueOf(maximum))>0)throw new IllegalArgumentException();return number.intValueExact();}
private void save(String key,String value)throws IOException{Files.createDirectories(directory);var file=directory.resolve("config.yml");var lockFile=directory.resolve(".config.lock");try(var channel=FileChannel.open(lockFile,StandardOpenOption.CREATE,StandardOpenOption.WRITE,LinkOption.NOFOLLOW_LINKS);var lock=channel.tryLock()){if(lock==null)throw new IOException("Configuration busy");var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(20);options.setCodePointLimit(1_048_576);var yaml=new Yaml(new SafeConstructor(options));Map<String,Object> document=new LinkedHashMap<>();if(Files.exists(file,LinkOption.NOFOLLOW_LINKS)){if(!Files.isRegularFile(file,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe configuration");byte[] bytes;try(var input=Files.newInputStream(file,LinkOption.NOFOLLOW_LINKS)){bytes=input.readNBytes(1_048_577);}if(bytes.length>1_048_576)throw new IOException("Configuration too large");Object loaded=yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());if(loaded instanceof Map<?,?> map)for(var entry:map.entrySet()){if(!(entry.getKey() instanceof String savedKey))throw new IOException("Invalid key");document.put(savedKey,entry.getValue());}}document.put(key,Integer.parseInt(value));byte[] bytes=yaml.dump(document).getBytes(StandardCharsets.UTF_8);Path temporary=Files.createTempFile(directory,".config-rare-",".tmp");try{Files.write(temporary,bytes,StandardOpenOption.TRUNCATE_EXISTING);try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE)){output.force(true);}Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);DurableFiles.forceDirectory(directory);}finally{Files.deleteIfExists(temporary);}}catch(OverlappingFileLockException busy){throw new IOException("Configuration busy",busy);}}
private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
@@ -20,12 +20,14 @@ import org.bukkit.plugin.Plugin;
public final class DownedController implements Listener,AutoCloseable{
public enum RescueStatus{APPLIED,APPLIED_PENDING_RECOVERY,STALE,EXPIRED,FAILED}
private record Holder(UUID episode)implements InventoryHolder{public Inventory getInventory(){return null;}}
private final Plugin plugin;private final EquipmentSessions sessions;private final DownedDirectory directory;private final Clock clock;private final Consumer<Throwable> failures;private final java.util.Map<UUID,Boolean> gravity=new java.util.HashMap<>();private final java.util.Set<UUID> revertingSlots=new java.util.HashSet<>();private final java.util.Set<UUID> endedProtection=new java.util.HashSet<>();private games.dmg.fantasystick.events.DownedEventBridge events;private org.bukkit.scheduler.BukkitTask pulse;private boolean closed;
private final Plugin plugin;private final EquipmentSessions sessions;private final DownedDirectory directory;private final Clock clock;private final Consumer<Throwable> failures;private final java.util.Map<UUID,Boolean> gravity=new java.util.HashMap<>();private final java.util.Set<UUID> revertingSlots=new java.util.HashSet<>();private final java.util.Set<UUID> endedProtection=new java.util.HashSet<>();private games.dmg.fantasystick.events.DownedEventBridge events;private games.dmg.fantasystick.shop.PhoenixDownService phoenix;private org.bukkit.scheduler.BukkitTask pulse;private boolean closed;
public DownedController(Plugin plugin,EquipmentSessions sessions,DownedDirectory directory,Clock clock,Consumer<Throwable> failures){this.plugin=Objects.requireNonNull(plugin);this.sessions=Objects.requireNonNull(sessions);this.directory=Objects.requireNonNull(directory);this.clock=Objects.requireNonNull(clock);this.failures=Objects.requireNonNull(failures);}
public void attachEvents(games.dmg.fantasystick.events.DownedEventBridge events){if(pulse!=null||this.events!=null)throw new IllegalStateException("Downed events already attached or controller installed");this.events=Objects.requireNonNull(events);}
public void attachPhoenix(games.dmg.fantasystick.shop.PhoenixDownService phoenix){if(pulse!=null||this.phoenix!=null)throw new IllegalStateException("Phoenix Down already attached or controller installed");this.phoenix=Objects.requireNonNull(phoenix);}
public void install(){if(pulse!=null)throw new IllegalStateException("Downed controller already installed");pulse=plugin.getServer().getScheduler().runTaskTimer(plugin,this::pulse,20L,20L);var existing=directory.all();if(events!=null)events.reconcile(existing);for(var episode:existing){if(episode.outcome()==DownedEpisode.Outcome.REVIVED&&episode.protectionUntil()!=null&&!clock.instant().isBefore(episode.protectionUntil()))endedProtection.add(episode.id());if(episode.phase()==DownedEpisode.Phase.PENDING){if(clock.instant().isBefore(episode.expiresAt()))open(episode);else finalizeDeath(episode,"timeout");}else if(episode.outcome()==DownedEpisode.Outcome.FINAL_DEATH&&!episode.published())sessions.finalizeDowned(episode);}}
public void open(DownedEpisode episode){if(closed||episode.phase()!=DownedEpisode.Phase.PENDING)return;var player=plugin.getServer().getPlayer(episode.player());if(player==null||!player.isOnline())return;freeze(player);var inventory=plugin.getServer().createInventory(new Holder(episode.id()),27,Component.text("Downed — "+remaining(episode)+"s"));inventory.setItem(11,item(Material.SKELETON_SKULL,"Accept Death",true));inventory.setItem(13,item(Material.GRAY_DYE,"Use Phoenix Down — unavailable",false));inventory.setItem(15,item(Material.GRAY_DYE,"Wait for Revive",false));player.openInventory(inventory);}
public void open(DownedEpisode episode){if(closed||episode.phase()!=DownedEpisode.Phase.PENDING)return;var player=plugin.getServer().getPlayer(episode.player());if(player==null||!player.isOnline())return;freeze(player);var inventory=plugin.getServer().createInventory(new Holder(episode.id()),27,Component.text("Downed — "+remaining(episode)+"s"));inventory.setItem(11,item(Material.SKELETON_SKULL,"Accept Death",true));var quantity=phoenix==null?java.math.BigInteger.ZERO:phoenix.quantity(episode.character());inventory.setItem(13,quantity.signum()>0?phoenix(quantity):item(Material.GRAY_DYE,"Use Phoenix Down — unavailable",false));inventory.setItem(15,item(Material.GRAY_DYE,"Wait for Revive",false));player.openInventory(inventory);}
private static ItemStack item(Material material,String name,boolean action){var item=new ItemStack(material);var meta=item.getItemMeta();meta.displayName(Component.text(name));if(!action)meta.lore(java.util.List.of(Component.text("Waiting does not pause the countdown.")));item.setItemMeta(meta);return item;}
private static ItemStack phoenix(java.math.BigInteger quantity){var item=new ItemStack(Material.FEATHER);var meta=item.getItemMeta();meta.displayName(Component.text("Use Phoenix Down — "+quantity+" available"));meta.lore(java.util.List.of(Component.text("Consumes one only if this rescue wins.")));item.setItemMeta(meta);return item;}
private long remaining(DownedEpisode episode){var remaining=java.time.Duration.between(clock.instant(),episode.expiresAt());if(remaining.isNegative()||remaining.isZero())return 0;return remaining.getSeconds()+(remaining.getNano()==0?0:1);}
private void pulse(){if(closed)return;for(var episode:directory.all()){if(episode.phase()!=DownedEpisode.Phase.PENDING){if(episode.outcome()==DownedEpisode.Outcome.REVIVED&&episode.protectionUntil()!=null&&!clock.instant().isBefore(episode.protectionUntil())&&endedProtection.add(episode.id())&&events!=null)events.protectionEnded(episode);continue;}if(!clock.instant().isBefore(episode.expiresAt()))finalizeDeath(episode,"timeout");else{var player=plugin.getServer().getPlayer(episode.player());if(player!=null&&player.isOnline()){if(player.getOpenInventory().getTopInventory().getHolder() instanceof Holder holder&&holder.episode().equals(episode.id()))player.getOpenInventory().setTitle("Downed — "+remaining(episode)+"s");else open(episode);}}}}
public boolean downed(Player player){return episode(player)!=null;}
@@ -33,12 +35,12 @@ public final class DownedController implements Listener,AutoCloseable{
private void freeze(Player player){gravity.putIfAbsent(player.getUniqueId(),player.hasGravity());player.setGravity(false);player.setFallDistance(0);player.setVelocity(new org.bukkit.util.Vector());if(player.isInsideVehicle())player.leaveVehicle();}
private void release(Player player){var original=gravity.remove(player.getUniqueId());if(original!=null)player.setGravity(original);revertingSlots.remove(player.getUniqueId());}
public void revive(Player target,Duration protection,Consumer<RescueStatus> result){revive(target,protection,null,result);}
public void revive(Player target,Duration protection,DownedEpisode.Decision decision,Consumer<RescueStatus> result){var episode=sessions.downed(target.getUniqueId()).orElse(null);if(episode==null){result.accept(RescueStatus.STALE);return;}var operation=decision==null?UUID.randomUUID():decision.actionId();directory.claim(episode.id(),DownedEpisode.Outcome.REVIVED,operation,decision,protection,java.util.List.of()).whenComplete((claim,failure)->schedule(()->{if(failure!=null){report(failure);result.accept(RescueStatus.FAILED);return;}if(claim.status()==DownedDirectory.ClaimStatus.EXPIRED){result.accept(RescueStatus.EXPIRED);return;}if(claim.status()!=DownedDirectory.ClaimStatus.APPLIED){result.accept(RescueStatus.STALE);return;}var resolved=claim.episode().orElseThrow();if(events!=null){events.resolved(resolved);events.abilityResolved(resolved);}sessions.reviveDowned(resolved,applied->{if(!applied){result.accept(RescueStatus.FAILED);return;}release(target);if(events!=null)events.outcomePublished(resolved);directory.publish(resolved.id(),resolved.outcomeOperation()).whenComplete((published,publishFailure)->schedule(()->{if(publishFailure!=null){report(publishFailure);result.accept(RescueStatus.APPLIED_PENDING_RECOVERY);}else{if(events!=null)events.outcomePublished(published);result.accept(RescueStatus.APPLIED);}}));});}));}
public void revive(Player target,Duration protection,DownedEpisode.Decision decision,Consumer<RescueStatus> result){var episode=sessions.downed(target.getUniqueId()).orElse(null);if(episode==null){result.accept(RescueStatus.STALE);return;}var operation=decision==null?UUID.randomUUID():decision.actionId();directory.claim(episode.id(),DownedEpisode.Outcome.REVIVED,operation,decision,protection,java.util.List.of()).whenComplete((claim,failure)->schedule(()->{if(failure!=null){report(failure);result.accept(RescueStatus.FAILED);return;}if(claim.status()==DownedDirectory.ClaimStatus.EXPIRED){result.accept(RescueStatus.EXPIRED);return;}if(claim.status()!=DownedDirectory.ClaimStatus.APPLIED){result.accept(RescueStatus.STALE);return;}var resolved=claim.episode().orElseThrow();if(events!=null&&!"phoenix_down".equals(resolved.decision()==null?null:resolved.decision().source())){events.resolved(resolved);events.abilityResolved(resolved);}sessions.reviveDowned(resolved,applied->{if(!applied){result.accept(RescueStatus.FAILED);return;}release(target);if(events!=null)events.outcomePublished(resolved);directory.publish(resolved.id(),resolved.outcomeOperation()).whenComplete((published,publishFailure)->schedule(()->{if(publishFailure!=null){report(publishFailure);result.accept(RescueStatus.APPLIED_PENDING_RECOVERY);}else{if(events!=null)events.outcomePublished(published);result.accept(RescueStatus.APPLIED);}}));});}));}
private void finalizeDeath(DownedEpisode episode,String source){java.util.List<DownedEpisode.Reward> rewards;try{rewards=sessions.finalRewards(episode);}catch(RuntimeException failure){report(failure);return;}var operation=UUID.randomUUID();var decision=new DownedEpisode.Decision(source,"accept_death".equals(source)?episode.player():null,"accept_death".equals(source)?episode.playerName():null,"accept_death".equals(source)?episode.character():null,"accept_death".equals(source)?episode.characterName():null,"accept_death".equals(source)?episode.session():null,operation,null,null,null,null);directory.claim(episode.id(),DownedEpisode.Outcome.FINAL_DEATH,operation,decision,Duration.ZERO,rewards).whenComplete((claim,failure)->schedule(()->{if(failure!=null){report(failure);return;}if(claim.status()==DownedDirectory.ClaimStatus.APPLIED){var resolved=claim.episode().orElseThrow();if(events!=null)events.resolved(resolved);var player=plugin.getServer().getPlayer(episode.player());if(player!=null)release(player);sessions.finalizeDowned(resolved);}}));}
@EventHandler(priority=EventPriority.HIGHEST)public void click(org.bukkit.event.inventory.InventoryClickEvent event){if(event.getWhoClicked() instanceof Player actor&&sessions.downed(actor.getUniqueId()).isPresent())event.setCancelled(true);if(!(event.getView().getTopInventory().getHolder() instanceof Holder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getRawSlot()!=11)return;var episode=directory.all().stream().filter(value->value.id().equals(holder.episode())&&value.player().equals(player.getUniqueId())&&value.phase()==DownedEpisode.Phase.PENDING).findFirst().orElse(null);if(episode!=null)finalizeDeath(episode,"accept_death");}
@EventHandler(priority=EventPriority.HIGHEST)public void click(org.bukkit.event.inventory.InventoryClickEvent event){if(event.getWhoClicked() instanceof Player actor&&sessions.downed(actor.getUniqueId()).isPresent())event.setCancelled(true);if(!(event.getView().getTopInventory().getHolder() instanceof Holder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player))return;if(event.getRawSlot()==13&&phoenix!=null){phoenix.use(player,holder.episode(),outcome->{if(outcome.status()!=games.dmg.fantasystick.shop.PhoenixDownService.Status.APPLIED&&outcome.status()!=games.dmg.fantasystick.shop.PhoenixDownService.Status.APPLIED_PENDING_RECOVERY)player.sendMessage("Phoenix Down use refused: "+outcome.status().name().toLowerCase(java.util.Locale.ROOT).replace('_',' ')+".");});return;}if(event.getRawSlot()!=11)return;var episode=directory.all().stream().filter(value->value.id().equals(holder.episode())&&value.player().equals(player.getUniqueId())&&value.phase()==DownedEpisode.Phase.PENDING).findFirst().orElse(null);if(episode!=null)finalizeDeath(episode,"accept_death");}
@EventHandler(priority=EventPriority.HIGHEST)public void drag(org.bukkit.event.inventory.InventoryDragEvent event){if(event.getWhoClicked() instanceof Player actor&&sessions.downed(actor.getUniqueId()).isPresent()||event.getView().getTopInventory().getHolder() instanceof Holder)event.setCancelled(true);}
@EventHandler(priority=EventPriority.MONITOR)public void closeMenu(org.bukkit.event.inventory.InventoryCloseEvent event){if(!(event.getView().getTopInventory().getHolder() instanceof Holder holder)||closed)return;plugin.getServer().getScheduler().runTask(plugin,()->directory.all().stream().filter(value->value.id().equals(holder.episode())&&value.phase()==DownedEpisode.Phase.PENDING).findFirst().ifPresent(this::open));}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void damage(org.bukkit.event.entity.EntityDamageEvent event){if(!(event.getEntity() instanceof Player player))return;var episode=sessions.downed(player.getUniqueId()).orElse(null);boolean protectedRevive=episode==null&&sessions.downedProtected(player.getUniqueId());if(episode!=null||protectedRevive){event.setCancelled(true);if(events!=null){var authority=episode!=null?episode:directory.all().stream().filter(value->value.player().equals(player.getUniqueId())&&value.outcome()==DownedEpisode.Outcome.REVIVED&&value.protectionUntil()!=null&&clock.instant().isBefore(value.protectionUntil())).max(java.util.Comparator.comparing(DownedEpisode::resolvedAt)).orElse(null);if(authority!=null)events.prevented(authority,episode!=null?"downed_waiting":"revive_protection",Math.max(0,event.getFinalDamage()));}}}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void damage(org.bukkit.event.entity.EntityDamageEvent event){if(!(event.getEntity() instanceof Player player))return;var episode=sessions.downed(player.getUniqueId()).orElse(null);boolean protectedRevive=episode==null&&sessions.downedProtected(player.getUniqueId());if(episode!=null||protectedRevive){event.setCancelled(true);if(events!=null){var authority=episode!=null?episode:directory.all().stream().filter(value->value.player().equals(player.getUniqueId())&&value.outcome()==DownedEpisode.Outcome.REVIVED&&value.protectionUntil()!=null&&clock.instant().isBefore(value.protectionUntil())).max(java.util.Comparator.comparing(DownedEpisode::resolvedAt)).orElse(null);if(authority!=null)events.prevented(authority,episode!=null?"downed_waiting":authority.decision()!=null&&"phoenix_down".equals(authority.decision().source())?"phoenix_down_protection":"revive_protection",Math.max(0,event.getFinalDamage()));}}}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void heal(org.bukkit.event.entity.EntityRegainHealthEvent event){if(event.getEntity() instanceof Player player&&sessions.downed(player.getUniqueId()).isPresent())event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void move(org.bukkit.event.player.PlayerMoveEvent event){var episode=sessions.downed(event.getPlayer().getUniqueId()).orElse(null);if(episode==null||event.getTo()==null)return;if(event.getFrom().getX()!=event.getTo().getX()||event.getFrom().getY()!=event.getTo().getY()||event.getFrom().getZ()!=event.getTo().getZ())event.setTo(event.getFrom());}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void teleport(org.bukkit.event.player.PlayerTeleportEvent event){if(sessions.downed(event.getPlayer().getUniqueId()).isPresent()&&!sessions.downedRelocation(event.getPlayer().getUniqueId()))event.setCancelled(true);}
@@ -33,13 +33,14 @@ public final class DownedDirectory implements AutoCloseable{
private Map<UUID,DownedEpisode> load()throws IOException{if(Files.notExists(file))return Map.of();try{if(!Files.isRegularFile(file,LinkOption.NOFOLLOW_LINKS)||Files.isSymbolicLink(file)||Files.size(file)>1_048_576)throw new IllegalArgumentException();Object decoded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));if(!(decoded instanceof Map<?,?> root)||!"1".equals(String.valueOf(root.get("schema-version"))))throw new IllegalArgumentException();var result=new LinkedHashMap<UUID,DownedEpisode>();for(Object raw:list(root.get("episodes"))){var row=map(raw);var value=decode(row);if(result.put(value.character(),value)!=null)throw new IllegalArgumentException();}return Map.copyOf(result);}catch(RuntimeException invalid){throw new IOException("Invalid downed state",invalid);}}
private void save(Map<UUID,DownedEpisode> state)throws IOException{Files.createDirectories(file.getParent());var root=new LinkedHashMap<String,Object>();root.put("schema-version","1");root.put("episodes",state.values().stream().sorted(Comparator.comparing(DownedEpisode::character)).map(this::encode).toList());Path temporary=Files.createTempFile(file.getParent(),".downed-",".tmp");try{byte[] bytes=yaml().dump(root).getBytes(StandardCharsets.UTF_8);try(var out=FileChannel.open(temporary,StandardOpenOption.WRITE)){var buffer=ByteBuffer.wrap(bytes);while(buffer.hasRemaining())out.write(buffer);out.force(true);}Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);try(var directory=FileChannel.open(file.getParent(),StandardOpenOption.READ)){directory.force(true);}snapshot=Map.copyOf(state);}finally{Files.deleteIfExists(temporary);}}
private Map<String,Object> encode(DownedEpisode value){var row=new LinkedHashMap<String,Object>();row.put("id",value.id().toString());row.put("character",value.character().toString());row.put("player",value.player().toString());row.put("session",value.session().toString());row.put("world",value.world().toString());row.put("x",BigDecimal.valueOf(value.x()).toPlainString());row.put("y",BigDecimal.valueOf(value.y()).toPlainString());row.put("z",BigDecimal.valueOf(value.z()).toPlainString());row.put("yaw",Float.toString(value.yaw()));row.put("pitch",Float.toString(value.pitch()));row.put("entry-world",value.entryWorld().toString());row.put("entry-x",BigDecimal.valueOf(value.entryX()).toPlainString());row.put("entry-y",BigDecimal.valueOf(value.entryY()).toPlainString());row.put("entry-z",BigDecimal.valueOf(value.entryZ()).toPlainString());row.put("entry-yaw",Float.toString(value.entryYaw()));row.put("entry-pitch",Float.toString(value.entryPitch()));row.put("downed-at",value.downedAt().toString());row.put("expires-at",value.expiresAt().toString());row.put("attacker",value.attribution().attacker()==null?null:value.attribution().attacker().toString());row.put("cause",value.attribution().cause());row.put("player-defeat",value.attribution().playerDefeat());row.put("contributors",value.attribution().contributors().stream().map(UUID::toString).collect(java.util.stream.Collectors.toCollection(ArrayList::new)));row.put("victim-level",value.attribution().victimLevel());row.put("victim-coins",value.attribution().victimCoins().toString());row.put("character-name",value.characterName());row.put("player-name",value.playerName());row.put("character-revision",value.characterRevision()==null?null:value.characterRevision().toString());row.put("phase",value.phase().name());row.put("outcome",value.outcome()==null?null:value.outcome().name());row.put("outcome-operation",value.outcomeOperation()==null?null:value.outcomeOperation().toString());row.put("resolved-at",value.resolvedAt()==null?null:value.resolvedAt().toString());row.put("protection-until",value.protectionUntil()==null?null:value.protectionUntil().toString());var decision=value.decision();row.put("decision-source",decision==null?null:decision.source());row.put("decision-actor-player",decision==null||decision.actorPlayer()==null?null:decision.actorPlayer().toString());row.put("decision-actor-player-name",decision==null?null:decision.actorPlayerName());row.put("decision-actor-character",decision==null||decision.actorCharacter()==null?null:decision.actorCharacter().toString());row.put("decision-actor-character-name",decision==null?null:decision.actorCharacterName());row.put("decision-actor-session",decision==null||decision.actorSession()==null?null:decision.actorSession().toString());row.put("decision-action",decision==null?null:decision.actionId().toString());row.put("decision-cast-at",decision==null||decision.castAt()==null?null:decision.castAt().toString());row.put("decision-cooldown-until",decision==null||decision.cooldownUntil()==null?null:decision.cooldownUntil().toString());row.put("decision-final-range",decision==null||decision.finalRange()==null?null:Double.toString(decision.finalRange()));row.put("decision-line-of-sight",decision==null?null:decision.lineOfSight());row.put("final-rewards",value.finalRewards().stream().map(reward->{var saved=new LinkedHashMap<String,Object>();saved.put("character",reward.character().toString());saved.put("experience",reward.experience().toString());saved.put("coins",reward.coins().toString());return saved;}).collect(java.util.stream.Collectors.toCollection(ArrayList::new)));row.put("published",value.published());return row;}
private Map<String,Object> encode(DownedEpisode value){var row=new LinkedHashMap<String,Object>();row.put("id",value.id().toString());row.put("character",value.character().toString());row.put("player",value.player().toString());row.put("session",value.session().toString());row.put("world",value.world().toString());row.put("x",BigDecimal.valueOf(value.x()).toPlainString());row.put("y",BigDecimal.valueOf(value.y()).toPlainString());row.put("z",BigDecimal.valueOf(value.z()).toPlainString());row.put("yaw",Float.toString(value.yaw()));row.put("pitch",Float.toString(value.pitch()));row.put("entry-world",value.entryWorld().toString());row.put("entry-x",BigDecimal.valueOf(value.entryX()).toPlainString());row.put("entry-y",BigDecimal.valueOf(value.entryY()).toPlainString());row.put("entry-z",BigDecimal.valueOf(value.entryZ()).toPlainString());row.put("entry-yaw",Float.toString(value.entryYaw()));row.put("entry-pitch",Float.toString(value.entryPitch()));row.put("downed-at",value.downedAt().toString());row.put("expires-at",value.expiresAt().toString());row.put("attacker",value.attribution().attacker()==null?null:value.attribution().attacker().toString());row.put("cause",value.attribution().cause());row.put("player-defeat",value.attribution().playerDefeat());row.put("contributors",value.attribution().contributors().stream().map(UUID::toString).collect(java.util.stream.Collectors.toCollection(ArrayList::new)));row.put("victim-level",value.attribution().victimLevel());row.put("victim-coins",value.attribution().victimCoins().toString());row.put("character-name",value.characterName());row.put("player-name",value.playerName());row.put("character-revision",value.characterRevision()==null?null:value.characterRevision().toString());row.put("phase",value.phase().name());row.put("outcome",value.outcome()==null?null:value.outcome().name());row.put("outcome-operation",value.outcomeOperation()==null?null:value.outcomeOperation().toString());row.put("resolved-at",value.resolvedAt()==null?null:value.resolvedAt().toString());row.put("protection-until",value.protectionUntil()==null?null:value.protectionUntil().toString());var decision=value.decision();row.put("decision-source",decision==null?null:decision.source());row.put("decision-actor-player",decision==null||decision.actorPlayer()==null?null:decision.actorPlayer().toString());row.put("decision-actor-player-name",decision==null?null:decision.actorPlayerName());row.put("decision-actor-character",decision==null||decision.actorCharacter()==null?null:decision.actorCharacter().toString());row.put("decision-actor-character-name",decision==null?null:decision.actorCharacterName());row.put("decision-actor-session",decision==null||decision.actorSession()==null?null:decision.actorSession().toString());row.put("decision-action",decision==null?null:decision.actionId().toString());row.put("decision-cast-at",decision==null||decision.castAt()==null?null:decision.castAt().toString());row.put("decision-cooldown-until",decision==null||decision.cooldownUntil()==null?null:decision.cooldownUntil().toString());row.put("decision-final-range",decision==null||decision.finalRange()==null?null:Double.toString(decision.finalRange()));row.put("decision-line-of-sight",decision==null?null:decision.lineOfSight());var phoenix=decision==null?null:decision.phoenix();row.put("decision-phoenix-quantity-before",phoenix==null?null:phoenix.quantityBefore().toString());row.put("decision-phoenix-quantity-after",phoenix==null?null:phoenix.quantityAfter().toString());row.put("decision-phoenix-health-before",phoenix==null?null:phoenix.healthBefore().toPlainString());row.put("decision-phoenix-health-after",phoenix==null?null:phoenix.healthAfter().toPlainString());row.put("decision-phoenix-maximum",phoenix==null?null:phoenix.maximum().toPlainString());row.put("decision-phoenix-entry-point",phoenix==null?null:phoenix.entryPoint());row.put("final-rewards",value.finalRewards().stream().map(reward->{var saved=new LinkedHashMap<String,Object>();saved.put("character",reward.character().toString());saved.put("experience",reward.experience().toString());saved.put("coins",reward.coins().toString());return saved;}).collect(java.util.stream.Collectors.toCollection(ArrayList::new)));row.put("published",value.published());return row;}
private DownedEpisode decode(Map<?,?> row){
String attacker=optional(row,"attacker"),outcome=optional(row,"outcome"),operation=optional(row,"outcome-operation"),resolved=optional(row,"resolved-at"),protection=optional(row,"protection-until"),revision=optional(row,"character-revision"),characterName=optional(row,"character-name"),playerName=optional(row,"player-name"),decisionSource=optional(row,"decision-source"),decisionPlayer=optional(row,"decision-actor-player"),decisionPlayerName=optional(row,"decision-actor-player-name"),decisionCharacter=optional(row,"decision-actor-character"),decisionCharacterName=optional(row,"decision-actor-character-name"),decisionSession=optional(row,"decision-actor-session"),decisionAction=optional(row,"decision-action"),decisionCastAt=optional(row,"decision-cast-at"),decisionCooldown=optional(row,"decision-cooldown-until"),decisionRange=optional(row,"decision-final-range");
UUID world=uuid(row,"world"),entryWorld=row.containsKey("entry-world")?uuid(row,"entry-world"):world;
double x=Double.parseDouble(text(row,"x")),y=Double.parseDouble(text(row,"y")),z=Double.parseDouble(text(row,"z"));
float yaw=Float.parseFloat(text(row,"yaw")),pitch=Float.parseFloat(text(row,"pitch"));
var decision=decisionSource==null?null:new DownedEpisode.Decision(decisionSource,decisionPlayer==null?null:UUID.fromString(decisionPlayer),decisionPlayerName,decisionCharacter==null?null:UUID.fromString(decisionCharacter),decisionCharacterName,decisionSession==null?null:UUID.fromString(decisionSession),UUID.fromString(decisionAction),decisionCastAt==null?null:Instant.parse(decisionCastAt),decisionCooldown==null?null:Instant.parse(decisionCooldown),decisionRange==null?null:Double.valueOf(decisionRange),row.get("decision-line-of-sight")==null?null:Boolean.valueOf(String.valueOf(row.get("decision-line-of-sight"))));
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));}
@@ -45,19 +45,34 @@ public record DownedEpisode(UUID id, UUID character, UUID player, UUID session,
}
}
public record Phoenix(BigInteger quantityBefore, BigInteger quantityAfter,
java.math.BigDecimal healthBefore, java.math.BigDecimal healthAfter,
java.math.BigDecimal maximum, String entryPoint) {
public Phoenix {
Objects.requireNonNull(quantityBefore);Objects.requireNonNull(quantityAfter);
Objects.requireNonNull(healthBefore);Objects.requireNonNull(healthAfter);Objects.requireNonNull(maximum);
if(quantityBefore.signum()<=0||!quantityAfter.equals(quantityBefore.subtract(BigInteger.ONE))
||healthBefore.signum()<0||maximum.signum()<=0||healthAfter.compareTo(maximum)!=0
||healthBefore.compareTo(maximum)>0||!"downed_menu".equals(entryPoint))throw new IllegalArgumentException("Invalid Phoenix Down decision");
}
}
/** Durable winning decision context; nullable actor fields represent an automatic system timeout. */
public record Decision(String source, UUID actorPlayer, String actorPlayerName,
UUID actorCharacter, String actorCharacterName, UUID actorSession,
UUID actionId, Instant castAt, Instant cooldownUntil,
Double finalRange, Boolean lineOfSight) {
Double finalRange, Boolean lineOfSight, Phoenix phoenix) {
public Decision(String source,UUID actorPlayer,String actorPlayerName,UUID actorCharacter,String actorCharacterName,UUID actorSession,UUID actionId,Instant castAt,Instant cooldownUntil,Double finalRange,Boolean lineOfSight){this(source,actorPlayer,actorPlayerName,actorCharacter,actorCharacterName,actorSession,actionId,castAt,cooldownUntil,finalRange,lineOfSight,null);}
public Decision {
if (source == null || !List.of("revive", "accept_death", "timeout").contains(source)
if (source == null || !List.of("revive", "phoenix_down", "accept_death", "timeout").contains(source)
|| actionId == null || (actorPlayer == null) != (actorPlayerName == null)
|| (actorCharacter == null) != (actorCharacterName == null)
|| (actorCharacter == null) != (actorSession == null)
|| finalRange != null && (!Double.isFinite(finalRange) || finalRange < 0)
|| "revive".equals(source) && (actorPlayer == null || actorCharacter == null
|| castAt == null || cooldownUntil == null || finalRange == null || lineOfSight == null)) {
|| castAt == null || cooldownUntil == null || finalRange == null || lineOfSight == null)
|| "phoenix_down".equals(source) && (actorPlayer == null || actorCharacter == null || castAt == null || phoenix == null)
|| !"phoenix_down".equals(source) && phoenix != null) {
throw new IllegalArgumentException("Invalid downed decision");
}
}
@@ -1558,7 +1558,7 @@ 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];if(args.length==2){sender.sendMessage(key+"="+rareSettings.value(key)+(key.endsWith("-healing-percent")?" (applies to future accepted uses)":" (applies to the next rare restock only)"));return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RareSettings.Update> operation;try{operation=rareSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Rare setting is invalid; percentages are 0–100, values are nonnegative, and stock minima cannot exceed maxima.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;nextTick.accept(()->{boolean applied=failure==null&&rareSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Rare setting save failed; prior effective state is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+rareSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+"; current catalog and accepted uses are unchanged.");});});}
private void rareConfiguration(CommandSender sender,String[] args){String key=args[1],before=rareSettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;boolean phoenix=key.contains("phoenix-down");if(args.length==2){if(phoenix&&downedEvents!=null)downedEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+(key.endsWith("-healing-percent")||key.equals(games.dmg.fantasystick.persistence.RareSettings.PHOENIX_PROTECTION)?" (applies to future accepted uses)":" (applies to the next rare restock only)"));return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RareSettings.Update> operation;try{operation=rareSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Rare setting is invalid; percentages are 0–100, values are nonnegative, and stock minima cannot exceed maxima.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;nextTick.accept(()->{boolean applied=failure==null&&rareSettings.publish(saved);if(applied&&phoenix&&downedEvents!=null)downedEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Rare setting save failed; prior effective state is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+rareSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+"; current catalog and accepted uses are unchanged.");});});}
private void downedConfiguration(CommandSender sender,String[] args){String key=args[1],before=downedSettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;if(args.length==2){if(downedEvents!=null)downedEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+" (applies to future downed outcomes only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.DownedSettings.Update> operation;try{operation=downedSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Downed setting is invalid; timeout must be positive and protection may be zero, both at most 365 days.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;nextTick.accept(()->{boolean applied=failure==null&&downedSettings.publish(saved);if(applied&&downedEvents!=null)downedEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Downed setting save failed; prior effective state is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+downedSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+"; existing deadlines are unchanged.");});});}
private void coinConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+coinSettings.value(key)+" (applies to future creation or defeats only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.CoinSettings.Update> operation;try{operation=coinSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Coin setting is invalid; ranges must be ordered whole numbers from 0 through 2147483647.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=failure==null&&coinSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Coin configuration save failed; effective ranges are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+coinSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for future creation or defeats; existing balances and captured rewards are unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
@@ -0,0 +1,94 @@
package games.dmg.fantasystick.shop;
import games.dmg.fantasystick.persistence.RareSettings;
import games.dmg.fantasystick.session.DownedController;
import games.dmg.fantasystick.session.DownedDirectory;
import games.dmg.fantasystick.session.DownedEpisode;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.time.Clock;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.entity.Player;
/** Explicit exact-episode Phoenix Down reservation, rescue and restart reconciliation. */
public final class PhoenixDownService {
public enum Status { APPLIED, APPLIED_PENDING_RECOVERY, NOT_AVAILABLE, STALE, EXPIRED, FAILED }
public record Outcome(Status status) { }
@FunctionalInterface public interface Rescue {
void revive(Player player, java.time.Duration protection, DownedEpisode.Decision decision,
Consumer<DownedController.RescueStatus> result);
}
private final RareInventoryDirectory inventory;
private final RareSettings settings;
private final DownedDirectory downed;
private final EquipmentSessions sessions;
private final Clock clock;
private final Consumer<Runnable> nextTick;
private final Rescue rescue;
private final Consumer<Throwable> failures;
public PhoenixDownService(RareInventoryDirectory inventory, RareSettings settings,
DownedDirectory downed, EquipmentSessions sessions, Clock clock,
Consumer<Runnable> nextTick, Rescue rescue, Consumer<Throwable> failures) {
this.inventory=Objects.requireNonNull(inventory);this.settings=Objects.requireNonNull(settings);
this.downed=Objects.requireNonNull(downed);this.sessions=Objects.requireNonNull(sessions);
this.clock=Objects.requireNonNull(clock);this.nextTick=Objects.requireNonNull(nextTick);
this.rescue=Objects.requireNonNull(rescue);this.failures=Objects.requireNonNull(failures);
}
public BigInteger quantity(UUID character) { return inventory.quantity(character, RareProduct.PHOENIX_DOWN); }
/** A resolved matching outcome consumed the item; every provably unclaimed reservation is refunded. */
public void install() {
for (var use : inventory.unresolvedUses()) {
if (!RareProduct.PHOENIX_DOWN.id().equals(use.product())) continue;
var episode=downed.all().stream().filter(value->use.operation().equals(value.outcomeOperation())
&& value.outcome()==DownedEpisode.Outcome.REVIVED && value.decision()!=null
&& "phoenix_down".equals(value.decision().source())).findFirst();
var recovery=episode.isPresent()?inventory.commitUse(use.operation()):inventory.refundUse(use.operation());
recovery.exceptionally(failure->{report(failure);return RareInventoryDirectory.UseStatus.RESERVED;});
}
}
public void use(Player player, UUID episodeId, Consumer<Outcome> result) {
Objects.requireNonNull(player);Objects.requireNonNull(episodeId);Objects.requireNonNull(result);
var episode=current(player,episodeId);
if(episode==null){result.accept(new Outcome(Status.STALE));return;}
if(!clock.instant().isBefore(episode.expiresAt())){result.accept(new Outcome(Status.EXPIRED));return;}
var expected=quantity(episode.character());
if(expected.signum()<=0){result.accept(new Outcome(Status.NOT_AVAILABLE));return;}
var attribute=player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH);double health=player.getHealth(),maximum=attribute==null?Double.NaN:attribute.getValue();
if(!Double.isFinite(health)||!Double.isFinite(maximum)||health<0||maximum<=0||health>maximum){result.accept(new Outcome(Status.FAILED));return;}
var operation=UUID.randomUUID();
inventory.reserveUse(episode.character(),RareProduct.PHOENIX_DOWN,operation,expected).whenComplete((reserved,failure)->publish(()->{
if(failure!=null){report(failure);result.accept(new Outcome(Status.FAILED));return;}
if(reserved.status()!=RareInventoryDirectory.UseStatus.RESERVED){result.accept(new Outcome(reserved.status()==RareInventoryDirectory.UseStatus.NOT_AVAILABLE?Status.NOT_AVAILABLE:Status.STALE));return;}
var accepted=current(player,episodeId);
if(accepted==null||!clock.instant().isBefore(accepted.expiresAt())){refund(operation,result,accepted==null?Status.STALE:Status.EXPIRED);return;}
var proof=new DownedEpisode.Phoenix(expected,expected.subtract(BigInteger.ONE),java.math.BigDecimal.valueOf(health),java.math.BigDecimal.valueOf(maximum),java.math.BigDecimal.valueOf(maximum),"downed_menu");
var decision=new DownedEpisode.Decision("phoenix_down",accepted.player(),accepted.playerName(),accepted.character(),accepted.characterName(),accepted.session(),operation,clock.instant(),null,null,null,proof);
rescue.revive(player,settings.phoenixProtection(),decision,status->complete(operation,status,result));
}));
}
private DownedEpisode current(Player player,UUID episodeId){
var episode=downed.all().stream().filter(value->value.id().equals(episodeId)&&value.phase()==DownedEpisode.Phase.PENDING).findFirst().orElse(null);
if(episode==null||!player.isOnline()||!episode.player().equals(player.getUniqueId()))return null;
var active=sessions.active(player.getUniqueId()).orElse(null);
return active!=null&&active.session().equals(episode.session())&&active.character().equals(episode.character())?episode:null;
}
private void complete(UUID operation,DownedController.RescueStatus status,Consumer<Outcome> result){
boolean won=downed.all().stream().anyMatch(value->operation.equals(value.outcomeOperation())&&value.outcome()==DownedEpisode.Outcome.REVIVED&&value.decision()!=null&&"phoenix_down".equals(value.decision().source()));
if(won){inventory.commitUse(operation).whenComplete((ignored,failure)->publish(()->{if(failure!=null){report(failure);result.accept(new Outcome(Status.APPLIED_PENDING_RECOVERY));}else result.accept(new Outcome(status==DownedController.RescueStatus.APPLIED?Status.APPLIED:Status.APPLIED_PENDING_RECOVERY));}));return;}
var mapped=switch(status){case EXPIRED->Status.EXPIRED;case STALE->Status.STALE;case FAILED->Status.FAILED;case APPLIED,APPLIED_PENDING_RECOVERY->Status.APPLIED_PENDING_RECOVERY;};
refund(operation,result,mapped);
}
private void refund(UUID operation,Consumer<Outcome> result,Status status){inventory.refundUse(operation).whenComplete((ignored,failure)->publish(()->{if(failure!=null){report(failure);result.accept(new Outcome(Status.FAILED));}else result.accept(new Outcome(status));}));}
private void publish(Runnable action){try{nextTick.accept(()->{try{action.run();}catch(RuntimeException failure){report(failure);}});}catch(RuntimeException failure){report(failure);}}
private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}
}
@@ -12,7 +12,8 @@ import java.util.function.Supplier;
/** Serialized recoverable authority for the separate shared rare catalog. */
public final class RareMarket implements AutoCloseable {
public enum PurchaseStatus{APPLIED,SOLD_OUT,STALE,INSUFFICIENT,UNAVAILABLE,FAILED}
public record PurchaseOutcome(PurchaseStatus status,Optional<CharacterRecord> record,RareCatalog catalog){}
public record PurchaseProof(UUID operation,RareProduct product,BigInteger price,int stockAfter){}
public record PurchaseOutcome(PurchaseStatus status,Optional<CharacterRecord> record,RareCatalog catalog,PurchaseProof proof){public PurchaseOutcome(PurchaseStatus status,Optional<CharacterRecord> record,RareCatalog catalog){this(status,record,catalog,null);}}
private final RareCatalogRepository catalogs;private final RarePurchaseOperationStore operations;private final CharacterStorage characters;private final RareInventoryDirectory inventory;private final Supplier<RareMarketRules> rules;private final Clock clock;private final RareCatalogGenerator.InclusiveRoll random;private final ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-rare-market"));private final CompletionStage<Void> readiness;private volatile RareCatalog snapshot;private volatile boolean initialized;private volatile Consumer<RareCatalog> listener=ignored->{};private boolean closed,operationsClosing;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
public RareMarket(RareCatalogRepository catalogs,RarePurchaseOperationStore operations,CharacterStorage characters,RareInventoryDirectory inventory,Supplier<RareMarketRules> rules,Clock clock,RareCatalogGenerator.InclusiveRoll random){this.catalogs=Objects.requireNonNull(catalogs);this.operations=Objects.requireNonNull(operations);this.characters=Objects.requireNonNull(characters);this.inventory=Objects.requireNonNull(inventory);this.rules=Objects.requireNonNull(rules);this.clock=Objects.requireNonNull(clock);this.random=Objects.requireNonNull(random);readiness=submit(()->{initialize();return null;});}
public void attachListener(Consumer<RareCatalog> listener){this.listener=Objects.requireNonNull(listener);}public Optional<RareCatalog> snapshot(){return Optional.ofNullable(snapshot);}public Instant now(){return clock.instant();}public CompletionStage<Void> ready(){return readiness;}public CompletionStage<RareCatalog> current(){return readiness.thenCompose(ignored->submit(()->ensureCurrent(RareCatalog.RefreshReason.SCHEDULED)));}
@@ -21,7 +22,7 @@ public final class RareMarket implements AutoCloseable {
public CompletionStage<PurchaseOutcome> purchase(UUID character,BigInteger expectedCharacterRevision,UUID expectedCatalogRevision,RareProduct product){return readiness.thenCompose(ignored->submit(()->purchaseBlocking(character,expectedCharacterRevision,expectedCatalogRevision,product)));}
private void initialize()throws Exception{snapshot=catalogs.load().orElse(null);var plans=operations.load().toCompletableFuture().join();if(snapshot==null&&!plans.isEmpty())throw new IOException("Rare catalog missing for pending purchases");for(var plan:plans)execute(plan);if(snapshot!=null&&!snapshot.transactions().isEmpty()){var next=snapshot;for(var entry:snapshot.transactions().entrySet()){if(entry.getValue().state()==RareCatalog.TransactionState.RESERVED)throw new IOException("Orphan reserved rare purchase "+entry.getKey());next=next.prune(entry.getKey());}persist(next,false);}ensureCurrent(RareCatalog.RefreshReason.STARTUP);initialized=true;}
private PurchaseOutcome purchaseBlocking(UUID character,BigInteger expectedCharacterRevision,UUID expectedCatalogRevision,RareProduct product)throws Exception{var catalog=ensureCurrent(RareCatalog.RefreshReason.SCHEDULED);if(!catalog.transactions().isEmpty())return new PurchaseOutcome(PurchaseStatus.FAILED,Optional.empty(),catalog);if(!catalog.revision().equals(expectedCatalogRevision))return new PurchaseOutcome(PurchaseStatus.STALE,Optional.empty(),catalog);if(!catalog.available(product))return new PurchaseOutcome(PurchaseStatus.UNAVAILABLE,Optional.empty(),catalog);if(catalog.offer(product).remaining()==0)return new PurchaseOutcome(PurchaseStatus.SOLD_OUT,Optional.empty(),catalog);var plan=new RarePurchaseOperationStore.Plan(UUID.randomUUID(),character,expectedCharacterRevision,catalog.revision(),product,catalog.offer(product).price());operations.prepare(plan).toCompletableFuture().join();return execute(plan);}
private PurchaseOutcome execute(RarePurchaseOperationStore.Plan plan)throws Exception{var catalog=Objects.requireNonNull(snapshot,"Rare catalog unavailable");if(!catalog.revision().equals(plan.catalogRevision()))throw new IOException("Pending rare purchase belongs to another catalog");var transaction=catalog.transactions().get(plan.id());if(transaction==null){try{catalog=catalog.reserve(plan.id(),plan.product());}catch(IllegalStateException soldOut){operations.complete(plan.id()).toCompletableFuture().join();return new PurchaseOutcome(PurchaseStatus.SOLD_OUT,Optional.empty(),catalog);}persist(catalog,false);transaction=catalog.transactions().get(plan.id());}if(transaction.product()!=plan.product())throw new IOException("Rare reservation product mismatch");if(transaction.state()!=RareCatalog.TransactionState.RESERVED){if(transaction.state()==RareCatalog.TransactionState.CONSUMED)inventory.add(plan.character(),plan.product(),plan.id()).join();operations.complete(plan.id()).toCompletableFuture().join();var pruned=catalog.prune(plan.id());persist(pruned,false);return new PurchaseOutcome(transaction.state()==RareCatalog.TransactionState.CONSUMED?PurchaseStatus.APPLIED:PurchaseStatus.FAILED,characters.find(plan.character()).join(),pruned);}var purchase=characters.purchaseRare(plan.character(),plan.expectedCharacterRevision(),plan.id(),plan.price()).join();boolean consumed=purchase.status()==CharacterStorage.RarePurchaseStatus.APPLIED||purchase.status()==CharacterStorage.RarePurchaseStatus.DUPLICATE;if(consumed)inventory.add(plan.character(),plan.product(),plan.id()).join();catalog=catalog.settle(plan.id(),consumed);persist(catalog,consumed);operations.complete(plan.id()).toCompletableFuture().join();catalog=catalog.prune(plan.id());persist(catalog,false);var status=switch(purchase.status()){case APPLIED,DUPLICATE->PurchaseStatus.APPLIED;case INSUFFICIENT->PurchaseStatus.INSUFFICIENT;case STALE->PurchaseStatus.STALE;case UNAVAILABLE->PurchaseStatus.UNAVAILABLE;};return new PurchaseOutcome(status,purchase.record(),catalog);}
private PurchaseOutcome execute(RarePurchaseOperationStore.Plan plan)throws Exception{var catalog=Objects.requireNonNull(snapshot,"Rare catalog unavailable");if(!catalog.revision().equals(plan.catalogRevision()))throw new IOException("Pending rare purchase belongs to another catalog");var transaction=catalog.transactions().get(plan.id());if(transaction==null){try{catalog=catalog.reserve(plan.id(),plan.product());}catch(IllegalStateException soldOut){operations.complete(plan.id()).toCompletableFuture().join();return new PurchaseOutcome(PurchaseStatus.SOLD_OUT,Optional.empty(),catalog);}persist(catalog,false);transaction=catalog.transactions().get(plan.id());}if(transaction.product()!=plan.product())throw new IOException("Rare reservation product mismatch");if(transaction.state()!=RareCatalog.TransactionState.RESERVED){if(transaction.state()==RareCatalog.TransactionState.CONSUMED)inventory.add(plan.character(),plan.product(),plan.id()).join();operations.complete(plan.id()).toCompletableFuture().join();var pruned=catalog.prune(plan.id());persist(pruned,false);return new PurchaseOutcome(transaction.state()==RareCatalog.TransactionState.CONSUMED?PurchaseStatus.APPLIED:PurchaseStatus.FAILED,characters.find(plan.character()).join(),pruned,transaction.state()==RareCatalog.TransactionState.CONSUMED?new PurchaseProof(plan.id(),plan.product(),plan.price(),pruned.offer(plan.product()).remaining()):null);}var purchase=characters.purchaseRare(plan.character(),plan.expectedCharacterRevision(),plan.id(),plan.price()).join();boolean consumed=purchase.status()==CharacterStorage.RarePurchaseStatus.APPLIED||purchase.status()==CharacterStorage.RarePurchaseStatus.DUPLICATE;if(consumed)inventory.add(plan.character(),plan.product(),plan.id()).join();catalog=catalog.settle(plan.id(),consumed);persist(catalog,consumed);operations.complete(plan.id()).toCompletableFuture().join();catalog=catalog.prune(plan.id());persist(catalog,false);var status=switch(purchase.status()){case APPLIED,DUPLICATE->PurchaseStatus.APPLIED;case INSUFFICIENT->PurchaseStatus.INSUFFICIENT;case STALE->PurchaseStatus.STALE;case UNAVAILABLE->PurchaseStatus.UNAVAILABLE;};return new PurchaseOutcome(status,purchase.record(),catalog,status==PurchaseStatus.APPLIED?new PurchaseProof(plan.id(),plan.product(),plan.price(),catalog.offer(plan.product()).remaining()):null);}
private RareCatalog ensureCurrent(RareCatalog.RefreshReason reason)throws IOException{var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));if(snapshot!=null&&snapshot.day().equals(today))return snapshot;if(snapshot!=null&&!snapshot.transactions().isEmpty())throw new IOException("Rare purchases unresolved at rollover");var generated=RareCatalogGenerator.generate(today,clock.instant().truncatedTo(java.time.temporal.ChronoUnit.SECONDS),reason,rules.get(),random);persist(generated,true);return generated;}
private void persist(RareCatalog catalog,boolean notify)throws IOException{catalogs.save(catalog);snapshot=catalog;if(notify&&initialized)try{listener.accept(catalog);}catch(RuntimeException ignored){}}
private <T>CompletionStage<T> submit(Callable<T> action){synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Rare market closed"));pending++;}return CompletableFuture.supplyAsync(()->{try{return action.call();}catch(Exception failure){throw new CompletionException(failure);}finally{finished();}},worker).minimalCompletionStage();}
@@ -9,7 +9,8 @@ public enum RareProduct {
HEALTH_POTION("health-potion", "Health Potion", 150, 5, 50),
GREATER_HEALTH_POTION("greater-health-potion", "Greater Health Potion", 500, 2, 100),
RESKILL("reskill", "Reskill", 2000, 1, null),
MAGIC_CORE("magic-core", "Magic Core", 3000, 3, null);
MAGIC_CORE("magic-core", "Magic Core", 3000, 3, null),
PHOENIX_DOWN("phoenix-down", "Phoenix Down", 1000, 2, null);
private final String id;
private final String displayName;
@@ -15,11 +15,12 @@ public final class RareShop {
public record Outcome(Status status,Optional<CharacterRecord> record){}
public record View(CharacterRecord record,UUID session,RareCatalog catalog){}
private record Context(EquipmentSessions.Active active,CharacterRecord record,RareMerchant shop){}
private final RareMerchantDirectory shops;private final EquipmentSessions sessions;private final RareMarket market;private final Consumer<Runnable> nextTick;private final Consumer<Throwable> failures;
public RareShop(RareMerchantDirectory shops,EquipmentSessions sessions,RareMarket market,Consumer<Runnable> nextTick,Consumer<Throwable> failures){this.shops=Objects.requireNonNull(shops);this.sessions=Objects.requireNonNull(sessions);this.market=Objects.requireNonNull(market);this.nextTick=Objects.requireNonNull(nextTick);this.failures=Objects.requireNonNull(failures);}
private final RareMerchantDirectory shops;private final EquipmentSessions sessions;private final RareMarket market;private final RareInventoryDirectory inventory;private final Consumer<Runnable> nextTick;private final Consumer<Throwable> failures;private games.dmg.fantasystick.events.RareEventBridge events;
public RareShop(RareMerchantDirectory shops,EquipmentSessions sessions,RareMarket market,RareInventoryDirectory inventory,Consumer<Runnable> nextTick,Consumer<Throwable> failures){this.shops=Objects.requireNonNull(shops);this.sessions=Objects.requireNonNull(sessions);this.market=Objects.requireNonNull(market);this.inventory=Objects.requireNonNull(inventory);this.nextTick=Objects.requireNonNull(nextTick);this.failures=Objects.requireNonNull(failures);}
public void attachEvents(games.dmg.fantasystick.events.RareEventBridge events){if(this.events!=null)throw new IllegalStateException("Rare events already attached");this.events=Objects.requireNonNull(events);}
public Optional<CharacterRecord> active(Player player,UUID shop){return context(player,shop).map(Context::record);}public Optional<UUID> activeSession(Player player,UUID shop){return context(player,shop).map(value->value.active().session());}public Instant now(){return market.now();}
public void view(Player player,UUID shopId,Consumer<Optional<View>> result){var captured=context(player,shopId);if(captured.isEmpty()){result.accept(Optional.empty());return;}market.current().whenComplete((catalog,failure)->{if(failure!=null){report(failure);publish(()->result.accept(Optional.empty()));return;}publish(()->{var current=context(player,shopId);if(current.isEmpty()||!current.orElseThrow().active().session().equals(captured.orElseThrow().active().session()))result.accept(Optional.empty());else result.accept(Optional.of(new View(current.orElseThrow().record(),current.orElseThrow().active().session(),catalog)));});});}
public void buy(Player player,UUID shopId,UUID catalogRevision,RareProduct product,Consumer<Outcome> result){var lease=shops.acquire(shopId);if(lease.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var held=lease.orElseThrow();var context=context(player,shopId);if(context.isEmpty()){held.close();result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var captured=context.orElseThrow();market.purchase(captured.record().id(),captured.record().revision(),catalogRevision,product).whenComplete((purchase,failure)->{if(failure!=null){report(failure);publishHeld(held,()->result.accept(new Outcome(Status.FAILED,Optional.empty())));return;}publishHeld(held,()->{var current=context(player,shopId);if(current.isEmpty()||!current.orElseThrow().active().session().equals(captured.active().session())){result.accept(new Outcome(Status.STALE,purchase.record()));return;}var status=switch(purchase.status()){case APPLIED->Status.APPLIED;case SOLD_OUT->Status.SOLD_OUT;case INSUFFICIENT->Status.INSUFFICIENT;case STALE->Status.STALE;case UNAVAILABLE,FAILED->Status.FAILED;};if(purchase.status()==RareMarket.PurchaseStatus.APPLIED&&purchase.record().isPresent())sessions.acknowledgeProgress(captured.active(),captured.record().revision(),purchase.record().orElseThrow());result.accept(new Outcome(status,purchase.record()));});});}
public void buy(Player player,UUID shopId,UUID catalogRevision,RareProduct product,Consumer<Outcome> result){var lease=shops.acquire(shopId);if(lease.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var held=lease.orElseThrow();var context=context(player,shopId);if(context.isEmpty()){held.close();result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var captured=context.orElseThrow();market.purchase(captured.record().id(),captured.record().revision(),catalogRevision,product).whenComplete((purchase,failure)->{if(failure!=null){report(failure);publishHeld(held,()->result.accept(new Outcome(Status.FAILED,Optional.empty())));return;}publishHeld(held,()->{var current=context(player,shopId);if(current.isEmpty()||!current.orElseThrow().active().session().equals(captured.active().session())){result.accept(new Outcome(Status.STALE,purchase.record()));return;}var status=switch(purchase.status()){case APPLIED->Status.APPLIED;case SOLD_OUT->Status.SOLD_OUT;case INSUFFICIENT->Status.INSUFFICIENT;case STALE->Status.STALE;case UNAVAILABLE,FAILED->Status.FAILED;};if(purchase.status()==RareMarket.PurchaseStatus.APPLIED&&purchase.record().isPresent()){sessions.acknowledgeProgress(captured.active(),captured.record().revision(),purchase.record().orElseThrow());if(product==RareProduct.PHOENIX_DOWN&&purchase.proof()!=null&&events!=null){var after=inventory.quantity(captured.record().id(),product);events.purchased(purchase.proof().operation(),purchase.catalog().revision(),player.getUniqueId(),player.getName(),captured.record().id(),captured.record().name(),captured.active().session(),purchase.proof().price(),purchase.proof().stockAfter()+1,purchase.proof().stockAfter(),after.subtract(java.math.BigInteger.ONE),after);}}result.accept(new Outcome(status,purchase.record()));});});}
private Optional<Context> context(Player player,UUID shopId){var shop=shops.find(shopId).orElse(null);var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);if(shop==null||record==null)return Optional.empty();var location=player.getLocation();if(location.getWorld()==null||!location.getWorld().getUID().equals(shop.world()))return Optional.empty();double x=location.getX()-shop.x(),y=location.getY()-shop.y(),z=location.getZ()-shop.z();return x*x+y*y+z*z<=36?Optional.of(new Context(active,record,shop)):Optional.empty();}
private void publishHeld(RareMerchantDirectory.Lease lease,Runnable action){try{nextTick.accept(()->{try{action.run();}finally{lease.close();}});}catch(RuntimeException failure){lease.close();report(failure);}}private void publish(Runnable action){try{nextTick.accept(action);}catch(RuntimeException failure){report(failure);}}private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}
}
+4
View File
@@ -47,6 +47,10 @@ rare-stock-reskill-max: 1
rare-price-magic-core: 3000
rare-stock-magic-core-min: 0
rare-stock-magic-core-max: 3
rare-price-phoenix-down: 1000
rare-stock-phoenix-down-min: 0
rare-stock-phoenix-down-max: 2
rare-phoenix-down-invulnerability-seconds: 5
downed-timeout-seconds: 300
revive-downed-invulnerability-seconds: 5
sheet-resource-pack-url: ''
@@ -42,10 +42,10 @@ class DownedSessionNativeTest{
var types=java.util.List.of(org.bukkit.damage.DamageType.PLAYER_ATTACK,org.bukkit.damage.DamageType.ARROW,org.bukkit.damage.DamageType.ON_FIRE,org.bukkit.damage.DamageType.FALL);int index=0;for(var type:types){var directory=data.resolve("cause-"+index++);java.nio.file.Files.createDirectories(directory);try(var f=new FantasyStickLifecycleNativeTest.Fixture(directory)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(directory);f.players.put(actor.core.id,actor.player);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);actor.core.health=0;var death=death(actor,type);f.dispatcher.accept(death);var downed=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&(downed.all().isEmpty()||actor.inventories.isEmpty());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(death.isCancelled());assertFalse(actor.inventories.isEmpty());assertEquals(type.getKey().asString(),downed.all().getFirst().attribution().cause());f.plugin.setEnabled(false);var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(directory.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}var recorded=events.stream().filter(event->event.get("type").getAsString().endsWith("character.downed")).map(event->event.getAsJsonObject("data")).findFirst().orElseThrow();assertEquals(type.getKey().asString(),recorded.get("lethal_cause").getAsString());}}
}
@Test void nativeLethalOutcomeEntersDurableDownedPhaseWithoutFinalDeathOrTail()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);actor.core.location.add(7,0,3);var anchor=actor.core.location.clone();actor.core.health=0;var death=death(actor);f.dispatcher.accept(death);actor.player.setHealth(death.getReviveHealth());var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&directory.all().isEmpty();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(death.isCancelled());assertEquals(1d,actor.player.getHealth());var episode=directory.all().getFirst();assertEquals(actor.core.record.id(),episode.character());assertEquals(actor.core.id,episode.player());assertEquals(anchor.getWorld().getUID(),episode.world());assertEquals(anchor.getX(),episode.x());assertEquals(games.dmg.fantasystick.session.DownedEpisode.Phase.PENDING,episode.phase());var characters=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(characters.find(actor.core.record.id()).join().orElseThrow().dead());assertNotEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");var active=sessions.active(actor.core.id).orElseThrow();assertFalse(sessions.current(active));assertTrue(sessions.downed(actor.core.id).isPresent());}
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);actor.core.location.add(7,0,3);var anchor=actor.core.location.clone();actor.core.health=0;var death=death(actor);f.dispatcher.accept(death);actor.player.setHealth(death.getReviveHealth());var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&(directory.all().isEmpty()||actor.inventories.isEmpty());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(death.isCancelled());assertEquals(1d,actor.player.getHealth());var episode=directory.all().getFirst();assertEquals(actor.core.record.id(),episode.character());assertEquals(actor.core.id,episode.player());assertEquals(anchor.getWorld().getUID(),episode.world());assertEquals(anchor.getX(),episode.x());assertEquals(games.dmg.fantasystick.session.DownedEpisode.Phase.PENDING,episode.phase());var characters=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(characters.find(actor.core.record.id()).join().orElseThrow().dead());assertNotEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");var active=sessions.active(actor.core.id).orElseThrow();assertFalse(sessions.current(active));assertTrue(sessions.downed(actor.core.id).isPresent());assertEquals(org.bukkit.Material.GRAY_DYE,actor.inventories.getLast().getItem(13).getType());}
}
@Test void claimedReviveRestoresFullHealthInPlaceAndAddsBoundedDamageProtection()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);actor.core.location.add(4,0,2);var anchor=actor.core.location.clone();actor.core.health=0;f.dispatcher.accept(death(actor));var controller=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downedController");var outcome=new java.util.concurrent.atomic.AtomicReference<games.dmg.fantasystick.session.DownedController.RescueStatus>();for(int attempt=0;attempt<80&&!controller.downed(actor.player);attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}controller.revive(actor.player,java.time.Duration.ofSeconds(5),outcome::set);for(int attempt=0;attempt<80&&outcome.get()==null;attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertEquals(games.dmg.fantasystick.session.DownedController.RescueStatus.APPLIED,outcome.get());assertEquals(actor.maximumHealth.getValue(),actor.player.getHealth());assertEquals(anchor,actor.player.getLocation());var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");assertTrue(sessions.current(sessions.active(actor.core.id).orElseThrow()));assertTrue(sessions.downedProtected(actor.core.id));assertFalse(new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow().dead());var damage=org.mockito.Mockito.mock(org.bukkit.event.entity.EntityDamageEvent.class);org.mockito.Mockito.when(damage.getEntity()).thenReturn(actor.player);controller.damage(damage);org.mockito.Mockito.verify(damage).setCancelled(true);assertNotEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());sessions.exit(actor.player);f.awaitCharacterAndReceipt(actor);sessions.arrive(actor.player);for(int attempt=0;attempt<120&&sessions.active(actor.core.id).isEmpty();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(sessions.downedProtected(actor.core.id),"Reconnect retains the original absolute protection deadline");f.plugin.setEnabled(false);var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}assertTrue(events.stream().filter(event->event.get("type").getAsString().endsWith("combat.prevented")).map(event->event.getAsJsonObject("data")).anyMatch(event->event.get("reason").getAsString().equals("revive_protection")));}
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);var retainedPhoenix=(games.dmg.fantasystick.shop.RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");retainedPhoenix.add(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();actor.core.location.add(4,0,2);var anchor=actor.core.location.clone();actor.core.health=0;f.dispatcher.accept(death(actor));var controller=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downedController");var outcome=new java.util.concurrent.atomic.AtomicReference<games.dmg.fantasystick.session.DownedController.RescueStatus>();for(int attempt=0;attempt<80&&!controller.downed(actor.player);attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}controller.revive(actor.player,java.time.Duration.ofSeconds(5),outcome::set);for(int attempt=0;attempt<80&&outcome.get()==null;attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertEquals(games.dmg.fantasystick.session.DownedController.RescueStatus.APPLIED,outcome.get());assertEquals(actor.maximumHealth.getValue(),actor.player.getHealth());assertEquals(anchor,actor.player.getLocation());var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");assertTrue(sessions.current(sessions.active(actor.core.id).orElseThrow()));assertTrue(sessions.downedProtected(actor.core.id));assertFalse(new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow().dead());var damage=org.mockito.Mockito.mock(org.bukkit.event.entity.EntityDamageEvent.class);org.mockito.Mockito.when(damage.getEntity()).thenReturn(actor.player);controller.damage(damage);org.mockito.Mockito.verify(damage).setCancelled(true);assertNotEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());sessions.exit(actor.player);f.awaitCharacterAndReceipt(actor);sessions.arrive(actor.player);for(int attempt=0;attempt<120&&sessions.active(actor.core.id).isEmpty();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(sessions.downedProtected(actor.core.id),"Reconnect retains the original absolute protection deadline");f.plugin.setEnabled(false);var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}assertTrue(events.stream().filter(event->event.get("type").getAsString().endsWith("combat.prevented")).map(event->event.getAsJsonObject("data")).anyMatch(event->event.get("reason").getAsString().equals("revive_protection")));assertEquals(java.math.BigInteger.ONE,retainedPhoenix.quantity(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN));}
}
@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));}
@@ -60,7 +60,10 @@ class DownedSessionNativeTest{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());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);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());}
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);}}
@@ -0,0 +1,68 @@
package games.dmg.fantasystick;
import games.dmg.fantasystick.shop.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.persistence.RareSettings;
import games.dmg.fantasystick.persistence.YamlRareInventoryRepository;
import games.dmg.fantasystick.session.DownedController;
import games.dmg.fantasystick.session.DownedDirectory;
import games.dmg.fantasystick.session.DownedEpisode;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.nio.file.Path;
import java.time.*;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.bukkit.attribute.Attribute;
import org.bukkit.attribute.AttributeInstance;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class PhoenixDownServiceTest {
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@TempDir Path data;
private final Clock clock=Clock.fixed(Instant.parse("2026-09-25T00:00:00Z"),ZoneOffset.UTC);
@Test void explicitCurrentEpisodeSelectionReservesClaimsAndCommitsExactlyOne()throws Exception{
UUID character=UUID.randomUUID(),playerId=UUID.randomUUID(),session=UUID.randomUUID(),world=UUID.randomUUID();
try(var inventory=new RareInventoryDirectory(new YamlRareInventoryRepository(data));var downed=new DownedDirectory(data,clock);var settings=new RareSettings(data,Map.of())){
inventory.add(character,RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();var episode=downed.begin(character,playerId,session,world,1,2,3,0,0,world,4,5,6,0,0,"Wren","Account",BigInteger.TEN,Duration.ofMinutes(5),new DownedEpisode.Attribution(null,"minecraft:fall")).join();
var sessions=mock(EquipmentSessions.class);when(sessions.active(playerId)).thenReturn(Optional.of(new EquipmentSessions.Active(playerId,session,character)));var player=player(playerId,1,10);var captured=new AtomicReference<DownedEpisode.Decision>();
PhoenixDownService.Rescue rescue=(target,protection,decision,result)->{captured.set(decision);assertEquals(Duration.ofSeconds(5),protection);assertEquals(DownedDirectory.ClaimStatus.APPLIED,downed.claim(episode.id(),DownedEpisode.Outcome.REVIVED,decision.actionId(),decision,protection,List.of()).join().status());result.accept(DownedController.RescueStatus.APPLIED);};
var service=new PhoenixDownService(inventory,settings,downed,sessions,clock,Runnable::run,rescue,failureSink("unexpected failure"));var outcome=new CompletableFuture<PhoenixDownService.Outcome>();service.use(player,episode.id(),outcome::complete);assertEquals(PhoenixDownService.Status.APPLIED,outcome.get(5,TimeUnit.SECONDS).status());assertEquals(BigInteger.ZERO,service.quantity(character));var decision=captured.get();assertEquals("phoenix_down",decision.source());assertEquals("downed_menu",decision.phoenix().entryPoint());assertEquals(BigInteger.ONE,decision.phoenix().quantityBefore());assertEquals(BigInteger.ZERO,decision.phoenix().quantityAfter());assertEquals("1.0",decision.phoenix().healthBefore().toPlainString());assertEquals("10.0",decision.phoenix().healthAfter().toPlainString());
}
}
@Test void startupRefundsUnclaimedReservationAndCommitsMatchingResolvedProof()throws Exception{
UUID character=UUID.randomUUID(),player=UUID.randomUUID(),session=UUID.randomUUID(),world=UUID.randomUUID(),refund=UUID.randomUUID(),commit=UUID.randomUUID();
try(var inventory=new RareInventoryDirectory(new YamlRareInventoryRepository(data));var downed=new DownedDirectory(data,clock);var settings=new RareSettings(data,Map.of())){
inventory.add(character,RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();inventory.add(character,RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();var episode=downed.begin(character,player,session,world,0,0,0,0,0,Duration.ofMinutes(5),new DownedEpisode.Attribution(null,"minecraft:fall")).join();inventory.reserveUse(character,RareProduct.PHOENIX_DOWN,refund,BigInteger.TWO).join();var service=new PhoenixDownService(inventory,settings,downed,mock(EquipmentSessions.class),clock,Runnable::run,(a,b,c,d)->org.junit.jupiter.api.Assertions.fail(),failureSink("unexpected failure"));service.install();await(()->inventory.unresolvedUses().isEmpty());assertEquals(BigInteger.TWO,inventory.quantity(character,RareProduct.PHOENIX_DOWN));
inventory.reserveUse(character,RareProduct.PHOENIX_DOWN,commit,BigInteger.TWO).join();var proof=new DownedEpisode.Phoenix(BigInteger.TWO,BigInteger.ONE,java.math.BigDecimal.ONE,java.math.BigDecimal.TEN,java.math.BigDecimal.TEN,"downed_menu");var decision=new DownedEpisode.Decision("phoenix_down",player,"Account",character,"Wren",session,commit,clock.instant(),null,null,null,proof);downed.claim(episode.id(),DownedEpisode.Outcome.REVIVED,commit,decision,Duration.ofSeconds(5),List.of()).join();service.install();await(()->inventory.unresolvedUses().isEmpty());assertEquals(BigInteger.ONE,inventory.quantity(character,RareProduct.PHOENIX_DOWN));
}
}
@Test void finalDeathWinningAfterReservationRefundsThePhoenixDown()throws Exception{
UUID character=UUID.randomUUID(),playerId=UUID.randomUUID(),session=UUID.randomUUID(),world=UUID.randomUUID();
try(var inventory=new RareInventoryDirectory(new YamlRareInventoryRepository(data));var downed=new DownedDirectory(data,clock);var settings=new RareSettings(data,Map.of())){
inventory.add(character,RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();var episode=downed.begin(character,playerId,session,world,0,0,0,0,0,world,0,0,0,0,0,"Wren","Account",BigInteger.ONE,Duration.ofMinutes(5),new DownedEpisode.Attribution(null,"minecraft:fall")).join();var sessions=mock(EquipmentSessions.class);when(sessions.active(playerId)).thenReturn(Optional.of(new EquipmentSessions.Active(playerId,session,character)));PhoenixDownService.Rescue losing=(target,protection,decision,result)->{downed.claim(episode.id(),DownedEpisode.Outcome.FINAL_DEATH,UUID.randomUUID(),Duration.ZERO).join();result.accept(DownedController.RescueStatus.STALE);};var service=new PhoenixDownService(inventory,settings,downed,sessions,clock,Runnable::run,losing,failureSink("unexpected failure"));var outcome=new CompletableFuture<PhoenixDownService.Outcome>();service.use(player(playerId,1,10),episode.id(),outcome::complete);assertEquals(PhoenixDownService.Status.STALE,outcome.get(5,TimeUnit.SECONDS).status());assertEquals(BigInteger.ONE,inventory.quantity(character,RareProduct.PHOENIX_DOWN));assertEquals(DownedEpisode.Outcome.FINAL_DEATH,downed.find(character).join().orElseThrow().outcome());
}
}
@Test void missingItemOrForeignStaleEpisodeCannotInvokeRescue()throws Exception{
UUID character=UUID.randomUUID(),owner=UUID.randomUUID(),foreign=UUID.randomUUID(),session=UUID.randomUUID(),world=UUID.randomUUID();
try(var inventory=new RareInventoryDirectory(new YamlRareInventoryRepository(data));var downed=new DownedDirectory(data,clock);var settings=new RareSettings(data,Map.of())){
var episode=downed.begin(character,owner,session,world,0,0,0,0,0,Duration.ofMinutes(5),new DownedEpisode.Attribution(null,"minecraft:fall")).join();var sessions=mock(EquipmentSessions.class);when(sessions.active(owner)).thenReturn(Optional.of(new EquipmentSessions.Active(owner,session,character)));var invoked=new java.util.concurrent.atomic.AtomicBoolean();var service=new PhoenixDownService(inventory,settings,downed,sessions,clock,Runnable::run,(a,b,c,d)->invoked.set(true),failureSink("unexpected failure"));var none=new AtomicReference<PhoenixDownService.Outcome>();service.use(player(owner,1,10),episode.id(),none::set);assertEquals(PhoenixDownService.Status.NOT_AVAILABLE,none.get().status());var stale=new AtomicReference<PhoenixDownService.Outcome>();service.use(player(foreign,1,10),episode.id(),stale::set);assertEquals(PhoenixDownService.Status.STALE,stale.get().status());assertFalse(invoked.get());
}
}
private static Player player(UUID id,double health,double maximum){var player=mock(Player.class);when(player.getUniqueId()).thenReturn(id);when(player.isOnline()).thenReturn(true);when(player.getHealth()).thenReturn(health);var attribute=mock(AttributeInstance.class);when(attribute.getValue()).thenReturn(maximum);when(player.getAttribute(Attribute.MAX_HEALTH)).thenReturn(attribute);return player;}
private static java.util.function.Consumer<Throwable> failureSink(String message){return failure->org.junit.jupiter.api.Assertions.fail(message,failure);}
private static void await(java.util.function.BooleanSupplier condition)throws Exception{for(int i=0;i<100&&!condition.getAsBoolean();i++)Thread.sleep(10);assertTrue(condition.getAsBoolean());}
}
@@ -28,10 +28,10 @@ class RareShopNativeTest {
@SuppressWarnings("unchecked")
@Test void registeredMerchantPurchaseInventoryUseAndNativeHealingUseConnectedPath()throws Exception{
java.nio.file.Files.writeString(data.resolve("config.yml"),"rare-price-variance-percent: 0\nrare-price-health-potion: 40\nrare-stock-health-potion-min: 1\nrare-stock-health-potion-max: 1\nrare-price-reskill: 20\nrare-stock-reskill-min: 2\nrare-stock-reskill-max: 2\nrare-price-magic-core: 30\nrare-stock-magic-core-min: 1\nrare-stock-magic-core-max: 1\n");
java.nio.file.Files.writeString(data.resolve("config.yml"),"rare-price-variance-percent: 0\nrare-price-health-potion: 40\nrare-stock-health-potion-min: 1\nrare-stock-health-potion-max: 1\nrare-price-reskill: 20\nrare-stock-reskill-min: 2\nrare-stock-reskill-max: 2\nrare-price-magic-core: 30\nrare-stock-magic-core-min: 1\nrare-stock-magic-core-max: 1\nrare-price-phoenix-down: 10\nrare-stock-phoenix-down-min: 1\nrare-stock-phoenix-down-max: 1\n");
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);doReturn(true).when(actor.player).hasPermission("fantasystick.admin");UUID worldId=UUID.randomUUID();when(actor.world.getUID()).thenReturn(worldId);int x=actor.core.location.getBlockX()>>4,z=actor.core.location.getBlockZ()>>4;var chunk=mock(Chunk.class);when(chunk.getWorld()).thenReturn(actor.world);when(chunk.getX()).thenReturn(x);when(chunk.getZ()).thenReturn(z);when(chunk.getEntities()).thenReturn(new org.bukkit.entity.Entity[0]);when(actor.world.getChunkAt(x,z)).thenReturn(chunk);when(actor.world.isChunkLoaded(x,z)).thenReturn(true);when(actor.world.getEntities()).thenReturn(List.of());
assertEquals(List.of("list","place","remove","restock"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"rare-shop",""}));assertTrue(f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","rare-"}).containsAll(List.of("rare-price-health-potion","rare-price-reskill","rare-stock-reskill-min","rare-stock-reskill-max","rare-price-magic-core","rare-stock-magic-core-min","rare-stock-magic-core-max")));assertEquals(List.of("20","2000"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","rare-price-reskill",""}));assertEquals(List.of("30","3000"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","rare-price-magic-core",""}));
assertEquals(List.of("list","place","remove","restock"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"rare-shop",""}));assertTrue(f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","rare-"}).containsAll(List.of("rare-price-health-potion","rare-price-reskill","rare-stock-reskill-min","rare-stock-reskill-max","rare-price-magic-core","rare-stock-magic-core-min","rare-stock-magic-core-max","rare-price-phoenix-down","rare-stock-phoenix-down-min","rare-stock-phoenix-down-max","rare-phoenix-down-invulnerability-seconds")));assertEquals(List.of("20","2000"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","rare-price-reskill",""}));assertEquals(List.of("30","3000"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","rare-price-magic-core",""}));
var villager=mock(Villager.class);when(villager.getLocation()).thenReturn(actor.core.location);var values=new HashMap<String,String>();var pdc=mock(PersistentDataContainer.class);when(villager.getPersistentDataContainer()).thenReturn(pdc);doAnswer(call->{values.put(((NamespacedKey)call.getArgument(0)).getKey(),call.getArgument(2));return null;}).when(pdc).set(any(NamespacedKey.class),eq(PersistentDataType.STRING),anyString());when(pdc.get(any(NamespacedKey.class),eq(PersistentDataType.STRING))).thenAnswer(call->values.get(((NamespacedKey)call.getArgument(0)).getKey()));doAnswer(call->{((java.util.function.Consumer<Villager>)call.getArgument(2)).accept(villager);return villager;}).when(actor.world).spawn(any(Location.class),eq(Villager.class),any(java.util.function.Consumer.class));
f.command.execute(actor.player,"fantasystick",new String[]{"rare-shop","place","Curios"});await(f,()->((RareMerchantDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchants")).all().size()==1);var directory=(RareMerchantDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchants");var shop=directory.all().getFirst();assertEquals("Curios",shop.name());var controller=(RareMerchantController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchantController");assertTrue(controller.tagged(villager));
var repository=new YamlCharacterRepository(data);var old=repository.load(actor.core.record.id()).orElseThrow();var purchasedStats=new java.util.EnumMap<>(old.stats());purchasedStats.put(games.dmg.fantasystick.progression.Stat.DEX,BigInteger.ONE);repository.replace(new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),purchasedStats,old.level(),old.experience(),BigInteger.valueOf(39),old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),false,null,null,BigInteger.valueOf(150),true,old.rewardReceipts(),old.pvpCoinEscrow(),old.gear()),old.revision());f.activate(actor);
@@ -39,9 +39,14 @@ class RareShopNativeTest {
var menus=(RareShopMenus)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareShopMenus");var click=mock(InventoryClickEvent.class);when(click.getInventory()).thenReturn(menu);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getCurrentItem()).thenReturn(potion);menus.click(click);var inventory=(RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");await(f,()->inventory.quantity(actor.core.record.id(),RareProduct.HEALTH_POTION).equals(BigInteger.ONE));var characters=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");await(f,()->sessions.active(actor.core.id).flatMap(sessions::currentRecord).map(value->value.coins().equals(BigInteger.valueOf(110))).orElse(false));var active=sessions.active(actor.core.id).orElseThrow(()->new AssertionError("Purchase removed active session: "+actor.messages));var record=sessions.currentRecord(active).orElseThrow(()->new AssertionError("Purchase left no current record: "+actor.messages));var injured=characters.saveHealth(record.id(),record.revision(),BigDecimal.valueOf(5),BigDecimal.valueOf(100)).join().orElseThrow(()->new AssertionError("Injury save was refused"));assertTrue(sessions.acknowledgeProgress(active,record.revision(),injured));actor.player.setHealth(5);
PartyBookNativeTest.open(f,actor);assertTrue(PartyBookNativeTest.text(actor).contains("Health Potion"),PartyBookNativeTest.text(actor));assertTrue(PartyBookNativeTest.text(actor).contains("[Use]"),PartyBookNativeTest.text(actor));var action=PartyBookNativeTest.button(actor,"[Use]");PartyBookNativeTest.click(f,actor,action);await(f,()->inventory.quantity(actor.core.record.id(),RareProduct.HEALTH_POTION).signum()==0&&actor.player.getHealth()>5&&actor.messages.stream().anyMatch(message->message.contains("Used Health Potion")));assertEquals(10d,actor.player.getHealth());assertEquals(0,BigDecimal.TEN.compareTo(characters.find(record.id()).join().orElseThrow().health()));assertTrue(actor.messages.stream().anyMatch(message->message.contains("Used Health Potion")&&message.contains("restored")));
int menusBeforeReskill=actor.inventories.size();menus.open(actor.player,shop.id());await(f,()->actor.inventories.size()>menusBeforeReskill);var reskillMenu=actor.inventories.getLast();var reskill=reskillMenu.getItem(13);assertTrue(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(reskill.getItemMeta().displayName()).contains("Reskill — 20 coins"));var reskillClick=mock(InventoryClickEvent.class);when(reskillClick.getInventory()).thenReturn(reskillMenu);when(reskillClick.getWhoClicked()).thenReturn(actor.player);when(reskillClick.getCurrentItem()).thenReturn(reskill);menus.click(reskillClick);await(f,()->inventory.quantity(actor.core.record.id(),RareProduct.RESKILL).equals(BigInteger.ONE));await(f,()->sessions.active(actor.core.id).flatMap(sessions::currentRecord).map(value->value.coins().equals(BigInteger.valueOf(90))&&!value.stats().get(games.dmg.fantasystick.progression.Stat.DEX).equals(BigInteger.ZERO)).orElse(false));PartyBookNativeTest.open(f,actor);PartyBookNativeTest.click(f,actor,PartyBookNativeTest.button(actor,"[Use]"));await(f,()->PartyBookNativeTest.text(actor).contains("Confirm Reskill"));PartyBookNativeTest.click(f,actor,PartyBookNativeTest.button(actor,"[Confirm Reskill]"));await(f,()->inventory.quantity(actor.core.record.id(),RareProduct.RESKILL).signum()==0&&sessions.active(actor.core.id).flatMap(sessions::currentRecord).map(value->value.stats().values().stream().allMatch(BigInteger.ZERO::equals)&&value.unspentPoints().equals(BigInteger.valueOf(40))).orElse(false));var resetActive=sessions.active(actor.core.id).orElseThrow();var resetRecord=sessions.currentRecord(resetActive).orElseThrow();var repurchased=characters.purchaseStat(resetRecord.id(),resetRecord.revision(),games.dmg.fantasystick.progression.Stat.DEX,BigDecimal.valueOf(actor.player.getHealth()),new BigDecimal("1024")).join().record().orElseThrow();assertTrue(sessions.acknowledgeProgress(resetActive,resetRecord.revision(),repurchased));assertEquals(BigInteger.ONE,repurchased.stats().get(games.dmg.fantasystick.progression.Stat.DEX));assertEquals(BigInteger.valueOf(39),repurchased.unspentPoints());int beforeCoreMenu=actor.inventories.size();menus.open(actor.player,shop.id());await(f,()->actor.inventories.size()>beforeCoreMenu);var coreMenu=actor.inventories.getLast();var coreItem=coreMenu.getItem(14);assertTrue(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(coreItem.getItemMeta().displayName()).contains("Magic Core — 30 coins"));var coreClick=mock(InventoryClickEvent.class);when(coreClick.getInventory()).thenReturn(coreMenu);when(coreClick.getWhoClicked()).thenReturn(actor.player);when(coreClick.getCurrentItem()).thenReturn(coreItem);menus.click(coreClick);awaitEvidence(f,()->inventory.quantity(actor.core.record.id(),RareProduct.MAGIC_CORE).equals(BigInteger.ONE)&&sessions.active(actor.core.id).flatMap(sessions::currentRecord).map(value->value.coins().equals(BigInteger.valueOf(60))).orElse(false),()->"core purchase inventory="+inventory.inventory(actor.core.record.id())+" record="+sessions.active(actor.core.id).flatMap(sessions::currentRecord)+" messages="+actor.messages);PartyBookNativeTest.open(f,actor);PartyBookNativeTest.click(f,actor,PartyBookNativeTest.button(actor,"[Use]"));awaitEvidence(f,()->inventory.quantity(actor.core.record.id(),RareProduct.MAGIC_CORE).signum()==0&&sessions.active(actor.core.id).flatMap(sessions::currentRecord).map(value->value.unspentPoints().equals(BigInteger.valueOf(40))).orElse(false),()->"core use inventory="+inventory.inventory(actor.core.record.id())+" record="+sessions.active(actor.core.id).flatMap(sessions::currentRecord)+" messages="+actor.messages);int beforeSecondReskill=actor.inventories.size();menus.open(actor.player,shop.id());await(f,()->actor.inventories.size()>beforeSecondReskill);var secondReskillMenu=actor.inventories.getLast();var secondReskill=secondReskillMenu.getItem(13);var secondReskillClick=mock(InventoryClickEvent.class);when(secondReskillClick.getInventory()).thenReturn(secondReskillMenu);when(secondReskillClick.getWhoClicked()).thenReturn(actor.player);when(secondReskillClick.getCurrentItem()).thenReturn(secondReskill);menus.click(secondReskillClick);awaitEvidence(f,()->inventory.quantity(actor.core.record.id(),RareProduct.RESKILL).equals(BigInteger.ONE)&&sessions.active(actor.core.id).flatMap(sessions::currentRecord).map(value->value.coins().equals(BigInteger.valueOf(40))).orElse(false),()->"second reskill inventory="+inventory.inventory(actor.core.record.id())+" messages="+actor.messages);PartyBookNativeTest.open(f,actor);PartyBookNativeTest.click(f,actor,PartyBookNativeTest.button(actor,"[Use]"));await(f,()->PartyBookNativeTest.text(actor).contains("Confirm Reskill"));PartyBookNativeTest.click(f,actor,PartyBookNativeTest.button(actor,"[Confirm Reskill]"));awaitEvidence(f,()->inventory.quantity(actor.core.record.id(),RareProduct.RESKILL).signum()==0&&sessions.active(aLine truncated
f.plugin.setEnabled(false);f.awaitWorkers();f.plugin.setEnabled(true);var reloaded=(RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");assertEquals(BigInteger.ZERO,reloaded.quantity(actor.core.record.id(),RareProduct.HEALTH_POTION));assertEquals(BigInteger.ZERO,reloaded.quantity(actor.core.record.id(),RareProduct.RESKILL));assertEquals(BigInteger.ZERO,reloaded.quantity(actor.core.record.id(),RareProduct.MAGIC_CORE));var persisted=new YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();assertTrue(persisted.stats().values().stream().allMatch(BigInteger.ZERO::equals));assertEquals(BigInteger.valueOf(41),persisted.unspentPoints());assertTrue(((RareMerchantDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchants")).find(shop.id()).isPresent());
int beforePhoenixMenu=actor.inventories.size();menus.open(actor.player,shop.id());await(f,()->actor.inventories.size()>beforePhoenixMenu);var phoenixMenu=actor.inventories.getLast();var phoenix=phoenixMenu.getItem(15);assertTrue(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(phoenix.getItemMeta().displayName()).contains("Phoenix Down — 10 coins"));var phoenixClick=mock(InventoryClickEvent.class);when(phoenixClick.getInventory()).thenReturn(phoenixMenu);when(phoenixClick.getWhoClicked()).thenReturn(actor.player);when(phoenixClick.getCurrentItem()).thenReturn(phoenix);menus.click(phoenixClick);await(f,()->inventory.quantity(actor.core.record.id(),RareProduct.PHOENIX_DOWN).equals(BigInteger.ONE));int beforeRestockMessage=actor.messages.size();f.command.execute(actor.player,"fantasystick",new String[]{"rare-shop","restock"});await(f,()->actor.messages.size()>beforeRestockMessage&&actor.messages.getLast().contains("Restocked Rare Items catalog"));
f.plugin.setEnabled(false);f.awaitWorkers();var eventTypes=new java.util.ArrayList<String>();try(var eventFiles=java.nio.file.Files.walk(data.resolve("events"))){for(var eventFile:eventFiles.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(eventFile))if(!line.isBlank()){var event=com.google.gson.JsonParser.parseString(line).getAsJsonObject();if(event.getAsJsonObject("data").has("product_id")&&event.getAsJsonObject("data").get("product_id").getAsString().equals("phoenix-down"))eventTypes.add(event.get("type").getAsString());}}assertTrue(eventTypes.stream().anyMatch(type->type.endsWith("rare_item.purchased")),eventTypes.toString());assertTrue(eventTypes.stream().anyMatch(type->type.endsWith("rare_catalog.refreshed")),eventTypes.toString());f.plugin.setEnabled(true);var reloaded=(RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");assertEquals(BigInteger.ZERO,reloaded.quantity(actor.core.record.id(),RareProduct.HEALTH_POTION));assertEquals(BigInteger.ZERO,reloaded.quantity(actor.core.record.id(),RareProduct.RESKILL));assertEquals(BigInteger.ZERO,reloaded.quantity(actor.core.record.id(),RareProduct.MAGIC_CORE));assertEquals(BigInteger.ONE,reloaded.quantity(actor.core.record.id(),RareProduct.PHOENIX_DOWN));var persisted=new YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();assertTrue(persisted.stats().values().stream().allMatch(BigInteger.ZERO::equals));assertEquals(BigInteger.valueOf(41),persisted.unspentPoints());assertTrue(((RareMerchantDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchants")).find(shop.id()).isPresent());
}
}
@Test void administratorReadsAndChangesFuturePhoenixProtectionWithEvents()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();var phoenixKeys=f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","rare-"});assertTrue(phoenixKeys.containsAll(List.of("rare-price-phoenix-down","rare-stock-phoenix-down-min","rare-stock-phoenix-down-max","rare-phoenix-down-invulnerability-seconds")),phoenixKeys.toString());assertEquals(List.of("1000"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","rare-price-phoenix-down",""}));assertEquals(List.of("0"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","rare-stock-phoenix-down-min",""}));assertEquals(List.of("2"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","rare-stock-phoenix-down-max",""}));assertEquals(List.of("5"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","rare-phoenix-down-invulnerability-seconds",""}));f.command.execute(sender.port,"fantasystick",new String[]{"config","rare-phoenix-down-invulnerability-seconds"});assertTrue(sender.messages.getLast().contains("=5"));int before=sender.messages.size();f.command.execute(sender.port,"fantasystick",new String[]{"config","rare-phoenix-down-invulnerability-seconds","0"});await(f,()->sender.messages.size()>before);assertTrue(sender.messages.getLast().contains("=0"));f.command.execute(sender.port,"fantasystick",new String[]{"config","rare-phoenix-down-invulnerability-seconds","31536001"});assertTrue(sender.messages.getLast().contains("invalid"));assertEquals(java.time.Duration.ZERO,((RareSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareSettings")).phoenixProtection());f.plugin.setEnabled(false);var types=new java.util.ArrayList<String>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank()){var event=com.google.gson.JsonParser.parseString(line).getAsJsonObject();if(event.getAsJsonObject("data").has("key")&&event.getAsJsonObject("data").get("key").getAsString().equals("rare-phoenix-down-invulnerability-seconds"))types.add(event.get("type").getAsString());}}assertTrue(types.stream().anyMatch(type->type.endsWith("config.read")),types.toString());assertTrue(types.stream().anyMatch(type->type.endsWith("config.changed")),types.toString());}
}
@Test void reskillUseRequiresFreshConfirmationThenResetsTheExactActiveCharacter()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);
@@ -71,7 +76,7 @@ class RareShopNativeTest {
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var inventory=(RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");await(f,()->inventory.unresolvedUses().isEmpty());assertEquals(BigInteger.ZERO,inventory.quantity(invested.id(),RareProduct.RESKILL));assertEquals(BigInteger.ONE,inventory.quantity(other.id(),RareProduct.RESKILL));assertEquals(BigInteger.ZERO,inventory.quantity(invested.id(),RareProduct.MAGIC_CORE));assertEquals(BigInteger.ONE,inventory.quantity(other.id(),RareProduct.MAGIC_CORE));var saved=new YamlCharacterRepository(data).load(invested.id()).orElseThrow();assertEquals(BigInteger.valueOf(41),saved.unspentPoints());assertTrue(saved.stats().values().stream().allMatch(BigInteger.ZERO::equals));}
}
@Test void everyMerchantSharesOneAllZeroCatalogAndShowsAnEmptyShop()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"rare-stock-lesser-health-potion-min: 0\nrare-stock-lesser-health-potion-max: 0\nrare-stock-health-potion-min: 0\nrare-stock-health-potion-max: 0\nrare-stock-greater-health-potion-min: 0\nrare-stock-greater-health-potion-max: 0\nrare-stock-reskill-min: 0\nrare-stock-reskill-max: 0\nrare-stock-magic-core-min: 0\nrare-stock-magic-core-max: 0\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);UUID world=UUID.randomUUID();when(actor.world.getUID()).thenReturn(world);f.activate(actor);var directory=(RareMerchantDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchants");var first=directory.place("One",world,actor.core.location.getX(),actor.core.location.getY(),actor.core.location.getZ(),0,0).join();var second=directory.place("Two",world,actor.core.location.getX(),actor.core.location.getY(),actor.core.location.getZ(),0,0).join();var menus=(RareShopMenus)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareShopMenus");menus.open(actor.player,first.id());await(f,()->!actor.inventories.isEmpty());var one=actor.inventories.getLast();assertTrue(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(one.getItem(13).getItemMeta().displayName()).contains("Reskill"));assertTrue(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(one.getItem(14).getItemMeta().displayName()).contains("Magic Core"));assertEquals(Material.BARRIER,one.getItem(16).getType());menus.open(actor.player,second.id());await(f,()->actor.inventories.size()>=2);var two=actor.inventories.getLast();assertEquals(one.getItem(10).getItemMeta().displayName(),two.getItem(10).getItemMeta().displayName());assertTrue(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(one.getItem(10).getItemMeta().displayName()).startsWith("Sold out"));}}
@Test void everyMerchantSharesOneAllZeroCatalogAndShowsAnEmptyShop()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"rare-stock-lesser-health-potion-min: 0\nrare-stock-lesser-health-potion-max: 0\nrare-stock-health-potion-min: 0\nrare-stock-health-potion-max: 0\nrare-stock-greater-health-potion-min: 0\nrare-stock-greater-health-potion-max: 0\nrare-stock-reskill-min: 0\nrare-stock-reskill-max: 0\nrare-stock-magic-core-min: 0\nrare-stock-magic-core-max: 0\nrare-stock-phoenix-down-min: 0\nrare-stock-phoenix-down-max: 0\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);UUID world=UUID.randomUUID();when(actor.world.getUID()).thenReturn(world);f.activate(actor);var directory=(RareMerchantDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchants");var first=directory.place("One",world,actor.core.location.getX(),actor.core.location.getY(),actor.core.location.getZ(),0,0).join();var second=directory.place("Two",world,actor.core.location.getX(),actor.core.location.getY(),actor.core.location.getZ(),0,0).join();var menus=(RareShopMenus)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareShopMenus");menus.open(actor.player,first.id());await(f,()->!actor.inventories.isEmpty());var one=actor.inventories.getLast();assertTrue(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(one.getItem(13).getItemMeta().displayName()).contains("Reskill"));assertTrue(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(one.getItem(14).getItemMeta().displayName()).contains("Magic Core"));assertEquals(Material.BARRIER,one.getItem(16).getType());menus.open(actor.player,second.id());await(f,()->actor.inventories.size()>=2);var two=actor.inventories.getLast();assertEquals(one.getItem(10).getItemMeta().displayName(),two.getItem(10).getItemMeta().displayName());assertTrue(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(one.getItem(10).getItemMeta().displayName()).startsWith("Sold out"));}}
@Test void allPotionSizesPreserveFractionalHealingAndFullHealthStock()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);f.activate(actor);var inventory=(RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");var characters=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");inventory.add(actor.core.record.id(),RareProduct.LESSER_HEALTH_POTION,UUID.randomUUID()).join();injure(actor,characters,sessions,new BigDecimal("4"));useFirst(f,actor);awaitEvidence(f,()->actor.player.getHealth()==6.5&&inventory.quantity(actor.core.record.id(),RareProduct.LESSER_HEALTH_POTION).signum()==0,()->"lesser health="+actor.player.getHealth()+" quantity="+inventory.quantity(actor.core.record.id(),RareProduct.LESSER_HEALTH_POTION)+" messages="+actor.messages);inventory.add(actor.core.record.id(),RareProduct.LESSER_HEALTH_POTION,UUID.randomUUID()).join();injure(actor,characters,sessions,BigDecimal.TEN);var potionService=(RarePotionService)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rarePotions");var refusal=new java.util.concurrent.atomic.AtomicReference<RarePotionService.Outcome>();potionService.use(actor.player,RareProduct.LESSER_HEALTH_POTION,refusal::set);assertEquals(RarePotionService.Status.FULL_HEALTH,refusal.get().status());assertEquals(BigInteger.ONE,inventory.quantity(actor.core.record.id(),RareProduct.LESSER_HEALTH_POTION));var settings=(RareSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareSettings");var zero=settings.set("rare-lesser-health-potion-healing-percent","0").toCompletableFuture().join();assertTrue(settings.publish(zero));injure(actor,characters,sessions,BigDecimal.ONE);var noEffect=new java.util.concurrent.atomic.AtomicReference<RarePotionService.Outcome>();potionService.use(actor.player,RareProduct.LESSER_HEALTH_POTION,noEffect::set);assertEquals(RarePotionService.Status.NO_EFFECT,noEffect.get().status());assertEquals(BigInteger.ONE,inventory.quantity(actor.core.record.id(),RareProduct.LESSER_HEALTH_POTION));inventory.add(actor.core.record.id(),RareProduct.GREATER_HEALTH_POTION,UUID.randomUUID()).join();injure(actor,characters,sessions,BigDecimal.ONE);var greater=new java.util.concurrent.atomic.AtomicReference<RarePotionService.Outcome>();potionService.use(actor.player,RareProduct.GREATER_HEALTH_POTION,greater::set);awaitEvidence(f,()->greater.get()!=null&&greater.get().status()==RarePotionService.Status.APPLIED&&actor.player.getHealth()==10&&inventory.quantity(actor.core.record.id(),RareProduct.GREATER_HEALTH_POTION).signum()==0,()->"greater outcome="+greater.get()+" health="+actor.player.getHealth()+" quantities="+inventory.inventory(actor.core.record.id())+" messages="+actor.messages);assertEquals(BigInteger.ONE,inventory.quantity(actor.core.record.id(),RareProduct.LESSER_HEALTH_POTION));}}
private static void injure(FantasyStickLifecycleNativeTest.Actor actor,CharacterStorage characters,games.dmg.fantasystick.session.EquipmentSessions sessions,BigDecimal health){var active=sessions.active(actor.core.id).orElseThrow();var record=sessions.currentRecord(active).orElseThrow();var saved=characters.saveHealth(record.id(),record.revision(),health,BigDecimal.valueOf(100)).join().orElseThrow();if(!saved.revision().equals(record.revision()))assertTrue(sessions.acknowledgeProgress(active,record.revision(),saved));actor.player.setHealth(health.doubleValue());}
@@ -78,7 +78,7 @@ class DistributionTest {
"adventure-champion-reward-xp: 150", "adventure-champion-reward-coins: 40")) {
assertTrue(defaults.contains(setting), "Missing packaged adventure default: " + setting);
}
for(String setting:java.util.List.of("rare-price-reskill: 2000","rare-stock-reskill-min: 0","rare-stock-reskill-max: 1","rare-price-magic-core: 3000","rare-stock-magic-core-min: 0","rare-stock-magic-core-max: 3"))assertTrue(defaults.contains(setting),"Missing packaged Reskill default: "+setting);
for(String setting:java.util.List.of("rare-price-reskill: 2000","rare-stock-reskill-min: 0","rare-stock-reskill-max: 1","rare-price-magic-core: 3000","rare-stock-magic-core-min: 0","rare-stock-magic-core-max: 3","rare-price-phoenix-down: 1000","rare-stock-phoenix-down-min: 0","rare-stock-phoenix-down-max: 2","rare-phoenix-down-invulnerability-seconds: 5"))assertTrue(defaults.contains(setting),"Missing packaged Reskill default: "+setting);
assertFalse(defaults.contains("rare-reskill-healing-percent"));
assertTrue(defaults.contains("great-sword-base-damage: 7"));
assertTrue(defaults.contains("great-sword-recovery-seconds: 0.75"));
@@ -77,6 +77,11 @@ final class DownedEventBridgeTest {
assertEquals("4",((java.util.Map<?,?>)((java.util.List<?>)first.get(1).data().get("final_rewards")).getFirst()).get("coins"));
}
@Test void phoenixResolutionWaitsForNativeAcknowledgementAndRetainsStableExactUseEvidence(){
var captured=new ArrayList<EventInput>();var events=new DownedEventBridge(captured::add,Clock.fixed(NOW,ZoneOffset.UTC));var subject=episode(DownedEpisode.Phase.PENDING,null,null,null,null,false);var operation=UUID.randomUUID();var proof=new DownedEpisode.Phoenix(BigInteger.TWO,BigInteger.ONE,new java.math.BigDecimal("1"),new java.math.BigDecimal("10"),new java.math.BigDecimal("10"),"downed_menu");var decision=new DownedEpisode.Decision("phoenix_down",subject.player(),subject.playerName(),subject.character(),subject.characterName(),subject.session(),operation,NOW.minusSeconds(1),null,null,null,proof);var resolved=episode(DownedEpisode.Phase.RESOLVED,DownedEpisode.Outcome.REVIVED,operation,NOW,NOW.plusSeconds(5),false,decision);
events.reconcile(List.of(resolved));assertEquals(List.of("character.downed"),captured.stream().map(EventInput::type).toList());captured.clear();events.outcomePublished(resolved);var first=List.copyOf(captured);events.outcomePublished(resolved);assertEquals(List.of("character.downed_resolved","action.resolved"),first.stream().map(EventInput::type).toList());assertEquals(first.stream().map(EventInput::id).toList(),captured.subList(first.size(),captured.size()).stream().map(EventInput::id).toList());var data=first.getFirst().data();assertEquals("phoenix_down",data.get("outcome"));assertEquals("downed_menu",data.get("selection_entry_point"));assertEquals("phoenix-down",data.get("item_id"));assertEquals("2",data.get("quantity_before"));assertEquals("1",data.get("quantity_after"));assertEquals("1",data.get("health_before"));assertEquals("10",data.get("health_after"));assertEquals(operation.toString(),data.get("item_use_operation_id"));assertEquals("phoenix_down_native_application",first.get(1).data().get("action_kind"));captured.clear();events.protectionEnded(resolved);assertEquals("phoenix_down_invulnerability",captured.getFirst().data().get("effect_id"));
}
@Test void transientPreventionAndConfigFactsRemainTruthfulAndLoggingFailureCannotEscape() {
var captured = new ArrayList<EventInput>();
var events = new DownedEventBridge(captured::add, Clock.fixed(NOW, ZoneOffset.UTC));
@@ -0,0 +1,15 @@
package games.dmg.fantasystick.events;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.shop.*;
import java.math.BigInteger;
import java.time.*;
import java.util.*;
import org.junit.jupiter.api.Test;
final class RareEventBridgeTest {
@Test void phoenixCatalogAndPurchaseFactsUseStableExactIdentifiersAndDecimalStrings(){
var captured=new ArrayList<EventInput>();var now=Instant.parse("2026-09-25T01:00:00Z");var bridge=new RareEventBridge(captured::add,Clock.fixed(now,ZoneOffset.UTC));var offers=new EnumMap<RareProduct,RareCatalog.Offer>(RareProduct.class);offers.put(RareProduct.PHOENIX_DOWN,new RareCatalog.Offer(BigInteger.valueOf(1007),2));var catalog=new RareCatalog(LocalDate.of(2026,9,25),UUID.randomUUID(),now,RareCatalog.RefreshReason.ADMINISTRATIVE,offers);bridge.catalogRefreshed(catalog);bridge.catalogRefreshed(catalog);assertEquals(2,captured.size());assertEquals(captured.get(0).id(),captured.get(1).id());assertEquals("rare_catalog.refreshed",captured.getFirst().type());assertEquals("phoenix-down",captured.getFirst().data().get("product_id"));assertEquals("1007",captured.getFirst().data().get("price"));assertEquals("2",captured.getFirst().data().get("stock"));captured.clear();UUID operation=UUID.randomUUID(),player=UUID.randomUUID(),character=UUID.randomUUID(),session=UUID.randomUUID();bridge.purchased(operation,catalog.revision(),player,"Account",character,"Wren",session,BigInteger.valueOf(1007),2,1,BigInteger.valueOf(12),BigInteger.valueOf(11));bridge.purchased(operation,catalog.revision(),player,"Account",character,"Wren",session,BigInteger.valueOf(1007),2,1,BigInteger.valueOf(12),BigInteger.valueOf(11));assertEquals(captured.get(0).id(),captured.get(1).id());var event=captured.getFirst();assertEquals("rare_item.purchased",event.type());assertEquals(operation,event.actionId());assertEquals(character,event.context().characterId());assertEquals("2",event.data().get("stock_before"));assertEquals("1",event.data().get("stock_after"));assertEquals("12",event.data().get("quantity_before"));assertEquals("11",event.data().get("quantity_after"));
}
@Test void eventSinkFailureCannotAlterMarketCallers(){var bridge=new RareEventBridge(value->{throw new IllegalStateException("disk");},Clock.systemUTC());var offers=new EnumMap<RareProduct,RareCatalog.Offer>(RareProduct.class);offers.put(RareProduct.PHOENIX_DOWN,new RareCatalog.Offer(BigInteger.ONE,0));assertDoesNotThrow(()->bridge.catalogRefreshed(new RareCatalog(LocalDate.now(),UUID.randomUUID(),Instant.now(),RareCatalog.RefreshReason.SCHEDULED,offers)));}
}
@@ -31,6 +31,20 @@ class RareSettingsTest {
}
}
@Test void registersPhoenixDownAndItsBoundedFutureRescueProtection()throws Exception{
try(var settings=new RareSettings(data,Map.of())){
assertTrue(RareSettings.keys().containsAll(java.util.List.of("rare-price-phoenix-down","rare-stock-phoenix-down-min","rare-stock-phoenix-down-max","rare-phoenix-down-invulnerability-seconds")));
assertEquals("1000",settings.value("rare-price-phoenix-down"));
assertEquals("0",settings.value("rare-stock-phoenix-down-min"));
assertEquals("2",settings.value("rare-stock-phoenix-down-max"));
assertEquals(java.time.Duration.ofSeconds(5),settings.phoenixProtection());
assertEquals(java.math.BigInteger.valueOf(1000),settings.rules().basePrices().get(RareProduct.PHOENIX_DOWN));
assertThrows(IllegalArgumentException.class,()->settings.set("rare-phoenix-down-invulnerability-seconds","31536001"));
var zero=settings.set("rare-phoenix-down-invulnerability-seconds","0").toCompletableFuture().join();
assertTrue(settings.publish(zero));assertEquals(java.time.Duration.ZERO,settings.phoenixProtection());
}
}
@Test void validatesRangesAndPublishesOnlyAcknowledgedWritesWhilePreservingOtherYaml()throws Exception{
java.nio.file.Files.writeString(data.resolve("config.yml"),"unrelated: keep\n");
try(var settings=new RareSettings(data,Map.of())){
@@ -65,6 +65,12 @@ class DownedDirectoryTest {
}
}
@Test void preservesExactPhoenixSelectionItemAndHealthProofAcrossRestart()throws Exception{
var clock=Clock.fixed(Instant.parse("2026-09-25T00:40:00Z"),ZoneOffset.UTC);UUID character=UUID.randomUUID(),player=UUID.randomUUID(),session=UUID.randomUUID(),action=UUID.randomUUID();var proof=new DownedEpisode.Phoenix(java.math.BigInteger.TWO,java.math.BigInteger.ONE,new java.math.BigDecimal("1"),new java.math.BigDecimal("10"),new java.math.BigDecimal("10"),"downed_menu");var decision=new DownedEpisode.Decision("phoenix_down",player,"Account",character,"Wren",session,action,clock.instant(),null,null,null,proof);
try(var directory=new DownedDirectory(data,clock)){var episode=directory.begin(character,player,session,UUID.randomUUID(),1,2,3,0,0,UUID.randomUUID(),4,5,6,0,0,"Wren","Account",java.math.BigInteger.valueOf(12),Duration.ofMinutes(5),new DownedEpisode.Attribution(null,"minecraft:fall")).join();directory.claim(episode.id(),DownedEpisode.Outcome.REVIVED,action,decision,Duration.ofSeconds(5),java.util.List.of()).join();}
try(var directory=new DownedDirectory(data,clock)){var restored=directory.find(character).join().orElseThrow();assertEquals(decision,restored.decision());assertEquals(java.math.BigInteger.TWO,restored.decision().phoenix().quantityBefore());assertEquals(new java.math.BigDecimal("10"),restored.decision().phoenix().maximum());assertEquals(clock.instant().plusSeconds(5),restored.protectionUntil());}
}
@Test void concurrentRescueAndDeathClaimsPublishExactlyOneDurableWinner()throws Exception{
var clock=Clock.fixed(Instant.parse("2026-09-24T01:30:00Z"),ZoneOffset.UTC);
try(var directory=new DownedDirectory(data,clock)){
@@ -58,6 +58,23 @@ class RareInventoryDirectoryTest {
}
}
@Test void phoenixReservationRetainsExactQuantityAndOperationAcrossRestart()throws Exception{
UUID character=UUID.randomUUID(),operation=UUID.randomUUID();
try(var inventory=new RareInventoryDirectory(new YamlRareInventoryRepository(data))){
inventory.add(character,RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();
inventory.add(character,RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();
var reserved=inventory.reserveUse(character,RareProduct.PHOENIX_DOWN,operation,BigInteger.TWO).join();
assertEquals(RareInventoryDirectory.UseStatus.RESERVED,reserved.status());
assertEquals(BigInteger.ONE,inventory.quantity(character,RareProduct.PHOENIX_DOWN));
}
try(var inventory=new RareInventoryDirectory(new YamlRareInventoryRepository(data))){
var proof=inventory.unresolvedUses().getFirst();
assertEquals(operation,proof.operation());assertEquals("phoenix-down",proof.product());assertEquals(BigInteger.TWO,proof.expectedQuantity());
assertEquals(RareInventoryDirectory.UseStatus.COMMITTED,inventory.commitUse(operation).join());
assertEquals(BigInteger.ONE,inventory.quantity(character,RareProduct.PHOENIX_DOWN));
}
}
@Test void concurrentReskillReservationsCannotSpendOneItemTwice()throws Exception{
UUID character=UUID.randomUUID();try(var inventory=new RareInventoryDirectory(new YamlRareInventoryRepository(data))){inventory.add(character,RareProduct.RESKILL,UUID.randomUUID()).join();var first=inventory.reserveUse(character,RareProduct.RESKILL,UUID.randomUUID(),BigInteger.ONE);var second=inventory.reserveUse(character,RareProduct.RESKILL,UUID.randomUUID(),BigInteger.ONE);assertEquals(java.util.Set.of(RareInventoryDirectory.UseStatus.RESERVED,RareInventoryDirectory.UseStatus.NOT_AVAILABLE),java.util.Set.of(first.join().status(),second.join().status()));assertEquals(BigInteger.ZERO,inventory.quantity(character,RareProduct.RESKILL));}
}
@@ -39,8 +39,8 @@ class RareMarketTest {
private static CharacterRecord withCoins(CharacterRecord old){return new CharacterRecord(old.id(),old.name(),old.revision(),old.stats(),old.level(),old.experience(),old.unspentPoints(),old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),false,null,null,BigInteger.valueOf(100),true,java.util.Set.of(),BigInteger.ZERO,CharacterGear.none());}
@Test void newlyDeliveredProductsWaitForTheNextRefreshInsteadOfRerollingTheCurrentCatalog()throws Exception{
var now=Instant.parse("2026-09-24T12:00:00Z");var clock=Clock.fixed(now,ZoneOffset.UTC);var offers=new java.util.EnumMap<RareProduct,RareCatalog.Offer>(RareProduct.class);for(var product:List.of(RareProduct.LESSER_HEALTH_POTION,RareProduct.HEALTH_POTION,RareProduct.GREATER_HEALTH_POTION))offers.put(product,new RareCatalog.Offer(BigInteger.ONE,1));var revision=java.util.UUID.randomUUID();var legacy=new RareCatalog(LocalDate.of(2026,9,24),revision,now,RareCatalog.RefreshReason.STARTUP,offers);new YamlRareCatalogRepository(data).save(legacy);var settings=new RareSettings(data,Map.of("rare-stock-reskill-min","1","rare-stock-reskill-max","1","rare-stock-magic-core-min","1","rare-stock-magic-core-max","1"));var repository=new YamlCharacterRepository(data);var character=withCoins(new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults()));
try(var characters=new CharacterStorage(repository,clock);var inventory=new RareInventoryDirectory(new YamlRareInventoryRepository(data));var market=new RareMarket(new YamlRareCatalogRepository(data),new RarePurchaseOperationStore(data),characters,inventory,settings::rules,clock,(minimum,maximum)->minimum)){characters.create(character).join();var current=market.current().toCompletableFuture().join();assertEquals(revision,current.revision());assertFalse(current.available(RareProduct.RESKILL));assertFalse(current.available(RareProduct.MAGIC_CORE));assertEquals(RareMarket.PurchaseStatus.UNAVAILABLE,market.purchase(character.id(),character.revision(),revision,RareProduct.MAGIC_CORE).toCompletableFuture().join().status());var refreshed=market.restock().toCompletableFuture().join();assertTrue(refreshed.available(RareProduct.RESKILL));assertTrue(refreshed.available(RareProduct.MAGIC_CORE));assertEquals(1,refreshed.offer(RareProduct.RESKILL).remaining());assertEquals(1,refreshed.offer(RareProduct.MAGIC_CORE).remaining());}finally{settings.close();}
var now=Instant.parse("2026-09-24T12:00:00Z");var clock=Clock.fixed(now,ZoneOffset.UTC);var offers=new java.util.EnumMap<RareProduct,RareCatalog.Offer>(RareProduct.class);for(var product:List.of(RareProduct.LESSER_HEALTH_POTION,RareProduct.HEALTH_POTION,RareProduct.GREATER_HEALTH_POTION))offers.put(product,new RareCatalog.Offer(BigInteger.ONE,1));var revision=java.util.UUID.randomUUID();var legacy=new RareCatalog(LocalDate.of(2026,9,24),revision,now,RareCatalog.RefreshReason.STARTUP,offers);new YamlRareCatalogRepository(data).save(legacy);var settings=new RareSettings(data,Map.of("rare-stock-reskill-min","1","rare-stock-reskill-max","1","rare-stock-magic-core-min","1","rare-stock-magic-core-max","1","rare-stock-phoenix-down-min","1","rare-stock-phoenix-down-max","1"));var repository=new YamlCharacterRepository(data);var character=withCoins(new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults()));
try(var characters=new CharacterStorage(repository,clock);var inventory=new RareInventoryDirectory(new YamlRareInventoryRepository(data));var market=new RareMarket(new YamlRareCatalogRepository(data),new RarePurchaseOperationStore(data),characters,inventory,settings::rules,clock,(minimum,maximum)->minimum)){characters.create(character).join();var current=market.current().toCompletableFuture().join();assertEquals(revision,current.revision());assertFalse(current.available(RareProduct.RESKILL));assertFalse(current.available(RareProduct.MAGIC_CORE));assertFalse(current.available(RareProduct.PHOENIX_DOWN));assertEquals(RareMarket.PurchaseStatus.UNAVAILABLE,market.purchase(character.id(),character.revision(),revision,RareProduct.MAGIC_CORE).toCompletableFuture().join().status());var refreshed=market.restock().toCompletableFuture().join();assertTrue(refreshed.available(RareProduct.RESKILL));assertTrue(refreshed.available(RareProduct.MAGIC_CORE));assertTrue(refreshed.available(RareProduct.PHOENIX_DOWN));assertEquals(1,refreshed.offer(RareProduct.RESKILL).remaining());assertEquals(1,refreshed.offer(RareProduct.MAGIC_CORE).remaining());assertEquals(1,refreshed.offer(RareProduct.PHOENIX_DOWN).remaining());}finally{settings.close();}
}
@Test void staleGenerationAndZeroStockRefuseWithoutCoinsOrInventoryMutation() throws Exception {