feat(events): audit rare catalog and potion journeys
Release / release (push) Failing after 19m48s

This commit is contained in:
dmg committed 2026-09-27 13:15:33 -04:00
1 parent 103a3e44b7
commit 43734d2634
9 files changed
+490 -29

No files matched your search

@@ -166,6 +166,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.events.LocalEventLog eventLog;
private games.dmg.fantasystick.events.FishingEventBridge fishingAuditEvents;
private java.util.concurrent.CompletionStage<Void> fishingAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private java.util.concurrent.CompletionStage<Void> rareAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.PreferenceEventBridge preferenceEvents;
private games.dmg.fantasystick.events.EventCaptureListener eventCapture;
private games.dmg.fantasystick.events.DownedEventBridge downedEvents;
@@ -644,7 +645,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
armorerController=new games.dmg.fantasystick.shop.FantasyArmorerController(this,armorers,armorShopMenus::open);
rareShop=new games.dmg.fantasystick.shop.RareShop(rareMerchants,sessions,rareMarket,rareInventory,nextTick,failure->getLogger().log(Level.WARNING,"Rare Items purchase failed",failure));
rareShopMenus=new games.dmg.fantasystick.shop.RareShopMenus(this,rareShop);var localRareMenus=rareShopMenus;
rareMarket.attachListener(catalog->{if(lifetime!=generation)return;if(rareEvents!=null)rareEvents.catalogRefreshed(catalog);try{nextTick.accept(()->{if(lifetime==generation)localRareMenus.refreshOpen();});}catch(RuntimeException failure){if(lifetime==generation)getLogger().log(Level.WARNING,"Rare Items menu refresh failed",failure);}});
var pendingRareCatalogs=new java.util.concurrent.ConcurrentLinkedQueue<games.dmg.fantasystick.shop.RareCatalog>();
rareMarket.attachListener(catalog->{if(lifetime!=generation)return;var events=rareEvents;
if(events==null)pendingRareCatalogs.add(catalog);else events.catalogRefreshed(catalog);
if(catalog.reason()!=games.dmg.fantasystick.shop.RareCatalog.RefreshReason.STARTUP)
try{nextTick.accept(()->{if(lifetime==generation)localRareMenus.refreshOpen();});}
catch(RuntimeException failure){if(lifetime==generation)getLogger().log(Level.WARNING,"Rare Items menu refresh failed",failure);}});
rareMerchantController=new games.dmg.fantasystick.shop.RareMerchantController(this,rareMerchants,rareShopMenus::open);
rarePotions=new games.dmg.fantasystick.shop.RarePotionService(rareInventory,rareSettings,characters,sessions,this::dispatchOwnedHealing,nextTick,failure->getLogger().log(Level.WARNING,"Rare potion use failed",failure));
rareReskills=new games.dmg.fantasystick.shop.RareReskillService(rareInventory,characters,sessions,nextTick,record->{for(String ability:record.learned())abilityEngine.remove(record.id(),ability);speedI.sweep();lowLightVision.sweep();},failure->getLogger().log(Level.WARNING,"Rare Reskill use failed",failure));
@@ -826,7 +832,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
localBasicAttack.attachEvents(new games.dmg.fantasystick.events.BasicAttackEventBridge(
eventLog::emit, Clock.systemUTC()));
downedEvents = new games.dmg.fantasystick.events.DownedEventBridge(eventLog::emit, Clock.systemUTC());
rareEvents = new games.dmg.fantasystick.events.RareEventBridge(eventLog::emit, Clock.systemUTC());rareShop.attachEvents(rareEvents);
rareEvents = new games.dmg.fantasystick.events.RareEventBridge(eventLog::emit, Clock.systemUTC(),
getDataFolder().toPath(), failure->getLogger().log(Level.WARNING,"Rare event intent failed",failure));
rareShop.attachEvents(rareEvents);rarePotions.attachEvents(rareEvents);admin.attachRareEvents(rareEvents);
rareEvents.replay();
games.dmg.fantasystick.shop.RareCatalog committedRareCatalog;
while((committedRareCatalog=pendingRareCatalogs.poll())!=null)
rareEvents.catalogRefreshed(committedRareCatalog);
bookEvents = new games.dmg.fantasystick.events.BookEventBridge(eventLog::emit, Clock.systemUTC());books.attachEvents(bookEvents);
progressionEvents = new games.dmg.fantasystick.events.ProgressionEventBridge(eventLog::emit, Clock.systemUTC());books.attachProgressionEvents(progressionEvents);
abilityEvents = new games.dmg.fantasystick.events.AbilityEventBridge(eventLog::emit, Clock.systemUTC());books.attachAbilityEvents(abilityEvents);
@@ -1117,6 +1129,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingEvents = sessionEvents; sessionEvents = null;
var closingEventLog = eventLog; eventLog = null; preferenceEvents = null;
var closingFishingAuditEvents = fishingAuditEvents; fishingAuditEvents = null;
var closingRareEvents = rareEvents; rareEvents = null;
var closingNutritionAuditEvents = nutritionAuditEvents; nutritionAuditEvents = null;
var closingSwitchAuditEvents = switchAuditEvents; switchAuditEvents = null;
var closingSwitchConfigEvents = switchConfigEvents; switchConfigEvents = null;
@@ -1129,7 +1142,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingAdminRevivalAuditEvents = adminRevivalEvents; adminRevivalEvents = null;
var closingEquipmentAuditEvents = equipmentAuditEvents; equipmentAuditEvents = null;
var closingSessionRecoveryAuditEvents = sessionRecoveryAuditEvents; sessionRecoveryAuditEvents = null;
var closingEventCapture = eventCapture; eventCapture = null; var closingMessageAuditEvents = messageAuditEvents; messageAuditEvents = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; abilityEvents = null; passiveEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null;
var closingEventCapture = eventCapture; eventCapture = null; var closingMessageAuditEvents = messageAuditEvents; messageAuditEvents = null; downedEvents = null; bookEvents = null; progressionEvents = null; abilityEvents = null; passiveEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null;
var closingBooks = books; books = null;
var closingPartyEvents = partyEvents; partyEvents = null;
var closingPartyCombat = partyCombat; partyCombat = null;
@@ -1550,6 +1563,18 @@ public final class FantasyStickPlugin extends JavaPlugin {
return null;
}).thenCompose(ignored -> closingFishingAuditEvents.drained());
});
if (closingRareEvents != null) cleanup("rare event intent drain", () -> {
var marketDrained = closingRareMarket == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingRareMarket.drained();
var inventoryDrained = closingRareInventory == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingRareInventory.drained();
rareAuditDrain = java.util.concurrent.CompletableFuture.allOf(marketDrained.toCompletableFuture(),
inventoryDrained.toCompletableFuture(), configurationDrain.toCompletableFuture())
.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Rare owner drain failed", failure);
closingRareEvents.close();return null;
}).thenCompose(ignored -> closingRareEvents.drained());
});
if (closingEventLog != null) cleanup("local events", closingEventLog::close);
if (closingVitals != null) cleanup("native health hooks", closingVitals::close);
unregister(closingModes);
@@ -3,19 +3,123 @@ package games.dmg.fantasystick.events;
import games.dmg.fantasystick.shop.RareCatalog;
import games.dmg.fantasystick.shop.RareProduct;
import java.math.BigInteger;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.function.Function;
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){}}
/** Failure-isolated rare catalog and acknowledged purchase event adapter. */
public final class RareEventBridge implements AutoCloseable {
private final Consumer<EventInput> sink;private final Clock clock;private final FeatureEventOutbox outbox;
private final java.util.Set<UUID> catalogEvents=java.util.concurrent.ConcurrentHashMap.newKeySet();
public RareEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);this.outbox=null;}
public RareEventBridge(Function<EventInput,CompletionStage<LocalEventLog.Delivery>> sink,Clock clock,Path data,Consumer<Throwable> failures){
this.sink=null;this.clock=Objects.requireNonNull(clock);
this.outbox=new FeatureEventOutbox(data,"rare-event-intents",Set.of("rare_catalog.refreshed",
"rare_shop.placed","rare_shop.removed","rare_item.purchased","rare_item.used",
"config.read","config.changed"),sink,failures);
}
public void replay(){if(outbox!=null)outbox.replay();}
@Override public void close(){if(outbox!=null)outbox.close();}
public CompletionStage<Void> drained(){return outbox==null?CompletableFuture.completedFuture(null):outbox.drained();}
public void catalogRefreshed(RareCatalog catalog){
if(!catalogEvents.add(catalog.revision()))return;
var products=new java.util.ArrayList<java.util.Map<String,String>>();
for(var product:RareProduct.values())if(catalog.available(product)){
var offer=catalog.offer(product);
products.add(java.util.Map.of("product_id",product.id(),"price",offer.price().toString(),
"stock",Integer.toString(offer.remaining())));
}
var data=new LinkedHashMap<String,Object>();data.put("actor_kind","system");
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("next_midnight",catalog.day().plusDays(1).atStartOfDay(java.time.ZoneOffset.UTC).toInstant().toString());
data.put("products",products);data.put("outcome","acknowledged");
emit(stable(catalog.revision(),"rare-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){
purchased(RareProduct.PHOENIX_DOWN,operation,catalog,null,player,playerName,character,characterName,
session,price,stockBefore,stockAfter,quantityBefore,quantityAfter,null,null,null,null);
}
public void purchased(RareProduct product,UUID operation,UUID catalog,UUID shop,UUID player,String playerName,
UUID character,String characterName,UUID session,BigInteger price,int stockBefore,int stockAfter,
BigInteger quantityBefore,BigInteger quantityAfter,BigInteger coinsBefore,BigInteger coinsAfter,
BigInteger revisionBefore,BigInteger revisionAfter){
var data=new LinkedHashMap<String,Object>();data.put("actor_kind","player");data.put("product_id",product.id());
data.put("catalog_revision",catalog.toString());data.put("purchase_operation_id",operation.toString());
if(shop!=null)data.put("shop_id",shop.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());
if(coinsBefore!=null&&coinsAfter!=null){data.put("coins_before",coinsBefore.toString());data.put("coins_after",coinsAfter.toString());}
if(revisionBefore!=null&&revisionAfter!=null){data.put("revision_before",revisionBefore.toString());data.put("revision_after",revisionAfter.toString());}
data.put("outcome","acknowledged");
var context=new EventInput.Context(player,playerName,character,characterName,session,null);
emit(stable(operation,product==RareProduct.PHOENIX_DOWN?"phoenix-purchased":"rare-purchased"),operation,
clock.instant(),"rare_item.purchased",context,data);
}
public void configRead(String actorKind,UUID player,String playerName,String key,String value){
config("config.read",actorKind,player,playerName,key,null,value);
}
public void configChanged(String actorKind,UUID player,String playerName,String key,String before,String after){
config("config.changed",actorKind,player,playerName,key,before,after);
}
private void config(String type,String actorKind,UUID player,String playerName,String key,String before,String after){
var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);
if(player!=null)data.put("actor_player_id",player.toString());
if(playerName!=null)data.put("actor_player_name",playerName);
data.put("key",key);if(before!=null)data.put("before",before);data.put("after",after);
data.put("application",key.endsWith("-healing-percent")?"future_potion_uses":"next_rare_restock");
data.put("outcome","acknowledged");var operation=UUID.randomUUID();
var context=player==null?null:new EventInput.Context(player,playerName,null,null,null,null);
emit(stable(operation,type),operation,clock.instant(),type,context,data);
}
public void shopPlaced(games.dmg.fantasystick.shop.RareMerchant shop,UUID administrator,String name){
shopChanged("rare_shop.placed",shop.id(),shop,administrator,name);
}
public void shopRemoved(UUID operation,games.dmg.fantasystick.shop.RareMerchant shop,UUID administrator,String name){
shopChanged("rare_shop.removed",operation,shop,administrator,name);
}
private void shopChanged(String type,UUID operation,games.dmg.fantasystick.shop.RareMerchant shop,UUID administrator,String name){
var data=new LinkedHashMap<String,Object>();data.put("actor_kind",administrator==null?"console":"player");
if(name!=null)data.put("administrator_name",name);
data.put("shop_id",shop.id().toString());data.put("shop_name",shop.name());
data.put("world_id",shop.world().toString());data.put("x",shop.x());data.put("y",shop.y());
data.put("z",shop.z());data.put("yaw",(double)shop.yaw());data.put("pitch",(double)shop.pitch());
data.put("outcome","acknowledged");
var context=administrator==null?null:new EventInput.Context(administrator,name,null,null,null,null);
emit(stable(operation,type),operation,clock.instant(),type,context,data);
}
public EventInput.Context potionContext(org.bukkit.entity.Player player,UUID character,String name,UUID session){
var nativeLocation=player.getLocation();var world=nativeLocation.getWorld();
var location=world==null?null:new EventInput.Location(world.getUID(),world.getName(),
nativeLocation.getX(),nativeLocation.getY(),nativeLocation.getZ(),clock.instant(),"native");
return new EventInput.Context(player.getUniqueId(),player.getName(),character,name,session,location);
}
public void potionUsed(UUID operation,RareProduct product,EventInput.Context context,BigInteger quantityBefore,
int percent,BigDecimal healthBefore,BigDecimal healthAfter,BigDecimal maximum,
BigDecimal requested,BigDecimal applied,BigInteger revisionBefore,BigInteger revisionAfter){
var data=new LinkedHashMap<String,Object>();data.put("actor_kind","player");data.put("product_id",product.id());
data.put("use_operation_id",operation.toString());data.put("quantity_before",quantityBefore.toString());
data.put("quantity_after",quantityBefore.subtract(BigInteger.ONE).toString());
data.put("healing_percent",Integer.toString(percent));data.put("health_before",healthBefore.stripTrailingZeros().toPlainString());
data.put("health_after",healthAfter.stripTrailingZeros().toPlainString());
data.put("maximum_health",maximum.stripTrailingZeros().toPlainString());
data.put("requested_healing",requested.stripTrailingZeros().toPlainString());
data.put("applied_healing",applied.stripTrailingZeros().toPlainString());
data.put("revision_before",revisionBefore.toString());data.put("revision_after",revisionAfter.toString());
data.put("outcome","acknowledged");
emit(stable(operation,"potion-used"),operation,clock.instant(),"rare_item.used",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{var event=new EventInput(id,action,time,type,context,data);if(outbox!=null)outbox.accepted(event);else sink.accept(event);}catch(RuntimeException ignored){}}
private static UUID stable(UUID authority,String kind){return UUID.nameUUIDFromBytes(("fantasystick:"+authority+':'+kind).getBytes(StandardCharsets.UTF_8));}
}
@@ -111,6 +111,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.shop.ArmorMarket armorMarket;
private games.dmg.fantasystick.shop.RareMerchantDirectory rareMerchants;
private games.dmg.fantasystick.shop.RareMerchantController rareMerchantController;
private games.dmg.fantasystick.events.RareEventBridge rareAuditEvents;
private games.dmg.fantasystick.shop.RareMarket rareMarket;
private games.dmg.fantasystick.persistence.RareSettings rareSettings;
private games.dmg.fantasystick.persistence.DownedSettings downedSettings;
@@ -491,6 +492,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
public void attachArmorers(games.dmg.fantasystick.shop.FantasyArmorerDirectory directory,games.dmg.fantasystick.shop.FantasyArmorerController controller){if(closed||armorers!=null)throw new IllegalStateException("Armorers already attached or command closed");armorers=Objects.requireNonNull(directory);armorerController=Objects.requireNonNull(controller);}
public void attachArmorMarket(games.dmg.fantasystick.shop.ArmorMarket market){if(closed||armorMarket!=null)throw new IllegalStateException("Armor market already attached or command closed");armorMarket=Objects.requireNonNull(market);}
public void attachRareMarket(games.dmg.fantasystick.shop.RareMerchantDirectory directory,games.dmg.fantasystick.shop.RareMerchantController controller,games.dmg.fantasystick.shop.RareMarket market,games.dmg.fantasystick.persistence.RareSettings settings){if(closed||rareMerchants!=null)throw new IllegalStateException("Rare market already attached or command closed");rareMerchants=Objects.requireNonNull(directory);rareMerchantController=Objects.requireNonNull(controller);rareMarket=Objects.requireNonNull(market);rareSettings=Objects.requireNonNull(settings);}
public void attachRareEvents(games.dmg.fantasystick.events.RareEventBridge events){if(rareAuditEvents!=null)throw new IllegalStateException("Rare events already attached");rareAuditEvents=Objects.requireNonNull(events);}
public void attachDownedSettings(games.dmg.fantasystick.persistence.DownedSettings settings){if(closed||downedSettings!=null)throw new IllegalStateException("Downed settings already attached or command closed");downedSettings=Objects.requireNonNull(settings);}
public void attachDownedEvents(games.dmg.fantasystick.events.DownedEventBridge events){if(closed||downedEvents!=null)throw new IllegalStateException("Downed events already attached or command closed");downedEvents=Objects.requireNonNull(events);}
@@ -868,7 +870,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void placeShopResponse(org.bukkit.entity.Player player,org.bukkit.Location location,games.dmg.fantasystick.shop.FantasyArmorer saved,Throwable failure){if(failure!=null)report(player.getUniqueId(),"shop-place",failure);if(closed)return;nextTick.accept(()->{if(closed)return;if(failure!=null){if(player.isOnline())player.sendMessage("Fantasy Armorer placement failed; no registration or entity changed.");return;}try{int x=location.getBlockX()>>4,z=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(x,z))armorerController.reconcile(location.getWorld().getChunkAt(x,z));if(player.isOnline())player.sendMessage("Placed Fantasy Armorer "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"shop-entity",entityFailure);armorers.remove(saved.id()).whenComplete((ignored,rollback)->nextTick.accept(()->{armorerController.removeLoaded(saved.id());if(player.isOnline())player.sendMessage("Fantasy Armorer placement failed; no registration or entity changed.");}));}});}
private void removeShopResponse(CommandSender sender,UUID id,java.util.Optional<games.dmg.fantasystick.shop.FantasyArmorer> removed,Throwable failure){if(failure!=null)report(null,"shop-remove",failure);if(closed)return;nextTick.accept(()->{if(failure!=null)sender.sendMessage("Fantasy Armorer removal failed; registration and entity are unchanged.");else if(removed.isEmpty())sender.sendMessage("Fantasy Armorer not found.");else try{armorerController.removeLoaded(id);sender.sendMessage("Removed Fantasy Armorer "+id+".");}catch(RuntimeException entityFailure){var value=removed.orElseThrow();report(null,"shop-entity-remove",entityFailure);armorers.restore(value).whenComplete((restored,rollback)->{if(rollback!=null)report(null,"shop-remove-rollback",rollback);if(closed)return;nextTick.accept(()->{if(rollback==null){var world=server.getWorld(value.world());if(world!=null&&world.isChunkLoaded(((int)Math.floor(value.x()))>>4,((int)Math.floor(value.z()))>>4))armorerController.reconcile(world.getChunkAt(((int)Math.floor(value.x()))>>4,((int)Math.floor(value.z()))>>4));sender.sendMessage("Fantasy Armorer removal failed; registration and entity are unchanged.");}else sender.sendMessage("Fantasy Armorer removal rollback failed; inspect diagnostics.");});});}});}
private void rareShop(CommandSender sender,String[] args){if(rareMerchants==null||rareMerchantController==null||rareMarket==null||args.length<2){rareShopUsage(sender);return;}if(args[1].equals("restock")&&args.length==2){rareMarket.restock().whenComplete((catalog,failure)->{if(failure!=null)report(null,"rare-shop-restock",failure);if(closed)return;nextTick.accept(()->{if(closed||!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Restocked Rare Items catalog "+catalog.revision()+"; next automatic restock remains 00:00 UTC.":"Rare Items restock failed; prior catalog is unchanged.");});});return;}if(args[1].equals("list")&&args.length==2){var values=rareMerchants.all();if(values.isEmpty())sender.sendMessage("No Rare Items merchants are registered.");else for(var value:values)sender.sendMessage(value.id()+" "+(value.name().isBlank()?"Rare Items":value.name())+" @ "+value.world()+" "+value.x()+" "+value.y()+" "+value.z());return;}if(args[1].equals("place")&&sender instanceof org.bukkit.entity.Player player){String name=args.length<3?"":String.join(" ",java.util.Arrays.copyOfRange(args,2,args.length));var location=player.getLocation().clone();try{rareMerchants.place(name,location.getWorld().getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((saved,failure)->{if(failure!=null)report(player.getUniqueId(),"rare-shop-place",failure);if(closed)return;nextTick.accept(()->{if(closed||!player.hasPermission(PERMISSION)||!player.isOnline())return;if(failure!=null){player.sendMessage("Rare Items placement failed; prior state is unchanged.");return;}try{int x=location.getBlockX()>>4,z=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(x,z))rareMerchantController.reconcile(location.getWorld().getChunkAt(x,z));player.sendMessage("Placed Rare Items merchant "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"rare-shop-entity",entityFailure);rareMerchants.remove(saved.id());rareMerchantController.removeLoaded(saved.id());player.sendMessage("Rare Items placement failed; registration rollback was requested.");}});});}catch(IllegalArgumentException invalid){sender.sendMessage("Rare Items merchant name is invalid.");}return;}if(args[1].equals("remove")&&args.length==3){UUID id;try{id=UUID.fromString(args[2]);if(!id.toString().equals(args[2]))throw new IllegalArgumentException();}catch(IllegalArgumentException invalid){sender.sendMessage("Rare Items merchant UUID is invalid.");return;}rareMerchants.remove(id).whenComplete((removed,failure)->{if(failure!=null)report(null,"rare-shop-remove",failure);if(closed)return;nextTick.accept(()->{if(closed||!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Rare Items removal failed; registration is unchanged.");else if(removed.isEmpty())sender.sendMessage("Rare Items merchant not found.");else{rareMerchantController.removeLoaded(id);sender.sendMessage("Removed Rare Items merchant "+id+".");}});});return;}rareShopUsage(sender);}
private void rareShop(CommandSender sender,String[] args){if(rareMerchants==null||rareMerchantController==null||rareMarket==null||args.length<2){rareShopUsage(sender);return;}if(args[1].equals("restock")&&args.length==2){rareMarket.restock().whenComplete((catalog,failure)->{if(failure!=null)report(null,"rare-shop-restock",failure);if(closed)return;nextTick.accept(()->{if(closed||!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Restocked Rare Items catalog "+catalog.revision()+"; next automatic restock remains 00:00 UTC.":"Rare Items restock failed; prior catalog is unchanged.");});});return;}if(args[1].equals("list")&&args.length==2){var values=rareMerchants.all();if(values.isEmpty())sender.sendMessage("No Rare Items merchants are registered.");else for(var value:values)sender.sendMessage(value.id()+" "+(value.name().isBlank()?"Rare Items":value.name())+" @ "+value.world()+" "+value.x()+" "+value.y()+" "+value.z());return;}if(args[1].equals("place")&&sender instanceof org.bukkit.entity.Player player){String name=args.length<3?"":String.join(" ",java.util.Arrays.copyOfRange(args,2,args.length));var location=player.getLocation().clone();try{rareMerchants.place(name,location.getWorld().getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((saved,failure)->{if(failure!=null)report(player.getUniqueId(),"rare-shop-place",failure);if(closed)return;nextTick.accept(()->{if(closed||!player.hasPermission(PERMISSION)||!player.isOnline())return;if(failure!=null){player.sendMessage("Rare Items placement failed; prior state is unchanged.");return;}try{int x=location.getBlockX()>>4,z=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(x,z))rareMerchantController.reconcile(location.getWorld().getChunkAt(x,z));if(rareAuditEvents!=null)rareAuditEvents.shopPlaced(saved,player.getUniqueId(),player.getName());player.sendMessage("Placed Rare Items merchant "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"rare-shop-entity",entityFailure);rareMerchants.remove(saved.id());rareMerchantController.removeLoaded(saved.id());player.sendMessage("Rare Items placement failed; registration rollback was requested.");}});});}catch(IllegalArgumentException invalid){sender.sendMessage("Rare Items merchant name is invalid.");}return;}if(args[1].equals("remove")&&args.length==3){UUID id;try{id=UUID.fromString(args[2]);if(!id.toString().equals(args[2]))throw new IllegalArgumentException();}catch(IllegalArgumentException invalid){sender.sendMessage("Rare Items merchant UUID is invalid.");return;}rareMerchants.remove(id).whenComplete((removed,failure)->{if(failure!=null)report(null,"rare-shop-remove",failure);if(closed)return;nextTick.accept(()->{if(closed||!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Rare Items removal failed; registration is unchanged.");else if(removed.isEmpty())sender.sendMessage("Rare Items merchant not found.");else{rareMerchantController.removeLoaded(id);if(rareAuditEvents!=null)rareAuditEvents.shopRemoved(UUID.randomUUID(),removed.orElseThrow(),sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null,sender instanceof org.bukkit.entity.Player namedPlayer?namedPlayer.getName():null);sender.sendMessage("Removed Rare Items merchant "+id+".");}});});return;}rareShopUsage(sender);}
private static void rareShopUsage(CommandSender sender){sender.sendMessage("Usage: /fstickadmin rare-shop <place [name...]|remove <shop-uuid>|list|restock>");}
private void trainingDummy(CommandSender sender, String[] args) {
@@ -1899,7 +1901,16 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void partyEconomyConfiguration(CommandSender sender,String[] args){String key=args[1],before=partyEconomySettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;if(args.length==2){if(partyEvents!=null)partyEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+" (applies to future party transactions only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PartyEconomySettings.Update> operation;try{operation=partyEconomySettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Party price is invalid; enter a whole number from 0 through 2147483647.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=failure==null&&partyEconomySettings.publish(saved);if(applied&&partyEvents!=null)partyEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Party price save failed; the effective price is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+partyEconomySettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for future party transactions.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void gearConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+gearSettings.value(key)+" (applies to the next armor restock only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.GearSettings.Update> operation;try{operation=gearSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Armor market setting is invalid; variance is 0–100, prices are nonnegative, and positive stock minima cannot exceed maxima.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;nextTick.accept(()->{boolean applied=failure==null&&gearSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Armor market setting save failed; effective settings and the current catalog are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+gearSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for the next automatic or administrative restock; the current catalog is unchanged.");});});}
private void rareConfiguration(CommandSender sender,String[] args){String key=args[1],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 static boolean potionConfig(String key){
if(key.equals("rare-price-variance-percent"))return true;
for(var product:games.dmg.fantasystick.shop.RareProduct.values())if(product.heals()){
var id=product.id();
if(key.equals("rare-price-"+id)||key.equals("rare-stock-"+id+"-min")
||key.equals("rare-stock-"+id+"-max")||key.equals("rare-"+id+"-healing-percent"))return true;
}
return false;
}
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);else if(potionConfig(key)&&rareAuditEvents!=null)rareAuditEvents.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());else if(applied&&potionConfig(key)&&rareAuditEvents!=null)rareAuditEvents.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],before=coinSettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;if(args.length==2){if(coinEvents!=null)coinEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+" (applies to future creation or defeats only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.CoinSettings.Update> operation;try{operation=coinSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Coin setting is invalid; ranges must be ordered whole numbers from 0 through 2147483647.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=failure==null&&coinSettings.publish(saved);if(applied&&coinEvents!=null)coinEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Coin configuration save failed; effective ranges are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+coinSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for future creation or defeats; existing balances and captured rewards are unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
@@ -14,17 +14,19 @@ public final class RareMarket implements AutoCloseable {
public enum PurchaseStatus{APPLIED,SOLD_OUT,STALE,INSUFFICIENT,UNAVAILABLE,FAILED}
public record PurchaseProof(UUID operation,RareProduct product,BigInteger price,int stockAfter){}
public record PurchaseOutcome(PurchaseStatus status,Optional<CharacterRecord> record,RareCatalog catalog,PurchaseProof proof){public PurchaseOutcome(PurchaseStatus status,Optional<CharacterRecord> record,RareCatalog catalog){this(status,record,catalog,null);}}
private final RareCatalogRepository catalogs;private final RarePurchaseOperationStore operations;private final CharacterStorage characters;private final RareInventoryDirectory inventory;private final Supplier<RareMarketRules> rules;private final Clock clock;private final RareCatalogGenerator.InclusiveRoll random;private final ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-rare-market"));private final CompletionStage<Void> readiness;private volatile RareCatalog snapshot;private volatile boolean initialized;private volatile Consumer<RareCatalog> listener=ignored->{};private boolean closed,operationsClosing;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
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 Consumer<RareCatalog> listener=ignored->{};private final ArrayDeque<RareCatalog> pendingRefreshes=new ArrayDeque<>();private boolean listenerAttached,closed,operationsClosing;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
public RareMarket(RareCatalogRepository catalogs,RarePurchaseOperationStore operations,CharacterStorage characters,RareInventoryDirectory inventory,Supplier<RareMarketRules> rules,Clock clock,RareCatalogGenerator.InclusiveRoll random){this.catalogs=Objects.requireNonNull(catalogs);this.operations=Objects.requireNonNull(operations);this.characters=Objects.requireNonNull(characters);this.inventory=Objects.requireNonNull(inventory);this.rules=Objects.requireNonNull(rules);this.clock=Objects.requireNonNull(clock);this.random=Objects.requireNonNull(random);readiness=submit(()->{initialize();return null;});}
public void attachListener(Consumer<RareCatalog> listener){this.listener=Objects.requireNonNull(listener);}public Optional<RareCatalog> snapshot(){return Optional.ofNullable(snapshot);}public Instant now(){return clock.instant();}public CompletionStage<Void> ready(){return readiness;}public CompletionStage<RareCatalog> current(){return readiness.thenCompose(ignored->submit(()->ensureCurrent(RareCatalog.RefreshReason.SCHEDULED)));}
public synchronized void attachListener(Consumer<RareCatalog> listener){this.listener=Objects.requireNonNull(listener);listenerAttached=true;while(!pendingRefreshes.isEmpty())deliver(pendingRefreshes.removeFirst());}public Optional<RareCatalog> snapshot(){return Optional.ofNullable(snapshot);}public Instant now(){return clock.instant();}public CompletionStage<Void> ready(){return readiness;}public CompletionStage<RareCatalog> current(){return readiness.thenCompose(ignored->submit(()->ensureCurrent(RareCatalog.RefreshReason.SCHEDULED)));}
public CompletionStage<RareCatalog> restock(){return readiness.thenCompose(ignored->submit(()->{var current=ensureCurrent(RareCatalog.RefreshReason.SCHEDULED);if(!current.transactions().isEmpty())throw new IOException("Rare purchases are unresolved");var generated=RareCatalogGenerator.generate(LocalDate.now(clock.withZone(ZoneOffset.UTC)),clock.instant().truncatedTo(java.time.temporal.ChronoUnit.SECONDS),RareCatalog.RefreshReason.ADMINISTRATIVE,rules.get(),random);persist(generated,true);return generated;}));}
public static Instant nextScheduledRestock(Instant now){return now.atZone(ZoneOffset.UTC).toLocalDate().plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();}
public CompletionStage<PurchaseOutcome> purchase(UUID character,BigInteger expectedCharacterRevision,UUID expectedCatalogRevision,RareProduct product){return readiness.thenCompose(ignored->submit(()->purchaseBlocking(character,expectedCharacterRevision,expectedCatalogRevision,product)));}
private void initialize()throws Exception{snapshot=catalogs.load().orElse(null);var plans=operations.load().toCompletableFuture().join();if(snapshot==null&&!plans.isEmpty())throw new IOException("Rare catalog missing for pending purchases");for(var plan:plans)execute(plan);if(snapshot!=null&&!snapshot.transactions().isEmpty()){var next=snapshot;for(var entry:snapshot.transactions().entrySet()){if(entry.getValue().state()==RareCatalog.TransactionState.RESERVED)throw new IOException("Orphan reserved rare purchase "+entry.getKey());next=next.prune(entry.getKey());}persist(next,false);}ensureCurrent(RareCatalog.RefreshReason.STARTUP);initialized=true;}
private 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);}
private PurchaseOutcome purchaseBlocking(UUID character,BigInteger expectedCharacterRevision,UUID expectedCatalogRevision,RareProduct product)throws Exception{var catalog=ensureCurrent(RareCatalog.RefreshReason.SCHEDULED);if(!catalog.transactions().isEmpty())return new PurchaseOutcome(PurchaseStatus.FAILED,Optional.empty(),catalog);if(!catalog.revision().equals(expectedCatalogRevision))return new PurchaseOutcome(PurchaseStatus.STALE,Optional.empty(),catalog);if(!catalog.available(product))return new PurchaseOutcome(PurchaseStatus.UNAVAILABLE,Optional.empty(),catalog);if(catalog.offer(product).remaining()==0)return new PurchaseOutcome(PurchaseStatus.SOLD_OUT,Optional.empty(),catalog);var plan=new RarePurchaseOperationStore.Plan(UUID.randomUUID(),character,expectedCharacterRevision,catalog.revision(),product,catalog.offer(product).price());operations.prepare(plan).toCompletableFuture().join();return execute(plan);}
private PurchaseOutcome execute(RarePurchaseOperationStore.Plan plan)throws Exception{var catalog=Objects.requireNonNull(snapshot,"Rare catalog unavailable");if(!catalog.revision().equals(plan.catalogRevision()))throw new IOException("Pending rare purchase belongs to another catalog");var transaction=catalog.transactions().get(plan.id());if(transaction==null){try{catalog=catalog.reserve(plan.id(),plan.product());}catch(IllegalStateException soldOut){operations.complete(plan.id()).toCompletableFuture().join();return new PurchaseOutcome(PurchaseStatus.SOLD_OUT,Optional.empty(),catalog);}persist(catalog,false);transaction=catalog.transactions().get(plan.id());}if(transaction.product()!=plan.product())throw new IOException("Rare reservation product mismatch");if(transaction.state()!=RareCatalog.TransactionState.RESERVED){if(transaction.state()==RareCatalog.TransactionState.CONSUMED)inventory.add(plan.character(),plan.product(),plan.id()).join();operations.complete(plan.id()).toCompletableFuture().join();var pruned=catalog.prune(plan.id());persist(pruned,false);return new PurchaseOutcome(transaction.state()==RareCatalog.TransactionState.CONSUMED?PurchaseStatus.APPLIED:PurchaseStatus.FAILED,characters.find(plan.character()).join(),pruned,transaction.state()==RareCatalog.TransactionState.CONSUMED?new PurchaseProof(plan.id(),plan.product(),plan.price(),pruned.offer(plan.product()).remaining()):null);}var purchase=characters.purchaseRare(plan.character(),plan.expectedCharacterRevision(),plan.id(),plan.price()).join();boolean consumed=purchase.status()==CharacterStorage.RarePurchaseStatus.APPLIED||purchase.status()==CharacterStorage.RarePurchaseStatus.DUPLICATE;if(consumed)inventory.add(plan.character(),plan.product(),plan.id()).join();catalog=catalog.settle(plan.id(),consumed);persist(catalog,consumed);operations.complete(plan.id()).toCompletableFuture().join();catalog=catalog.prune(plan.id());persist(catalog,false);var status=switch(purchase.status()){case APPLIED,DUPLICATE->PurchaseStatus.APPLIED;case INSUFFICIENT->PurchaseStatus.INSUFFICIENT;case STALE->PurchaseStatus.STALE;case UNAVAILABLE->PurchaseStatus.UNAVAILABLE;};return new PurchaseOutcome(status,purchase.record(),catalog,status==PurchaseStatus.APPLIED?new PurchaseProof(plan.id(),plan.product(),plan.price(),catalog.offer(plan.product()).remaining()):null);}
private RareCatalog ensureCurrent(RareCatalog.RefreshReason reason)throws IOException{var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));if(snapshot!=null&&snapshot.day().equals(today))return snapshot;if(snapshot!=null&&!snapshot.transactions().isEmpty())throw new IOException("Rare purchases unresolved at rollover");var generated=RareCatalogGenerator.generate(today,clock.instant().truncatedTo(java.time.temporal.ChronoUnit.SECONDS),reason,rules.get(),random);persist(generated,true);return generated;}
private void persist(RareCatalog catalog,boolean notify)throws IOException{catalogs.save(catalog);snapshot=catalog;if(notify&&initialized)try{listener.accept(catalog);}catch(RuntimeException ignored){}}
private void persist(RareCatalog catalog,boolean notify)throws IOException{catalogs.save(catalog);snapshot=catalog;if(notify)notifyRefresh(catalog);}
private synchronized void notifyRefresh(RareCatalog catalog){if(!listenerAttached)pendingRefreshes.addLast(catalog);else deliver(catalog);}
private void deliver(RareCatalog catalog){try{listener.accept(catalog);}catch(RuntimeException ignored){}}
private <T>CompletionStage<T> submit(Callable<T> action){synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Rare market closed"));pending++;}return CompletableFuture.supplyAsync(()->{try{return action.call();}catch(Exception failure){throw new CompletionException(failure);}finally{finished();}},worker).minimalCompletionStage();}
private synchronized void finished(){pending--;if(closed&&pending==0)closeOperations();}private void closeOperations(){if(operationsClosing)return;operationsClosing=true;operations.close();operations.drained().whenComplete((ignored,failure)->{if(failure==null)drained.complete(null);else drained.completeExceptionally(failure);});}public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)closeOperations();}
}
@@ -20,13 +20,18 @@ public final class RarePotionService {
public enum Status{APPLIED,FULL_HEALTH,NO_EFFECT,NOT_AVAILABLE,STALE,FAILED}
public record Outcome(Status status,BigDecimal healed){}
private record Context(EquipmentSessions.Active active,CharacterRecord record){}
private final RareInventoryDirectory inventory;private final RareSettings settings;private final CharacterStorage characters;private final EquipmentSessions sessions;private final Consumer<EntityRegainHealthEvent> healingEvents;private final Consumer<Runnable> nextTick;private final Consumer<Throwable> failures;
private final RareInventoryDirectory inventory;private final RareSettings settings;private final CharacterStorage characters;private final EquipmentSessions sessions;private final Consumer<EntityRegainHealthEvent> healingEvents;private final Consumer<Runnable> nextTick;private final Consumer<Throwable> failures;private games.dmg.fantasystick.events.RareEventBridge events;
public RarePotionService(RareInventoryDirectory inventory,RareSettings settings,CharacterStorage characters,EquipmentSessions sessions,Consumer<EntityRegainHealthEvent> healingEvents,Consumer<Runnable> nextTick,Consumer<Throwable> failures){this.inventory=Objects.requireNonNull(inventory);this.settings=Objects.requireNonNull(settings);this.characters=Objects.requireNonNull(characters);this.sessions=Objects.requireNonNull(sessions);this.healingEvents=Objects.requireNonNull(healingEvents);this.nextTick=Objects.requireNonNull(nextTick);this.failures=Objects.requireNonNull(failures);}
public void attachEvents(games.dmg.fantasystick.events.RareEventBridge events){if(this.events!=null)throw new IllegalStateException("Rare potion events already attached");this.events=Objects.requireNonNull(events);}
/** Ambiguous pre-restart native delivery is consumed but never replayed onto a replacement holder. */
public void install(){for(var use:inventory.unresolvedUses())if(RareProduct.find(use.product()).map(RareProduct::heals).orElse(false))inventory.commitUse(use.operation()).exceptionally(failure->{report(failure);return RareInventoryDirectory.UseStatus.RESERVED;});}
public BigInteger quantity(UUID character,RareProduct product){return inventory.quantity(character,product);}
public void use(Player player,RareProduct product,Consumer<Outcome> result){if(!product.heals()){result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO));return;}var context=context(player);if(context.isEmpty()){result.accept(new Outcome(Status.STALE,BigDecimal.ZERO));return;}var captured=context.orElseThrow();var attribute=player.getAttribute(Attribute.MAX_HEALTH);double current=player.getHealth(),maximum=attribute==null?Double.NaN:attribute.getValue();if(!Double.isFinite(current)||!Double.isFinite(maximum)||maximum<=0||current>=maximum){result.accept(new Outcome(Status.FULL_HEALTH,BigDecimal.ZERO));return;}int percent=settings.rules().healingPercent().get(product);double proposed=Math.min(maximum-current,maximum*percent/100d);if(!Double.isFinite(proposed)||proposed<=0){result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO));return;}var event=new EntityRegainHealthEvent(player,proposed,EntityRegainHealthEvent.RegainReason.CUSTOM);healingEvents.accept(event);double accepted=Math.min(proposed,Math.min(maximum-current,event.getAmount()));if(event.isCancelled()||!Double.isFinite(accepted)||accepted<=0){result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO));return;}UUID operation=UUID.randomUUID();BigInteger expectedQuantity=inventory.quantity(captured.record().id(),product);BigDecimal before=BigDecimal.valueOf(current),limit=BigDecimal.valueOf(maximum).min(NativeHealthLimit.read()),target=BigDecimal.valueOf(current+accepted).min(limit);inventory.reserveUse(captured.record().id(),product,operation,expectedQuantity,before,limit,percent).whenComplete((reservation,reserveFailure)->{if(reserveFailure!=null){report(reserveFailure);publish(()->result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO)));return;}if(reservation.status()!=RareInventoryDirectory.UseStatus.RESERVED){publish(()->result.accept(new Outcome(reservation.status()==RareInventoryDirectory.UseStatus.NOT_AVAILABLE?Status.NOT_AVAILABLE:Status.STALE,BigDecimal.ZERO)));return;}characters.saveHealth(captured.record().id(),captured.record().revision(),target,limit).whenComplete((saved,saveFailure)->publish(()->complete(player,captured,operation,accepted,saved,saveFailure,result)));});}
private void complete(Player player,Context captured,UUID operation,double accepted,Optional<CharacterRecord> saved,Throwable failure,Consumer<Outcome> result){if(failure!=null||saved==null||saved.isEmpty()){if(failure!=null)report(failure);inventory.refundUse(operation).whenComplete((ignored,refundFailure)->{if(refundFailure!=null)report(refundFailure);publish(()->result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO)));});return;}var persisted=saved.orElseThrow();var current=context(player);boolean acknowledged=false;try{acknowledged=current.isPresent()&&current.orElseThrow().active().session().equals(captured.active().session())&&sessions.acknowledgeProgress(captured.active(),captured.record().revision(),persisted);}catch(RuntimeException uncertain){report(uncertain);}if(!acknowledged){inventory.commitUse(operation).whenComplete((ignored,commitFailure)->{if(commitFailure!=null)report(commitFailure);publish(()->result.accept(new Outcome(Status.STALE,BigDecimal.ZERO)));});return;}var attribute=player.getAttribute(Attribute.MAX_HEALTH);double now=player.getHealth(),maximum=attribute==null?Double.NaN:attribute.getValue(),desired=Math.min(maximum,persisted.health().doubleValue()),restored=Math.min(accepted,desired-now);if(!Double.isFinite(now)||!Double.isFinite(maximum)||!Double.isFinite(restored)||restored<=0){inventory.refundUse(operation).whenComplete((ignored,refundFailure)->{if(refundFailure!=null)report(refundFailure);publish(()->result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO)));});return;}try{player.setHealth(Math.min(maximum,now+restored));}catch(RuntimeException uncertain){report(uncertain);inventory.commitUse(operation);result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO));return;}inventory.commitUse(operation).whenComplete((status,commitFailure)->{if(commitFailure!=null){report(commitFailure);publish(()->result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO)));return;}publish(()->result.accept(new Outcome(Status.APPLIED,BigDecimal.valueOf(restored).stripTrailingZeros())));});}
public void use(Player player,RareProduct product,Consumer<Outcome> result){if(!product.heals()){result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO));return;}var context=context(player);if(context.isEmpty()){result.accept(new Outcome(Status.STALE,BigDecimal.ZERO));return;}var captured=context.orElseThrow();var attribute=player.getAttribute(Attribute.MAX_HEALTH);double current=player.getHealth(),maximum=attribute==null?Double.NaN:attribute.getValue();if(!Double.isFinite(current)||!Double.isFinite(maximum)||maximum<=0||current>=maximum){result.accept(new Outcome(Status.FULL_HEALTH,BigDecimal.ZERO));return;}int percent=settings.rules().healingPercent().get(product);double proposed=Math.min(maximum-current,maximum*percent/100d);if(!Double.isFinite(proposed)||proposed<=0){result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO));return;}var event=new EntityRegainHealthEvent(player,proposed,EntityRegainHealthEvent.RegainReason.CUSTOM);healingEvents.accept(event);double accepted=Math.min(proposed,Math.min(maximum-current,event.getAmount()));if(event.isCancelled()||!Double.isFinite(accepted)||accepted<=0){result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO));return;}UUID operation=UUID.randomUUID();BigInteger expectedQuantity=inventory.quantity(captured.record().id(),product);BigDecimal before=BigDecimal.valueOf(current),limit=BigDecimal.valueOf(maximum).min(NativeHealthLimit.read()),target=BigDecimal.valueOf(current+accepted).min(limit);inventory.reserveUse(captured.record().id(),product,operation,expectedQuantity,before,limit,percent).whenComplete((reservation,reserveFailure)->{if(reserveFailure!=null){report(reserveFailure);publish(()->result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO)));return;}if(reservation.status()!=RareInventoryDirectory.UseStatus.RESERVED){publish(()->result.accept(new Outcome(reservation.status()==RareInventoryDirectory.UseStatus.NOT_AVAILABLE?Status.NOT_AVAILABLE:Status.STALE,BigDecimal.ZERO)));return;}characters.saveHealth(captured.record().id(),captured.record().revision(),target,limit).whenComplete((saved,saveFailure)->publish(()->complete(player,captured,operation,product,percent,expectedQuantity,accepted,saved,saveFailure,result)));});}
private void complete(Player player,Context captured,UUID operation,RareProduct product,int percent,BigInteger quantityBefore,double accepted,Optional<CharacterRecord> saved,Throwable failure,Consumer<Outcome> result){if(failure!=null||saved==null||saved.isEmpty()){if(failure!=null)report(failure);inventory.refundUse(operation).whenComplete((ignored,refundFailure)->{if(refundFailure!=null)report(refundFailure);publish(()->result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO)));});return;}var persisted=saved.orElseThrow();var current=context(player);boolean acknowledged=false;try{acknowledged=current.isPresent()&&current.orElseThrow().active().session().equals(captured.active().session())&&sessions.acknowledgeProgress(captured.active(),captured.record().revision(),persisted);}catch(RuntimeException uncertain){report(uncertain);}if(!acknowledged){inventory.commitUse(operation).whenComplete((ignored,commitFailure)->{if(commitFailure!=null)report(commitFailure);publish(()->result.accept(new Outcome(Status.STALE,BigDecimal.ZERO)));});return;}var attribute=player.getAttribute(Attribute.MAX_HEALTH);double now=player.getHealth(),maximum=attribute==null?Double.NaN:attribute.getValue(),desired=Math.min(maximum,persisted.health().doubleValue()),restored=Math.min(accepted,desired-now);if(!Double.isFinite(now)||!Double.isFinite(maximum)||!Double.isFinite(restored)||restored<=0){inventory.refundUse(operation).whenComplete((ignored,refundFailure)->{if(refundFailure!=null)report(refundFailure);publish(()->result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO)));});return;}try{player.setHealth(Math.min(maximum,now+restored));}catch(RuntimeException uncertain){report(uncertain);inventory.commitUse(operation);result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO));return;}var audit=events;var actor=audit==null?null:safePotionContext(audit,player,captured);var nativeBefore=BigDecimal.valueOf(now);var nativeAfter=BigDecimal.valueOf(now+restored);var nativeMaximum=BigDecimal.valueOf(maximum);inventory.commitUse(operation).whenComplete((status,commitFailure)->{if(commitFailure!=null){report(commitFailure);publish(()->result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO)));return;}if(status==RareInventoryDirectory.UseStatus.COMMITTED&&audit!=null&&actor!=null)audit.potionUsed(operation,product,actor,quantityBefore,percent,nativeBefore,nativeAfter,nativeMaximum,BigDecimal.valueOf(accepted),BigDecimal.valueOf(restored),captured.record().revision(),persisted.revision());publish(()->result.accept(new Outcome(Status.APPLIED,BigDecimal.valueOf(restored).stripTrailingZeros())));});}
private games.dmg.fantasystick.events.EventInput.Context safePotionContext(games.dmg.fantasystick.events.RareEventBridge audit,Player player,Context captured){
try{return audit.potionContext(player,captured.record().id(),captured.record().name(),captured.active().session());}
catch(RuntimeException failure){report(failure);return null;}
}
private Optional<Context> context(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);return record==null?Optional.empty():Optional.of(new Context(active,record));}
private void publish(Runnable action){try{nextTick.accept(action);}catch(RuntimeException failure){report(failure);}}private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}
}
@@ -20,7 +20,7 @@ public final class RareShop {
public void attachEvents(games.dmg.fantasystick.events.RareEventBridge events){if(this.events!=null)throw new IllegalStateException("Rare events already attached");this.events=Objects.requireNonNull(events);}
public Optional<CharacterRecord> active(Player player,UUID shop){return context(player,shop).map(Context::record);}public Optional<UUID> activeSession(Player player,UUID shop){return context(player,shop).map(value->value.active().session());}public Instant now(){return market.now();}
public void view(Player player,UUID shopId,Consumer<Optional<View>> result){var captured=context(player,shopId);if(captured.isEmpty()){result.accept(Optional.empty());return;}market.current().whenComplete((catalog,failure)->{if(failure!=null){report(failure);publish(()->result.accept(Optional.empty()));return;}publish(()->{var current=context(player,shopId);if(current.isEmpty()||!current.orElseThrow().active().session().equals(captured.orElseThrow().active().session()))result.accept(Optional.empty());else result.accept(Optional.of(new View(current.orElseThrow().record(),current.orElseThrow().active().session(),catalog)));});});}
public void buy(Player player,UUID shopId,UUID catalogRevision,RareProduct product,Consumer<Outcome> result){var lease=shops.acquire(shopId);if(lease.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var held=lease.orElseThrow();var context=context(player,shopId);if(context.isEmpty()){held.close();result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var captured=context.orElseThrow();market.purchase(captured.record().id(),captured.record().revision(),catalogRevision,product).whenComplete((purchase,failure)->{if(failure!=null){report(failure);publishHeld(held,()->result.accept(new Outcome(Status.FAILED,Optional.empty())));return;}publishHeld(held,()->{var current=context(player,shopId);if(current.isEmpty()||!current.orElseThrow().active().session().equals(captured.active().session())){result.accept(new Outcome(Status.STALE,purchase.record()));return;}var status=switch(purchase.status()){case APPLIED->Status.APPLIED;case SOLD_OUT->Status.SOLD_OUT;case INSUFFICIENT->Status.INSUFFICIENT;case STALE->Status.STALE;case UNAVAILABLE,FAILED->Status.FAILED;};if(purchase.status()==RareMarket.PurchaseStatus.APPLIED&&purchase.record().isPresent()){sessions.acknowledgeProgress(captured.active(),captured.record().revision(),purchase.record().orElseThrow());if(product==RareProduct.PHOENIX_DOWN&&purchase.proof()!=null&&events!=null){var after=inventory.quantity(captured.record().id(),product);events.purchased(purchase.proof().operation(),purchase.catalog().revision(),player.getUniqueId(),player.getName(),captured.record().id(),captured.record().name(),captured.active().session(),purchase.proof().price(),purchase.proof().stockAfter()+1,purchase.proof().stockAfter(),after.subtract(java.math.BigInteger.ONE),after);}}result.accept(new Outcome(status,purchase.record()));});});}
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.heals()||product==RareProduct.PHOENIX_DOWN)&&purchase.proof()!=null&&events!=null){var after=inventory.quantity(captured.record().id(),product);var saved=purchase.record().orElseThrow();events.purchased(product,purchase.proof().operation(),purchase.catalog().revision(),shopId,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,captured.record().coins(),saved.coins(),captured.record().revision(),saved.revision());}}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){}}
}
@@ -40,11 +40,113 @@ class RareShopNativeTest {
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
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());
await(f,()->{
var events=exportedRareEvents(data);
return events.stream().filter(event->event.get("type").getAsString()
.endsWith("rare_catalog.refreshed")).count()==2
&&hasRareEvent(events,"rare_shop.placed","shop_id",shop.id().toString())
&&hasRareEvent(events,"rare_item.purchased","product_id","health-potion")
&&hasRareEvent(events,"rare_item.purchased","product_id","phoenix-down")
&&hasRareEvent(events,"rare_item.used","product_id","health-potion");
});
f.plugin.setEnabled(false);f.awaitWorkers();
var exported=new java.util.ArrayList<com.google.gson.JsonObject>();
try(var eventFiles=java.nio.file.Files.walk(data.resolve("events"))){
for(var path:eventFiles.filter(file->file.getFileName().toString().endsWith(".jsonl")).toList())
for(var line:java.nio.file.Files.readAllLines(path))if(!line.isBlank())
exported.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
var placements=exported.stream().filter(event->event.get("type").getAsString()
.endsWith("rare_shop.placed")&&event.getAsJsonObject("data")
.get("shop_id").getAsString().equals(shop.id().toString())).toList();
assertEquals(1,placements.size(),"Registered placement must reach exported JSONL");
assertEquals("Curios",placements.getFirst().getAsJsonObject("data").get("shop_name").getAsString());
var catalogs=exported.stream().filter(event->event.get("type").getAsString()
.endsWith("rare_catalog.refreshed")).toList();
assertEquals(2,catalogs.size(),"Startup and manual commits each publish one global catalog record");
assertEquals(2,catalogs.stream().map(event->event.getAsJsonObject("data")
.get("catalog_revision").getAsString()).distinct().count());
for(var catalog:catalogs){
var products=catalog.getAsJsonObject("data").getAsJsonArray("products");
assertTrue(products.asList().stream().anyMatch(product->product.getAsJsonObject()
.get("product_id").getAsString().equals("phoenix-down")));
var phoenixOffer=products.asList().stream().map(com.google.gson.JsonElement::getAsJsonObject)
.filter(product->product.get("product_id").getAsString().equals("phoenix-down"))
.findFirst().orElseThrow();
assertEquals("10",phoenixOffer.get("price").getAsString());
assertEquals("1",phoenixOffer.get("stock").getAsString());
var healing=products.asList().stream().map(com.google.gson.JsonElement::getAsJsonObject)
.filter(product->product.get("product_id").getAsString().equals("health-potion"))
.findFirst().orElseThrow();
assertEquals("40",healing.get("price").getAsString());
assertEquals("1",healing.get("stock").getAsString());
}
assertTrue(exported.stream().anyMatch(event->event.get("type").getAsString()
.endsWith("rare_item.purchased")&&event.getAsJsonObject("data")
.get("product_id").getAsString().equals("phoenix-down")));
assertFalse(exported.stream().anyMatch(event->event.get("type").getAsString()
.endsWith("rare_item.purchased")&&Set.of("reskill","magic-core").contains(
event.getAsJsonObject("data").get("product_id").getAsString())),
"US-087/088 purchase-event ownership is not selected by US-086");
var potionPurchases=exported.stream().filter(event->event.get("type").getAsString()
.endsWith("rare_item.purchased")&&event.getAsJsonObject("data")
.get("product_id").getAsString().equals("health-potion")).toList();
assertEquals(1,potionPurchases.size(),"Acknowledged potion purchase must reach JSONL");
assertEquals("40",potionPurchases.getFirst().getAsJsonObject("data").get("price").getAsString());
assertEquals("1",potionPurchases.getFirst().getAsJsonObject("data").get("stock_before").getAsString());
assertEquals("0",potionPurchases.getFirst().getAsJsonObject("data").get("stock_after").getAsString());
var potionUses=exported.stream().filter(event->event.get("type").getAsString()
.endsWith("rare_item.used")&&event.getAsJsonObject("data")
.get("product_id").getAsString().equals("health-potion")).toList();
assertEquals(1,potionUses.size(),"Acknowledged native potion healing must reach JSONL");
assertEquals("5",potionUses.getFirst().getAsJsonObject("data").get("health_before").getAsString());
assertEquals("10",potionUses.getFirst().getAsJsonObject("data").get("health_after").getAsString());
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());
f.plugin.setEnabled(false);f.awaitWorkers();
assertEquals(2,exportedRareEvents(data).stream().filter(event->event.get("type").getAsString()
.endsWith("rare_catalog.refreshed")).count(),"Restarting a current catalog is not another refresh");
}
}
@Test void registeredAdministratorRemovalExportsExactDurableMerchant()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);
var merchants=(RareMerchantDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchants");
var shop=merchants.place("Retired Curios",UUID.randomUUID(),12,64,20,90,0).join();
var sender=new EquipmentAdminCommandNativeTest.Sender();
f.command.execute(sender.port,"fantasystick",new String[]{"rare-shop","remove",shop.id().toString()});
await(f,()->merchants.find(shop.id()).isEmpty());
await(f,()->hasRareEvent(exportedRareEvents(data),"rare_shop.removed","shop_id",shop.id().toString()));
f.plugin.setEnabled(false);f.awaitWorkers();
var removals=new java.util.ArrayList<com.google.gson.JsonObject>();
try(var files=java.nio.file.Files.walk(data.resolve("events"))){
for(var path:files.filter(file->file.toString().endsWith(".jsonl")).toList())
for(var line:java.nio.file.Files.readAllLines(path)){
var event=com.google.gson.JsonParser.parseString(line).getAsJsonObject();
if(event.get("type").getAsString().endsWith("rare_shop.removed"))removals.add(event);
}
}
assertEquals(1,removals.size());
assertEquals(shop.id().toString(),removals.getFirst().getAsJsonObject("data")
.get("shop_id").getAsString());
assertEquals("Retired Curios",removals.getFirst().getAsJsonObject("data")
.get("shop_name").getAsString());
}
}
@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());}
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.command.execute(sender.port,"fantasystick",new String[]{"config","rare-health-potion-healing-percent"});
assertTrue(sender.messages.getLast().contains("=50"));
int potionMessages=sender.messages.size();
f.command.execute(sender.port,"fantasystick",new String[]{"config","rare-health-potion-healing-percent","75"});
await(f,()->sender.messages.size()>potionMessages);
assertEquals("75",((RareSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareSettings")).value("rare-health-potion-healing-percent"));
await(f,()->hasRareEvent(exportedRareEvents(data),"config.changed","key","rare-health-potion-healing-percent"));
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());
var potionTypes=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-health-potion-healing-percent"))potionTypes.add(event.get("type").getAsString());}}
assertTrue(potionTypes.stream().anyMatch(type->type.endsWith("config.read")),potionTypes.toString());
assertTrue(potionTypes.stream().anyMatch(type->type.endsWith("config.changed")),potionTypes.toString());}
}
@Test void reskillUseRequiresFreshConfirmationThenResetsTheExactActiveCharacter()throws Exception{
@@ -78,9 +180,57 @@ class RareShopNativeTest {
@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 transferredStoredPotionUseExportsNewHolderAndOriginalCharacter()throws Exception{
UUID originalCharacter,newHolder;
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);
var a=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(a.core.id,a.player);f.activate(a);
originalCharacter=a.core.record.id();
var inventory=(RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");
inventory.add(originalCharacter,RareProduct.HEALTH_POTION,UUID.randomUUID()).join();
var sessions=(games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.RESTORED,
sessions.exit(a.player));f.awaitCharacterAndReceipt(a);
var b=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(b.core.id,b.player);
newHolder=b.core.id;
b.core.inventory.setItemInOffHand(a.core.inventory.getItemInOffHand().clone());
f.activate(b);
assertEquals(originalCharacter,sessions.active(b.core.id).orElseThrow().character());
var characters=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");
injure(b,characters,sessions,new BigDecimal("5"));
useFirst(f,b);
await(f,()->inventory.quantity(originalCharacter,RareProduct.HEALTH_POTION).signum()==0
&&b.player.getHealth()>5);
await(f,()->hasRareEvent(exportedRareEvents(data),"rare_item.used","product_id","health-potion"));
f.plugin.setEnabled(false);f.awaitWorkers();
}
var uses=exportedRareEvents(data).stream().filter(event->event.get("type").getAsString()
.endsWith("rare_item.used")).toList();
assertEquals(1,uses.size());
assertEquals(originalCharacter.toString(),uses.getFirst().getAsJsonObject("data")
.get("character_id").getAsString());
assertEquals(newHolder.toString(),uses.getFirst().getAsJsonObject("data")
.get("player_id").getAsString());
}
@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());}
private static void useFirst(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor)throws Exception{PartyBookNativeTest.open(f,actor);PartyBookNativeTest.click(f,actor,PartyBookNativeTest.button(actor,"[Use]"));}
private static boolean hasRareEvent(List<com.google.gson.JsonObject> events,String type,String field,String value){
return events.stream().anyMatch(event->event.get("type").getAsString().endsWith(type)
&&event.getAsJsonObject("data").has(field)
&&event.getAsJsonObject("data").get(field).getAsString().equals(value));
}
private static List<com.google.gson.JsonObject> exportedRareEvents(Path root){
try(var files=java.nio.file.Files.walk(root.resolve("events"))){
var records=new ArrayList<com.google.gson.JsonObject>();
for(var path:files.filter(file->file.toString().endsWith(".jsonl")).toList())
for(var line:java.nio.file.Files.readAllLines(path))if(!line.isBlank())
records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
return records;
}catch(java.io.IOException|RuntimeException transientExport){return List.of();}
}
private static void awaitEvidence(FantasyStickLifecycleNativeTest.Fixture f,java.util.function.BooleanSupplier condition,java.util.function.Supplier<String> evidence)throws Exception{for(int attempt=0;attempt<80&&!condition.getAsBoolean();attempt++){var task=f.tasks.poll(50,TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(condition.getAsBoolean(),evidence);}
private static void await(FantasyStickLifecycleNativeTest.Fixture f,java.util.function.BooleanSupplier condition)throws Exception{for(int attempt=0;attempt<80&&!condition.getAsBoolean();attempt++){var task=f.tasks.poll(50,TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(condition.getAsBoolean());}
}
@@ -1,15 +1,122 @@
package games.dmg.fantasystick.events;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.shop.*;
import games.dmg.fantasystick.shop.RareCatalog;
import games.dmg.fantasystick.shop.RareProduct;
import java.math.BigInteger;
import java.time.*;
import java.util.*;
import java.nio.file.Path;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.io.TempDir;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
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)));}
@TempDir Path data;
@Test void rejectedCatalogIntentReplaysOneFullProductRevisionIntoJsonl() throws Exception {
var now=Instant.parse("2026-09-25T01:00:00Z");
var offers=new EnumMap<RareProduct,RareCatalog.Offer>(RareProduct.class);
offers.put(RareProduct.PHOENIX_DOWN,new RareCatalog.Offer(BigInteger.valueOf(1007),2));
offers.put(RareProduct.HEALTH_POTION,new RareCatalog.Offer(BigInteger.valueOf(151),0));
var catalog=new RareCatalog(LocalDate.of(2026,9,25),UUID.randomUUID(),now,
RareCatalog.RefreshReason.ADMINISTRATIVE,offers);
var refused=new RareEventBridge(event->CompletableFuture.completedFuture(LocalEventLog.Delivery.REJECTED),
Clock.fixed(now,ZoneOffset.UTC),data,ignored->{});
refused.catalogRefreshed(catalog);refused.close();
refused.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
var pending=data.resolve("rare-event-intents");
try(var paths=java.nio.file.Files.list(pending)){
assertEquals(1,paths.filter(path->path.toString().endsWith(".pending")).count());
}
var log=LocalEventLog.open(data,Clock.fixed(now,ZoneOffset.UTC),
new LocalEventLog.RuntimeInfo("test","test","test"),128,1_000_000,ignored->{});
log.ready().toCompletableFuture().get(5,TimeUnit.SECONDS);
var recovered=new RareEventBridge(log::emit,Clock.fixed(now,ZoneOffset.UTC),data,ignored->{});
recovered.replay();recovered.close();recovered.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
log.close();
var rows=new ArrayList<com.google.gson.JsonObject>();
try(var paths=java.nio.file.Files.walk(data.resolve("events"))){
for(var path:paths.filter(value->value.toString().endsWith(".jsonl")).toList())
for(var line:java.nio.file.Files.readAllLines(path)){
var event=com.google.gson.JsonParser.parseString(line).getAsJsonObject();
if(event.get("type").getAsString().endsWith("rare_catalog.refreshed"))rows.add(event);
}
}
assertEquals(1,rows.size());
assertEquals(catalog.revision().toString(),rows.getFirst().getAsJsonObject("data")
.get("catalog_revision").getAsString());
assertEquals(2,rows.getFirst().getAsJsonObject("data").getAsJsonArray("products").size());
}
@Test void oneCommittedCatalogRevisionIncludesPotionAndPhoenixOffersExactlyOnce() {
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));
offers.put(RareProduct.LESSER_HEALTH_POTION, new RareCatalog.Offer(BigInteger.valueOf(51), 0));
offers.put(RareProduct.HEALTH_POTION, new RareCatalog.Offer(BigInteger.valueOf(149), 3));
offers.put(RareProduct.GREATER_HEALTH_POTION, new RareCatalog.Offer(BigInteger.valueOf(499), 1));
var catalog = new RareCatalog(LocalDate.of(2026, 9, 25), UUID.randomUUID(), now,
RareCatalog.RefreshReason.ADMINISTRATIVE, offers);
bridge.catalogRefreshed(catalog);
bridge.catalogRefreshed(catalog);
assertEquals(1, captured.size(), "One revision is one catalog success");
var event = captured.getFirst();
assertEquals("rare_catalog.refreshed", event.type());
assertEquals(catalog.revision(), event.actionId());
assertEquals(catalog.refreshedAt(), event.time());
assertEquals(catalog.revision().toString(), event.data().get("catalog_revision"));
assertEquals("2026-09-25", event.data().get("catalog_day"));
assertEquals("administrative", event.data().get("refresh_reason"));
assertEquals("2026-09-26T00:00:00Z", event.data().get("next_midnight"));
assertFalse(event.data().containsKey("product_id"), "No Phoenix-only second success");
@SuppressWarnings("unchecked") var products = (List<Map<String, String>>) event.data().get("products");
assertEquals(List.of("lesser-health-potion", "health-potion", "greater-health-potion", "phoenix-down"),
products.stream().map(product -> product.get("product_id")).toList());
assertEquals("0", products.getFirst().get("stock"));
assertEquals("51", products.getFirst().get("price"));
assertEquals("1007", products.getLast().get("price"));
assertEquals("2", products.getLast().get("stock"));
}
@Test void phoenixPurchaseRetainsStableExactIdentifiersAndDecimalStrings() {
var captured = new ArrayList<EventInput>();
var bridge = new RareEventBridge(captured::add, Clock.systemUTC());
UUID operation = UUID.randomUUID(), catalog = UUID.randomUUID(), player = UUID.randomUUID(),
character = UUID.randomUUID(), session = UUID.randomUUID();
bridge.purchased(operation, catalog, player, "Account", character, "Wren", session,
BigInteger.valueOf(1007), 2, 1, BigInteger.valueOf(12), BigInteger.valueOf(11));
bridge.purchased(operation, catalog, 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("phoenix-down", event.data().get("product_id"));
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)));
}
}
@@ -15,6 +15,63 @@ import org.junit.jupiter.api.io.TempDir;
class RareMarketTest {
@TempDir Path data;
@Test void committedStartupAndManualRevisionsNotifyOnceButCurrentRestartDoesNot() throws Exception {
var clock=Clock.fixed(Instant.parse("2026-09-23T12:00:00Z"),ZoneOffset.UTC);
try(var settings=new RareSettings(data,Map.of("rare-price-variance-percent","0"));
var characters=new CharacterStorage(new YamlCharacterRepository(data),clock);
var inventory=new RareInventoryDirectory(new YamlRareInventoryRepository(data))){
var notifications=new java.util.ArrayList<RareCatalog>();
try(var market=new RareMarket(new YamlRareCatalogRepository(data),
new RarePurchaseOperationStore(data),characters,inventory,settings::rules,clock,
(minimum,maximum)->minimum)){
market.ready().toCompletableFuture().join();
market.attachListener(notifications::add);
var startup=market.current().toCompletableFuture().join();
assertEquals(List.of(startup.revision()),notifications.stream()
.map(RareCatalog::revision).toList());
var manual=market.restock().toCompletableFuture().join();
assertEquals(List.of(startup.revision(),manual.revision()),notifications.stream()
.map(RareCatalog::revision).toList());
market.current().toCompletableFuture().join();
assertEquals(2,notifications.size());
}
try(var restarted=new RareMarket(new YamlRareCatalogRepository(data),
new RarePurchaseOperationStore(data),characters,inventory,settings::rules,clock,
(minimum,maximum)->minimum)){
restarted.ready().toCompletableFuture().join();
var onRestart=new java.util.ArrayList<RareCatalog>();
restarted.attachListener(onRestart::add);
restarted.current().toCompletableFuture().join();
assertTrue(onRestart.isEmpty(),"Retaining a current revision is not a refresh success");
}
}
}
@Test void midnightRefreshNotifiesOnlyTheNewCommittedUtcRevision() throws Exception {
var instant=new java.util.concurrent.atomic.AtomicReference<>(Instant.parse("2026-09-23T23:59:59Z"));
Clock clock=new Clock(){
@Override public ZoneId getZone(){return ZoneOffset.UTC;}
@Override public Clock withZone(ZoneId zone){return zone.equals(ZoneOffset.UTC)?this:Clock.fixed(instant.get(),zone);}
@Override public Instant instant(){return instant.get();}
};
try(var settings=new RareSettings(data,Map.of("rare-price-variance-percent","0"));
var characters=new CharacterStorage(new YamlCharacterRepository(data),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)){
market.ready().toCompletableFuture().join();
var observed=new java.util.ArrayList<RareCatalog>();market.attachListener(observed::add);
var before=market.current().toCompletableFuture().join();
instant.set(Instant.parse("2026-09-24T00:00:00Z"));
var after=market.current().toCompletableFuture().join();
market.current().toCompletableFuture().join();
assertNotEquals(before.revision(),after.revision());
assertEquals(List.of(before.revision(),after.revision()),observed.stream()
.map(RareCatalog::revision).toList());
assertEquals(RareCatalog.RefreshReason.SCHEDULED,after.reason());
}
}
@Test void purchaseRecoverablyDebitsOnePriceStockAndAddsOneTypedItemAcrossRestart() throws Exception {
var clock=Clock.fixed(Instant.parse("2026-09-23T12:00:00Z"),ZoneOffset.UTC);var settings=new RareSettings(data,Map.of("rare-price-variance-percent","0","rare-price-health-potion","40","rare-stock-health-potion-min","1","rare-stock-health-potion-max","1"));var repository=new YamlCharacterRepository(data);var base=new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());var character=new CharacterRecord(base.id(),base.name(),base.revision(),base.stats(),base.level(),base.experience(),base.unspentPoints(),base.health(),base.learned(),base.loadout(),base.passives(),base.cooldowns(),base.restrictedKills(),false,null,null,BigInteger.valueOf(100),true,java.util.Set.of(),BigInteger.ZERO,CharacterGear.none());
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)){