feat(events): audit confirmed Reskill journeys
Release / release (push) Successful in 21m3s

This commit is contained in:
dmg committed 2026-09-30 21:28:08 -04:00
1 parent cee4690be9
commit 42cf9e160f
8 files changed
+491 -18

No files matched your search

@@ -834,7 +834,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
downedEvents = new games.dmg.fantasystick.events.DownedEventBridge(eventLog::emit, Clock.systemUTC());
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);
rareShop.attachEvents(rareEvents);rarePotions.attachEvents(rareEvents);rareReskills.attachEvents(rareEvents);admin.attachRareEvents(rareEvents);
rareEvents.replay();
games.dmg.fantasystick.shop.RareCatalog committedRareCatalog;
while((committedRareCatalog=pendingRareCatalogs.poll())!=null)
@@ -25,7 +25,7 @@ public final class RareEventBridge implements AutoCloseable {
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);
"config.read","config.changed","action.observed","action.resolved"),sink,failures);
}
public void replay(){if(outbox!=null)outbox.replay();}
@Override public void close(){if(outbox!=null)outbox.close();}
@@ -54,6 +54,13 @@ public final class RareEventBridge implements AutoCloseable {
UUID character,String characterName,UUID session,BigInteger price,int stockBefore,int stockAfter,
BigInteger quantityBefore,BigInteger quantityAfter,BigInteger coinsBefore,BigInteger coinsAfter,
BigInteger revisionBefore,BigInteger revisionAfter){
purchased(product,operation,catalog,shop,player,playerName,character,characterName,session,price,
stockBefore,stockAfter,quantityBefore,quantityAfter,coinsBefore,coinsAfter,revisionBefore,revisionAfter,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,EventInput.Location location){
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());
@@ -63,7 +70,7 @@ public final class RareEventBridge implements AutoCloseable {
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);
var context=new EventInput.Context(player,playerName,character,characterName,session,location);
emit(stable(operation,product==RareProduct.PHOENIX_DOWN?"phoenix-purchased":"rare-purchased"),operation,
clock.instant(),"rare_item.purchased",context,data);
}
@@ -120,6 +127,42 @@ public final class RareEventBridge implements AutoCloseable {
data.put("outcome","acknowledged");
emit(stable(operation,"potion-used"),operation,clock.instant(),"rare_item.used",context,data);
}
public void reskillAction(UUID operation,EventInput.Context context,String phase,String outcome,String reason){
var data=new LinkedHashMap<String,Object>();data.put("actor_kind","player");data.put("product_id","reskill");
data.put("action_kind","reskill_use");data.put("use_operation_id",operation.toString());
data.put("phase",phase);data.put("outcome",outcome);data.put("reason",reason);
String type=outcome.equals("observed")?"action.observed":"action.resolved";
emit(stable(operation,"reskill-"+phase+"-"+type),operation,clock.instant(),type,context,data);
}
public void reskillUsed(UUID operation,EventInput.Context context,BigInteger quantityBefore,
games.dmg.fantasystick.persistence.CharacterRecord before,
games.dmg.fantasystick.persistence.CharacterRecord after,
BigDecimal healthBefore,BigDecimal healthAfter,BigDecimal maximumBefore,BigDecimal maximumAfter){
var data=new LinkedHashMap<String,Object>();data.put("actor_kind","player");data.put("product_id","reskill");
data.put("use_operation_id",operation.toString());data.put("quantity_before",quantityBefore.toString());
data.put("quantity_after",quantityBefore.subtract(BigInteger.ONE).toString());
data.put("revision_before",before.revision().toString());data.put("revision_after",after.revision().toString());
var statsBefore=new LinkedHashMap<String,String>();var statsAfter=new LinkedHashMap<String,String>();
for(var stat:games.dmg.fantasystick.progression.Stat.values()){
String key=stat.name().toLowerCase(java.util.Locale.ROOT);
statsBefore.put(key,before.stats().get(stat).toString());statsAfter.put(key,after.stats().get(stat).toString());
}
data.put("stats_before",statsBefore);data.put("stats_after",statsAfter);
data.put("removed_learned",before.learned().stream().sorted().toList());
data.put("removed_passives",before.passives().stream().filter(value->!value.isEmpty()).distinct().sorted().toList());
data.put("active_slots_before",before.loadout());data.put("active_slots_after",after.loadout());
data.put("passive_slots_before",before.passives());data.put("passive_slots_after",after.passives());
data.put("points_before",before.unspentPoints().toString());data.put("points_after",after.unspentPoints().toString());
data.put("refunded_points",after.unspentPoints().subtract(before.unspentPoints()).toString());
data.put("health_before",healthBefore.stripTrailingZeros().toPlainString());
data.put("health_after",healthAfter.stripTrailingZeros().toPlainString());
data.put("maximum_before",maximumBefore.stripTrailingZeros().toPlainString());
data.put("maximum_after",maximumAfter.stripTrailingZeros().toPlainString());
var deadlines=after.cooldowns().entrySet().stream().sorted(java.util.Map.Entry.comparingByKey())
.map(entry->java.util.Map.of("ability_id",entry.getKey(),"deadline",entry.getValue().toString())).toList();
data.put("retained_cooldowns",deadlines);data.put("cleanup_outcome","acknowledged");data.put("outcome","acknowledged");
emit(stable(operation,"reskill-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));}
}
@@ -1910,7 +1910,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
}
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 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)||key.endsWith("-reskill")||key.startsWith("rare-stock-reskill-"))&&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)||key.endsWith("-reskill")||key.startsWith("rare-stock-reskill-"))&&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);}});}
@@ -18,6 +18,8 @@ public final class RareReskillService {
BigInteger quantity, BigInteger refund) { }
public record Outcome(Status status, Optional<CharacterRecord> record) { }
private record Context(EquipmentSessions.Active active, CharacterRecord record) { }
private record NativeAudit(games.dmg.fantasystick.events.EventInput.Context actor,
java.math.BigDecimal health, java.math.BigDecimal maximum) { }
private final RareInventoryDirectory inventory;
private final CharacterStorage characters;
@@ -25,6 +27,12 @@ public final class RareReskillService {
private final Consumer<Runnable> nextTick;
private final Consumer<CharacterRecord> cleanup;
private final Consumer<Throwable> failures;
private games.dmg.fantasystick.events.RareEventBridge events;
public void attachEvents(games.dmg.fantasystick.events.RareEventBridge events) {
if (this.events != null) throw new IllegalStateException("Reskill events already attached");
this.events = Objects.requireNonNull(events);
}
public RareReskillService(RareInventoryDirectory inventory, CharacterStorage characters,
EquipmentSessions sessions, Consumer<Runnable> nextTick,
@@ -42,35 +50,52 @@ public final class RareReskillService {
if (context == null) return Optional.empty();
var quantity = inventory.quantity(context.record().id(), RareProduct.RESKILL);
if (quantity.signum() <= 0) return Optional.empty();
return Optional.of(new Preview(UUID.randomUUID(), context.active(), context.record(), quantity,
refund(context.record())));
var preview = new Preview(UUID.randomUUID(), context.active(), context.record(), quantity,
refund(context.record()));
var captured = events == null ? null : nativeAudit(events, player, preview);
if (captured != null) {
events.reskillAction(preview.operation(), captured.actor(), "preview", "observed", "requested");
events.reskillAction(preview.operation(), captured.actor(), "preview",
preview.refund().signum() == 0 ? "refused" : "previewed",
preview.refund().signum() == 0 ? "nothing_to_reset" : "confirmation_required");
}
return Optional.of(preview);
}
public void confirm(Player player, Preview preview, Consumer<Outcome> result) {
Objects.requireNonNull(player); Objects.requireNonNull(preview); Objects.requireNonNull(result);
var captured = events == null ? null : nativeAudit(events, player, preview);
if (captured != null) events.reskillAction(preview.operation(), captured.actor(), "confirmation", "observed", "requested");
Consumer<Outcome> auditedResult = outcome -> {
if (captured != null && outcome.status() != Status.APPLIED)
events.reskillAction(preview.operation(), captured.actor(), "confirmation",
outcome.status() == Status.FAILED ? "failed" : "refused",
outcome.status().name().toLowerCase(java.util.Locale.ROOT));
result.accept(outcome);
};
var current = context(player).orElse(null);
if (current == null || !current.active().session().equals(preview.active().session())
|| !current.record().revision().equals(preview.record().revision())
|| !inventory.quantity(current.record().id(), RareProduct.RESKILL).equals(preview.quantity())) {
result.accept(new Outcome(Status.STALE, Optional.ofNullable(current).map(Context::record)));
auditedResult.accept(new Outcome(Status.STALE, Optional.ofNullable(current).map(Context::record)));
return;
}
if (preview.refund().signum() == 0) {
result.accept(new Outcome(Status.NOTHING_TO_RESET, Optional.of(current.record())));
auditedResult.accept(new Outcome(Status.NOTHING_TO_RESET, Optional.of(current.record())));
return;
}
var nativeLimit = NativeHealthLimit.read();
inventory.reserveUse(current.record().id(), RareProduct.RESKILL, preview.operation(), preview.quantity())
.whenComplete((reservation, reserveFailure) -> {
if (reserveFailure != null) { fail(reserveFailure, result); return; }
if (reserveFailure != null) { fail(reserveFailure, auditedResult); return; }
if (reservation.status() != RareInventoryDirectory.UseStatus.RESERVED) {
publish(() -> result.accept(new Outcome(
publish(() -> auditedResult.accept(new Outcome(
reservation.status() == RareInventoryDirectory.UseStatus.NOT_AVAILABLE
? Status.NOT_AVAILABLE : Status.STALE, Optional.empty())));
return;
}
characters.reskill(current.record().id(), current.record().revision(), preview.operation(), nativeLimit)
.whenComplete((reset, resetFailure) -> complete(player, preview, reset, resetFailure, result));
.whenComplete((reset, resetFailure) -> complete(player, preview, reset, resetFailure, auditedResult));
});
}
@@ -106,6 +131,8 @@ public final class RareReskillService {
return;
}
publish(() -> {
var audit = events;
var before = audit == null ? null : nativeAudit(audit, player, preview);
boolean acknowledged = false;
try {
var current = context(player);
@@ -116,13 +143,29 @@ public final class RareReskillService {
} catch (RuntimeException uncertain) { report(uncertain); }
if (acknowledged) try { cleanup.accept(preview.record()); } catch (RuntimeException uncertain) { report(uncertain); acknowledged=false; }
boolean currentSession = acknowledged;
inventory.commitUse(preview.operation()).whenComplete((ignored, commitFailure) -> {
var after = acknowledged && before != null ? nativeAudit(audit, player, preview) : null;
inventory.commitUse(preview.operation()).whenComplete((commitStatus, commitFailure) -> {
if (commitFailure != null) { report(commitFailure); publish(() -> result.accept(new Outcome(Status.FAILED, reset.record()))); }
else publish(() -> result.accept(new Outcome(currentSession ? Status.APPLIED : Status.STALE, reset.record())));
else {
if (currentSession && before != null && after != null
&& reset.status() == CharacterStorage.ReskillStatus.APPLIED
&& commitStatus == RareInventoryDirectory.UseStatus.COMMITTED)
audit.reskillUsed(preview.operation(), before.actor(), preview.quantity(), preview.record(),
reset.record().orElseThrow(), before.health(), after.health(), before.maximum(), after.maximum());
publish(() -> result.accept(new Outcome(currentSession ? Status.APPLIED : Status.STALE, reset.record())));
}
});
});
}
private NativeAudit nativeAudit(games.dmg.fantasystick.events.RareEventBridge audit, Player player, Preview preview) {
try {
return new NativeAudit(audit.potionContext(player, preview.record().id(), preview.record().name(),
preview.active().session()), java.math.BigDecimal.valueOf(player.getHealth()),
java.math.BigDecimal.valueOf(player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH).getValue()));
} catch (RuntimeException failure) { report(failure); return null; }
}
private void refund(UUID operation, Runnable after) {
inventory.refundUse(operation).whenComplete((ignored, failure) -> {
if (failure != null) report(failure);
@@ -20,7 +20,12 @@ 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.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()));});});}
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();var auditLocation=product==RareProduct.RESKILL?purchaseLocation(player,captured):null;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||product==RareProduct.RESKILL)&&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(),auditLocation);}}result.accept(new Outcome(status,purchase.record()));});});}
private games.dmg.fantasystick.events.EventInput.Location purchaseLocation(Player player,Context captured){
if(events==null)return null;
try{return events.potionContext(player,captured.record().id(),captured.record().name(),captured.active().session()).location();}
catch(RuntimeException failure){report(failure);return null;}
}
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){}}
}
@@ -80,14 +80,42 @@ class RareShopNativeTest {
.findFirst().orElseThrow();
assertEquals("40",healing.get("price").getAsString());
assertEquals("1",healing.get("stock").getAsString());
var resetOffer=products.asList().stream().map(com.google.gson.JsonElement::getAsJsonObject)
.filter(product->product.get("product_id").getAsString().equals("reskill"))
.findFirst().orElseThrow();
assertEquals("20",resetOffer.get("price").getAsString());
assertEquals("2",resetOffer.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");
.endsWith("rare_item.purchased")&&event.getAsJsonObject("data")
.get("product_id").getAsString().equals("magic-core")),
"US-088 purchase events wait for their own implementation");
var resetPurchases=exported.stream().filter(event->event.get("type").getAsString()
.endsWith("rare_item.purchased")&&event.getAsJsonObject("data").get("product_id")
.getAsString().equals("reskill")).toList();
assertEquals(2,resetPurchases.size(),"Each acknowledged Reskill receipt reaches JSONL");
assertEquals(2,resetPurchases.stream().map(event->event.get("id").getAsString()).distinct().count());
for(var purchase:resetPurchases){
var proof=purchase.getAsJsonObject("data");
assertEquals("20",proof.get("price").getAsString());
assertEquals(worldId.toString(),proof.getAsJsonObject("location").get("world_id").getAsString());
assertEquals(actor.core.location.getX(),proof.getAsJsonObject("location").get("x").getAsDouble());
assertEquals(new BigInteger(proof.get("coins_before").getAsString()).subtract(BigInteger.valueOf(20)),
new BigInteger(proof.get("coins_after").getAsString()));
assertEquals(new BigInteger(proof.get("stock_before").getAsString()).subtract(BigInteger.ONE),
new BigInteger(proof.get("stock_after").getAsString()));
assertEquals("1",proof.get("quantity_after").getAsString());
}
var resets=exported.stream().filter(event->event.get("type").getAsString()
.endsWith("rare_item.used")&&event.getAsJsonObject("data").get("product_id")
.getAsString().equals("reskill")).toList();
assertEquals(2,resets.size());
assertEquals(2,resets.stream().map(event->event.get("id").getAsString()).distinct().count());
assertTrue(resets.stream().allMatch(event->event.getAsJsonObject("data")
.get("refunded_points").getAsString().equals("1")));
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();
@@ -156,7 +184,37 @@ class RareShopNativeTest {
f.activate(actor);var inventory=(RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");inventory.add(invested.id(),RareProduct.RESKILL,UUID.randomUUID()).join();
PartyBookNativeTest.open(f,actor);String inventoryText=PartyBookNativeTest.text(actor);assertTrue(inventoryText.contains("Reskill: 1"),inventoryText);PartyBookNativeTest.click(f,actor,PartyBookNativeTest.button(actor,"[Use]"));
await(f,()->PartyBookNativeTest.text(actor).contains("Confirm Reskill"));String preview=PartyBookNativeTest.text(actor);assertTrue(preview.contains("Refund: 8 points"),preview);assertTrue(preview.contains("Cooldowns retained"),preview);assertEquals(BigInteger.ONE,inventory.quantity(invested.id(),RareProduct.RESKILL));
PartyBookNativeTest.click(f,actor,PartyBookNativeTest.button(actor,"[Confirm Reskill]"));var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");await(f,()->sessions.active(actor.core.id).flatMap(sessions::currentRecord).map(record->record.stats().values().stream().allMatch(BigInteger.ZERO::equals)&&record.unspentPoints().equals(BigInteger.valueOf(108))).orElse(false));var reset=sessions.currentRecord(sessions.active(actor.core.id).orElseThrow()).orElseThrow();assertTrue(reset.learned().isEmpty());assertEquals(List.of("","",""),reset.passives());assertEquals(deadline,reset.cooldowns().get("revive"));assertEquals(10d,actor.maximumHealth.getValue());assertEquals(10d,actor.player.getHealth());assertEquals(BigInteger.ZERO,inventory.quantity(invested.id(),RareProduct.RESKILL));
assertFalse(hasRareEvent(exportedRareEvents(data),"rare_item.used","product_id","reskill"),
"Preview must not claim a reset");
var confirmation=PartyBookNativeTest.button(actor,"[Confirm Reskill]");
PartyBookNativeTest.click(f,actor,confirmation);var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");await(f,()->sessions.active(actor.core.id).flatMap(sessions::currentRecord).map(record->record.stats().values().stream().allMatch(BigInteger.ZERO::equals)&&record.unspentPoints().equals(BigInteger.valueOf(108))).orElse(false));var reset=sessions.currentRecord(sessions.active(actor.core.id).orElseThrow()).orElseThrow();assertTrue(reset.learned().isEmpty());assertEquals(List.of("","",""),reset.passives());assertEquals(deadline,reset.cooldowns().get("revive"));assertEquals(10d,actor.maximumHealth.getValue());assertEquals(10d,actor.player.getHealth());assertEquals(BigInteger.ZERO,inventory.quantity(invested.id(),RareProduct.RESKILL));
PartyBookNativeTest.click(f,actor,confirmation);
await(f,()->hasRareEvent(exportedRareEvents(data),"rare_item.used","product_id","reskill"));
f.plugin.setEnabled(false);f.awaitWorkers();
var uses=exportedRareEvents(data).stream().filter(event->event.get("type").getAsString()
.endsWith("rare_item.used")&&event.getAsJsonObject("data").get("product_id").getAsString()
.equals("reskill")).toList();
assertEquals(1,uses.size(),"Confirmation retries cannot duplicate a business success");
var proof=uses.getFirst().getAsJsonObject("data");
assertEquals("8",proof.get("refunded_points").getAsString());
assertEquals("100",proof.get("points_before").getAsString());
assertEquals("108",proof.get("points_after").getAsString());
assertEquals("1",proof.get("quantity_before").getAsString());
assertEquals("0",proof.get("quantity_after").getAsString());
assertEquals("5",proof.getAsJsonObject("stats_before").get("con").getAsString());
assertTrue(proof.getAsJsonObject("stats_after").entrySet().stream()
.allMatch(entry->entry.getValue().getAsString().equals("0")));
assertEquals("15",proof.get("health_before").getAsString());
assertEquals("10",proof.get("health_after").getAsString());
assertEquals("15",proof.get("maximum_before").getAsString());
assertEquals("10",proof.get("maximum_after").getAsString());
assertEquals("revive",proof.getAsJsonArray("retained_cooldowns").get(0).getAsJsonObject().get("ability_id").getAsString());
assertEquals(deadline.toString(),proof.getAsJsonArray("retained_cooldowns").get(0).getAsJsonObject().get("deadline").getAsString());
assertEquals("unknown_saved",proof.getAsJsonArray("removed_learned").get(0).getAsString());
assertEquals("unknown_passive",proof.getAsJsonArray("removed_passives").get(0).getAsString());
assertEquals(actor.core.id.toString(),proof.get("player_id").getAsString());
assertEquals(invested.id().toString(),proof.get("character_id").getAsString());
assertEquals("acknowledged",proof.get("cleanup_outcome").getAsString());
}
}
@@ -0,0 +1,275 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import games.dmg.fantasystick.persistence.RareSettings;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.function.BooleanSupplier;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ReskillEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredReskillConfigurationExportsOnlyReadsAndCommittedChanges() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
((games.dmg.fantasystick.events.LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog"))
.ready().toCompletableFuture().get(5, TimeUnit.SECONDS);
var sender = new EquipmentAdminCommandNativeTest.Sender();
var keys = List.of("rare-price-reskill", "rare-stock-reskill-min", "rare-stock-reskill-max");
for (var key : keys) {
assertTrue(f.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "rare-"})
.contains(key));
f.command.execute(sender.port, "fstickadmin", new String[]{"config", key});
}
int messages = sender.messages.size();
f.command.execute(sender.port, "fstickadmin", new String[]{"config", keys.getFirst(), "2001"});
await(f, () -> sender.messages.size() > messages);
assertEquals("2001", ((RareSettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "rareSettings"))
.value(keys.getFirst()));
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
StandardOpenOption.CREATE, StandardOpenOption.WRITE); var lock = channel.lock()) {
assertTrue(lock.isValid());
int before = sender.messages.size();
f.command.execute(sender.port, "fstickadmin", new String[]{"config", keys.getFirst(), "2002"});
await(f, () -> sender.messages.size() > before);
assertTrue(sender.messages.getLast().contains("save failed"));
}
f.command.execute(sender.port, "fstickadmin", new String[]{"config", keys.getFirst(), "-1"});
sender.allowed = false;
f.command.execute(sender.port, "fstickadmin", new String[]{"config", keys.getFirst(), "2003"});
await(f, () -> {
try { return records(data).stream().filter(row -> row.getAsJsonObject("data").has("key")
&& row.getAsJsonObject("data").get("key").getAsString().contains("reskill")).count() == 4; }
catch (Exception failure) { throw new IllegalStateException(failure); }
});
f.plugin.setEnabled(false); f.awaitWorkers();
}
var config = records(data).stream().filter(row -> row.getAsJsonObject("data").has("key")
&& row.getAsJsonObject("data").get("key").getAsString().contains("reskill")).toList();
assertEquals(3, config.stream().filter(row -> row.get("type").getAsString().endsWith("config.read")).count());
var changes = config.stream().filter(row -> row.get("type").getAsString().endsWith("config.changed")).toList();
assertEquals(1, changes.size(), "Failed, invalid and unauthorized writes must not claim success");
var proof = changes.getFirst().getAsJsonObject("data");
assertEquals("2000", proof.get("before").getAsString());
assertEquals("2001", proof.get("after").getAsString());
assertEquals("next_rare_restock", proof.get("application").getAsString());
}
@Test void noOpPreviewAndStaleConfirmationExportRefusalsWithoutUseSuccess() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(a.core.id, a.player); f.activate(a);
var inventory = (games.dmg.fantasystick.shop.RareInventoryDirectory)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "rareInventory");
inventory.add(a.core.record.id(), games.dmg.fantasystick.shop.RareProduct.RESKILL, java.util.UUID.randomUUID()).join();
PartyBookNativeTest.open(f, a); PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[Use]"));
await(f, () -> a.messages.stream().anyMatch(message -> message.contains("no investments")));
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
var characters = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
spendStrength(a, sessions, characters);
PartyBookNativeTest.open(f, a); PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[Use]"));
await(f, () -> PartyBookNativeTest.text(a).contains("Confirm Reskill"));
var confirm = PartyBookNativeTest.button(a, "[Confirm Reskill]");
spendStrength(a, sessions, characters);
PartyBookNativeTest.click(f, a, confirm);
await(f, () -> a.messages.stream().anyMatch(message -> message.contains("build or quantity changed")));
await(f, () -> { try { return records(data).stream().filter(row -> row.get("type").getAsString()
.endsWith("action.resolved") && row.getAsJsonObject("data").has("product_id")
&& row.getAsJsonObject("data").get("product_id").getAsString().equals("reskill")
&& row.getAsJsonObject("data").get("outcome").getAsString().equals("refused")).count() == 2; }
catch (Exception failure) { throw new IllegalStateException(failure); } });
assertEquals(java.math.BigInteger.ONE, inventory.quantity(a.core.record.id(), games.dmg.fantasystick.shop.RareProduct.RESKILL));
f.plugin.setEnabled(false); f.awaitWorkers();
}
assertTrue(uses(data).isEmpty());
}
private static void spendStrength(FantasyStickLifecycleNativeTest.Actor a,
games.dmg.fantasystick.session.EquipmentSessions sessions,
games.dmg.fantasystick.persistence.CharacterStorage characters) {
var active=sessions.active(a.core.id).orElseThrow();var old=sessions.currentRecord(active).orElseThrow();
var changed=characters.purchaseStat(old.id(),old.revision(),games.dmg.fantasystick.progression.Stat.STR,
java.math.BigDecimal.valueOf(a.player.getHealth()),new java.math.BigDecimal("1024")).join().record().orElseThrow();
assertTrue(sessions.acknowledgeProgress(active,old.revision(),changed));
}
@Test void transferredHolderResetExportsHugeExactRefundAndNeverReplaysOnRestart() throws Exception {
java.util.UUID character, holder;
var points = java.math.BigInteger.TWO.pow(100);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = investedActor(f, points);
character = a.core.record.id();
var inventory = (games.dmg.fantasystick.shop.RareInventoryDirectory)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "rareInventory");
inventory.add(character, games.dmg.fantasystick.shop.RareProduct.RESKILL, java.util.UUID.randomUUID()).join();
PartyBookNativeTest.open(f, a);
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[Use]"));
await(f, () -> PartyBookNativeTest.text(a).contains("Confirm Reskill"));
var abandoned = PartyBookNativeTest.button(a, "[Confirm Reskill]");
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);
holder = b.core.id;
b.core.inventory.setItemInOffHand(a.core.inventory.getItemInOffHand().clone()); f.activate(b);
assertEquals(character, sessions.active(holder).orElseThrow().character());
PartyBookNativeTest.click(f, b, abandoned);
assertEquals(java.math.BigInteger.ONE, inventory.quantity(character, games.dmg.fantasystick.shop.RareProduct.RESKILL));
PartyBookNativeTest.open(f, b);
PartyBookNativeTest.click(f, b, PartyBookNativeTest.button(b, "[Use]"));
await(f, () -> PartyBookNativeTest.text(b).contains("Confirm Reskill"));
var confirm = PartyBookNativeTest.button(b, "[Confirm Reskill]");
PartyBookNativeTest.click(f, b, confirm);
await(f, () -> inventory.quantity(character, games.dmg.fantasystick.shop.RareProduct.RESKILL).signum() == 0
&& sessions.active(holder).flatMap(sessions::currentRecord).orElseThrow().unspentPoints().equals(points.add(java.math.BigInteger.valueOf(8))));
assertEquals(5d, b.player.getHealth(), "Reset cannot heal a still-valid lower health value");
assertEquals(10d, b.maximumHealth.getValue());
PartyBookNativeTest.click(f, b, confirm);
awaitUse(f);
f.plugin.setEnabled(false); f.awaitWorkers();
f.plugin.setEnabled(true);
var restored = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(character).orElseThrow();
assertEquals(points.add(java.math.BigInteger.valueOf(8)), restored.unspentPoints());
assertEquals(java.math.BigInteger.valueOf(150), restored.coins());
assertEquals(a.core.record.sheetGeneration(), restored.sheetGeneration());
f.plugin.setEnabled(false); f.awaitWorkers();
}
var uses = uses(data); assertEquals(1, uses.size());
var proof = uses.getFirst().getAsJsonObject("data");
assertEquals(character.toString(), proof.get("character_id").getAsString());
assertEquals(holder.toString(), proof.get("player_id").getAsString());
assertEquals(points.toString(), proof.get("points_before").getAsString());
assertEquals(points.add(java.math.BigInteger.valueOf(8)).toString(), proof.get("points_after").getAsString());
assertEquals(6, proof.getAsJsonObject("stats_before").size());
assertEquals(7, proof.getAsJsonArray("active_slots_after").size());
assertEquals(3, proof.getAsJsonArray("passive_slots_after").size());
assertTrue(proof.has("session_id")); assertTrue(proof.has("location"));
}
@Test void failedResetSaveRefundsItemAndEmitsNoSuccessButFreshRetryDoes() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var a = investedActor(f, java.math.BigInteger.valueOf(100));
var inventory = (games.dmg.fantasystick.shop.RareInventoryDirectory)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "rareInventory");
inventory.add(a.core.record.id(), games.dmg.fantasystick.shop.RareProduct.RESKILL, java.util.UUID.randomUUID()).join();
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
var failures = new ArrayList<Throwable>();
var characters = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters/.records.lock"),
StandardOpenOption.CREATE, StandardOpenOption.WRITE); var lock = channel.lock()) {
assertTrue(lock.isValid());
var service = new games.dmg.fantasystick.shop.RareReskillService(inventory, characters, sessions,
f.tasks::add, ignored -> {}, failures::add);
service.attachEvents((games.dmg.fantasystick.events.RareEventBridge)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "rareEvents"));
var outcome = new java.util.concurrent.atomic.AtomicReference<games.dmg.fantasystick.shop.RareReskillService.Outcome>();
service.confirm(a.player, service.preview(a.player).orElseThrow(), outcome::set);
await(f, () -> outcome.get() != null);
assertEquals(games.dmg.fantasystick.shop.RareReskillService.Status.FAILED, outcome.get().status());
assertEquals(java.math.BigInteger.ONE, inventory.quantity(a.core.record.id(), games.dmg.fantasystick.shop.RareProduct.RESKILL));
assertFalse(failures.isEmpty()); assertTrue(uses(data).isEmpty());
}
PartyBookNativeTest.open(f, a); PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[Use]"));
await(f, () -> PartyBookNativeTest.text(a).contains("Confirm Reskill"));
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[Confirm Reskill]")); awaitUse(f);
f.plugin.setEnabled(false); f.awaitWorkers();
}
assertEquals(1, uses(data).size());
}
@Test void nativeCleanupFailureCannotClaimUseAndAuditSinkFailureCannotUndoReset() throws Exception {
for (boolean cleanupFails : List.of(true, false)) {
var directory = Files.createDirectories(data.resolve(Boolean.toString(cleanupFails)));
try (var f = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
f.plugin.setEnabled(true); var a = investedActor(f, directory, java.math.BigInteger.valueOf(100));
var inventory = (games.dmg.fantasystick.shop.RareInventoryDirectory)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "rareInventory");
inventory.add(a.core.record.id(), games.dmg.fantasystick.shop.RareProduct.RESKILL, java.util.UUID.randomUUID()).join();
var characters = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
var emitted = new ArrayList<games.dmg.fantasystick.events.EventInput>();
var bridge = new games.dmg.fantasystick.events.RareEventBridge(event -> {
emitted.add(event); throw new IllegalStateException("audit unavailable");
}, java.time.Clock.systemUTC());
var service = new games.dmg.fantasystick.shop.RareReskillService(inventory, characters, sessions,
f.tasks::add, ignored -> { if (cleanupFails) throw new IllegalStateException("native cleanup failed"); }, ignored -> {});
service.attachEvents(bridge);
var outcome = new java.util.concurrent.atomic.AtomicReference<games.dmg.fantasystick.shop.RareReskillService.Outcome>();
service.confirm(a.player, service.preview(a.player).orElseThrow(), outcome::set);
await(f, () -> outcome.get() != null);
assertEquals(cleanupFails ? games.dmg.fantasystick.shop.RareReskillService.Status.STALE
: games.dmg.fantasystick.shop.RareReskillService.Status.APPLIED, outcome.get().status());
assertEquals(cleanupFails ? 0 : 1, emitted.stream().filter(event -> event.type().equals("rare_item.used")).count());
assertEquals(java.math.BigInteger.ZERO, inventory.quantity(a.core.record.id(), games.dmg.fantasystick.shop.RareProduct.RESKILL));
assertEquals(java.math.BigInteger.valueOf(108), characters.find(a.core.record.id()).join().orElseThrow().unspentPoints());
f.plugin.setEnabled(false); f.awaitWorkers();
}
}
}
private FantasyStickLifecycleNativeTest.Actor investedActor(FantasyStickLifecycleNativeTest.Fixture f,
java.math.BigInteger points) throws Exception { return investedActor(f, data, points); }
private FantasyStickLifecycleNativeTest.Actor investedActor(FantasyStickLifecycleNativeTest.Fixture f,
Path directory, java.math.BigInteger points) throws Exception {
var a = new FantasyStickLifecycleNativeTest.Actor(directory); f.players.put(a.core.id, a.player);
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(directory);
var old = repository.load(a.core.record.id()).orElseThrow();
var stats = new java.util.EnumMap<>(old.stats()); stats.replaceAll((key, value) -> java.math.BigInteger.ONE);
var invested = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(),
old.revision().add(java.math.BigInteger.ONE), stats, old.level(), old.experience(), points,
new java.math.BigDecimal("5"), java.util.Set.of("unknown_saved"),
List.of("unknown_saved", "", "", "", "", "", ""), List.of("unknown_passive", "", ""),
java.util.Map.of("revive", java.time.Instant.now().plusSeconds(86400)), old.restrictedKills(), false,
null, null, java.math.BigInteger.valueOf(150), true, old.rewardReceipts(), old.pvpCoinEscrow(), old.gear(), old.sheetGeneration());
repository.replace(invested, old.revision()); f.activate(a); return a;
}
static List<JsonObject> uses(Path directory) throws Exception {
return records(directory).stream().filter(row -> row.get("type").getAsString().endsWith("rare_item.used")
&& row.getAsJsonObject("data").get("product_id").getAsString().equals("reskill")).toList();
}
private void awaitUse(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
await(f, () -> { try { return uses(data).size() == 1; }
catch (Exception failure) { throw new IllegalStateException(failure); } });
}
static List<JsonObject> records(Path directory) throws Exception {
var result = new ArrayList<JsonObject>();
try (var dates = Files.list(directory.resolve("events"))) {
for (var date : dates.filter(path -> path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")).toList()) {
try (var segments = Files.list(date)) {
for (var segment : segments.filter(path -> path.getFileName().toString().endsWith(".jsonl")).toList())
for (var line : Files.readAllLines(segment)) if (!line.isBlank())
result.add(JsonParser.parseString(line).getAsJsonObject());
}
}
}
return result;
}
static void await(FantasyStickLifecycleNativeTest.Fixture f, BooleanSupplier condition) throws Exception {
for (int attempt = 0; attempt < 100 && !condition.getAsBoolean(); attempt++) {
var task = f.tasks.poll(50, TimeUnit.MILLISECONDS);
if (task != null) task.run();
}
assertTrue(condition.getAsBoolean());
}
}
@@ -112,6 +112,55 @@ final class RareEventBridgeTest {
assertEquals("11", event.data().get("quantity_after"));
}
@Test void rejectedReskillUseReplaysExactRefundSlotsAndUnknownDeadlinesWithoutAnotherMutation() throws Exception {
var now=Instant.parse("2026-10-01T01:00:00Z");
var base=new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());
var stats=new EnumMap<>(base.stats());stats.put(games.dmg.fantasystick.progression.Stat.CON,BigInteger.valueOf(5));
var points=BigInteger.TWO.pow(100);
var before=new games.dmg.fantasystick.persistence.CharacterRecord(base.id(),base.name(),base.revision(),
stats,base.level(),base.experience(),points,new java.math.BigDecimal("15"),java.util.Set.of("legacy.active"),
List.of("legacy.active","","","","","",""),List.of("legacy.passive","",""),
Map.of("legacy.revive",now.plusSeconds(86400)),base.restrictedKills(),false,null,null,
base.coins(),true,base.rewardReceipts(),base.pvpCoinEscrow(),base.gear(),base.sheetGeneration());
var repository=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);repository.create(before);
var operation=UUID.randomUUID();games.dmg.fantasystick.persistence.CharacterRecord after;
try(var storage=new games.dmg.fantasystick.persistence.CharacterStorage(repository,Clock.fixed(now,ZoneOffset.UTC))){
after=storage.reskill(before.id(),before.revision(),operation,new java.math.BigDecimal("1024")).join().record().orElseThrow();
}
var context=new EventInput.Context(UUID.randomUUID(),"Account",before.id(),before.name(),UUID.randomUUID(),
new EventInput.Location(UUID.randomUUID(),"world",1,64,3,now,"native"));
var attempted=new ArrayList<EventInput>();
var refused=new RareEventBridge(event->{attempted.add(event);return CompletableFuture.completedFuture(LocalEventLog.Delivery.REJECTED);},
Clock.fixed(now,ZoneOffset.UTC),data,ignored->{});
refused.reskillUsed(operation,context,BigInteger.ONE,before,after,new java.math.BigDecimal("15"),
java.math.BigDecimal.TEN,new java.math.BigDecimal("15"),java.math.BigDecimal.TEN);
refused.close();refused.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
assertEquals(1,attempted.size(),"Unknown proven skill/cooldown identifiers must not prevent durable event acceptance");
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_item.used"))rows.add(event);
}
}
assertEquals(1,rows.size());assertEquals(attempted.getFirst().id().toString(),rows.getFirst().get("id").getAsString());
var proof=rows.getFirst().getAsJsonObject("data");
assertEquals(operation.toString(),proof.get("use_operation_id").getAsString());
assertEquals(points.add(BigInteger.valueOf(7)).toString(),proof.get("points_after").getAsString());
assertEquals("legacy.active",proof.getAsJsonArray("removed_learned").get(0).getAsString());
assertEquals("legacy.passive",proof.getAsJsonArray("removed_passives").get(0).getAsString());
var deadline=proof.getAsJsonArray("retained_cooldowns").get(0).getAsJsonObject();
assertEquals("legacy.revive",deadline.get("ability_id").getAsString());
assertEquals(now.plusSeconds(86400).toString(),deadline.get("deadline").getAsString());
assertEquals(after,repository.load(before.id()).orElseThrow(),"Audit replay cannot reset/refund a character again");
}
@Test void eventSinkFailureCannotAlterMarketCallers() {
var bridge = new RareEventBridge(value -> { throw new IllegalStateException("disk"); }, Clock.systemUTC());
var offers = new EnumMap<RareProduct, RareCatalog.Offer>(RareProduct.class);