This commit is contained in:
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);
|
||||
|
||||
Reference in new issue
Block a user