Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42cf9e160f | ||
|
|
cee4690be9 | ||
|
|
9ed5dea830 | ||
|
|
43734d2634 | ||
|
|
103a3e44b7 | ||
|
|
e04a3152c1 | ||
|
|
9bb56f3a1c | ||
|
|
a57b59f4f1 | ||
|
|
f4db24492a |
No files matched your search
@@ -166,6 +166,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.events.LocalEventLog eventLog;
|
||||
private games.dmg.fantasystick.events.FishingEventBridge fishingAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> fishingAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private java.util.concurrent.CompletionStage<Void> rareAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.PreferenceEventBridge preferenceEvents;
|
||||
private games.dmg.fantasystick.events.EventCaptureListener eventCapture;
|
||||
private games.dmg.fantasystick.events.DownedEventBridge downedEvents;
|
||||
@@ -644,7 +645,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
armorerController=new games.dmg.fantasystick.shop.FantasyArmorerController(this,armorers,armorShopMenus::open);
|
||||
rareShop=new games.dmg.fantasystick.shop.RareShop(rareMerchants,sessions,rareMarket,rareInventory,nextTick,failure->getLogger().log(Level.WARNING,"Rare Items purchase failed",failure));
|
||||
rareShopMenus=new games.dmg.fantasystick.shop.RareShopMenus(this,rareShop);var localRareMenus=rareShopMenus;
|
||||
rareMarket.attachListener(catalog->{if(lifetime!=generation)return;if(rareEvents!=null)rareEvents.catalogRefreshed(catalog);try{nextTick.accept(()->{if(lifetime==generation)localRareMenus.refreshOpen();});}catch(RuntimeException failure){if(lifetime==generation)getLogger().log(Level.WARNING,"Rare Items menu refresh failed",failure);}});
|
||||
var pendingRareCatalogs=new java.util.concurrent.ConcurrentLinkedQueue<games.dmg.fantasystick.shop.RareCatalog>();
|
||||
rareMarket.attachListener(catalog->{if(lifetime!=generation)return;var events=rareEvents;
|
||||
if(events==null)pendingRareCatalogs.add(catalog);else events.catalogRefreshed(catalog);
|
||||
if(catalog.reason()!=games.dmg.fantasystick.shop.RareCatalog.RefreshReason.STARTUP)
|
||||
try{nextTick.accept(()->{if(lifetime==generation)localRareMenus.refreshOpen();});}
|
||||
catch(RuntimeException failure){if(lifetime==generation)getLogger().log(Level.WARNING,"Rare Items menu refresh failed",failure);}});
|
||||
rareMerchantController=new games.dmg.fantasystick.shop.RareMerchantController(this,rareMerchants,rareShopMenus::open);
|
||||
rarePotions=new games.dmg.fantasystick.shop.RarePotionService(rareInventory,rareSettings,characters,sessions,this::dispatchOwnedHealing,nextTick,failure->getLogger().log(Level.WARNING,"Rare potion use failed",failure));
|
||||
rareReskills=new games.dmg.fantasystick.shop.RareReskillService(rareInventory,characters,sessions,nextTick,record->{for(String ability:record.learned())abilityEngine.remove(record.id(),ability);speedI.sweep();lowLightVision.sweep();},failure->getLogger().log(Level.WARNING,"Rare Reskill use failed",failure));
|
||||
@@ -676,6 +682,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
adventureQuestController.registerResetParticipant(dungeonController);
|
||||
adventureQuestController.registerDungeonRoomPresence((dungeon,location)->location.getWorld()!=null&&dungeons.find(dungeon).filter(value->value.world().equals(location.getWorld().getUID())).isPresent()&&dungeons.roomAt(dungeon,location.getBlockX(),location.getBlockY(),location.getBlockZ()).isPresent());
|
||||
combatExperience.attachManagedOrigins(entity->adventureQuestController.encounterOrigin(entity).or(()->dungeonController.encounterOrigin(entity)));
|
||||
combatExperience.attachManagedRewardEvidence(dungeonController::killEvidence);
|
||||
combatExperience.attachManagedSettlement(dungeonController::settleKill);
|
||||
combatExperience.attachExcluded(entity->adventureQuestController.encounterOrigin(entity).isPresent());
|
||||
sessionEvents.attachManagedInteractionTarget(entity -> partyRegistrarController.tagged(entity)
|
||||
@@ -751,11 +758,60 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getServer().getVersion(), getServer().getMinecraftVersion(), getDescription().getVersion()), 1024, 134_217_728L,
|
||||
message -> getLogger().warning(message));
|
||||
eventCapture = new games.dmg.fantasystick.events.EventCaptureListener(eventLog, sessions, Clock.systemUTC());
|
||||
var completionLog = eventLog;
|
||||
var committedDungeonRuns = dungeons;
|
||||
var completionLocation = getDataFolder().toPath();
|
||||
var inFlightCompletions = new java.util.concurrent.ConcurrentHashMap<java.util.UUID,
|
||||
java.util.concurrent.CompletableFuture<games.dmg.fantasystick.events.LocalEventLog.Delivery>>();
|
||||
java.util.function.Function<games.dmg.fantasystick.events.EventInput,
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.events.LocalEventLog.Delivery>> fishingSink = input -> {
|
||||
if (!"dungeon.completed".equals(input.type())) return completionLog.emit(input);
|
||||
var result = new java.util.concurrent.CompletableFuture<games.dmg.fantasystick.events.LocalEventLog.Delivery>();
|
||||
var prior = inFlightCompletions.putIfAbsent(input.id(),result);
|
||||
if (prior != null) return prior;
|
||||
try {
|
||||
var evidence = input.data();
|
||||
var dungeon = java.util.UUID.fromString((String)evidence.get("dungeon_id"));
|
||||
var runGeneration = java.util.UUID.fromString((String)evidence.get("generation_id"));
|
||||
var boss = java.util.UUID.fromString((String)evidence.get("boss_id"));
|
||||
var exported = games.dmg.fantasystick.events.DungeonEventBridge.exported(
|
||||
completionLocation,input.id(),dungeon,runGeneration,boss);
|
||||
var delivery = exported
|
||||
? java.util.concurrent.CompletableFuture.completedFuture(
|
||||
games.dmg.fantasystick.events.LocalEventLog.Delivery.EXPORTED)
|
||||
: completionLog.emit(input);
|
||||
delivery.thenCompose(status -> {
|
||||
if (status == games.dmg.fantasystick.events.LocalEventLog.Delivery.REJECTED)
|
||||
return java.util.concurrent.CompletableFuture.completedFuture(status);
|
||||
return committedDungeonRuns.acknowledgeCompletion(dungeon,runGeneration,boss)
|
||||
.handle((ack,failure) -> {
|
||||
if (failure != null) {
|
||||
getLogger().log(Level.WARNING,"Dungeon completion publication acknowledgement failed",failure);
|
||||
return games.dmg.fantasystick.events.LocalEventLog.Delivery.REJECTED;
|
||||
}
|
||||
return status;
|
||||
});
|
||||
}).whenComplete((status,failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING,"Dungeon completion event sink failed",failure);
|
||||
result.complete(failure == null ? status
|
||||
: games.dmg.fantasystick.events.LocalEventLog.Delivery.REJECTED);
|
||||
inFlightCompletions.remove(input.id(),result);
|
||||
});
|
||||
} catch (java.io.IOException | RuntimeException invalid) {
|
||||
getLogger().log(Level.WARNING,"Dungeon completion export evidence is unavailable",invalid);
|
||||
result.complete(games.dmg.fantasystick.events.LocalEventLog.Delivery.REJECTED);
|
||||
inFlightCompletions.remove(input.id(),result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
fishingAuditEvents = new games.dmg.fantasystick.events.FishingEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
|
||||
fishingSink, Clock.systemUTC(), getDataFolder().toPath(), sessions,
|
||||
failure -> getLogger().log(Level.WARNING, "Fishing event intent failed", failure));
|
||||
adventureQuestCommands.attachFishingEvents(fishingAuditEvents);
|
||||
adventureQuestController.attachFishingEvents(fishingAuditEvents);
|
||||
var dungeonAuditEvents=new games.dmg.fantasystick.events.DungeonEventBridge(fishingAuditEvents);
|
||||
dungeonCommands.attachEvents(dungeonAuditEvents);
|
||||
dungeonController.attachEvents(dungeonAuditEvents);
|
||||
fishingController.attachFishingEvents(fishingAuditEvents);
|
||||
admin.attachFishingEvents(fishingAuditEvents);
|
||||
preferenceEvents = new games.dmg.fantasystick.events.PreferenceEventBridge(
|
||||
@@ -776,7 +832,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
localBasicAttack.attachEvents(new games.dmg.fantasystick.events.BasicAttackEventBridge(
|
||||
eventLog::emit, Clock.systemUTC()));
|
||||
downedEvents = new games.dmg.fantasystick.events.DownedEventBridge(eventLog::emit, Clock.systemUTC());
|
||||
rareEvents = new games.dmg.fantasystick.events.RareEventBridge(eventLog::emit, Clock.systemUTC());rareShop.attachEvents(rareEvents);
|
||||
rareEvents = new games.dmg.fantasystick.events.RareEventBridge(eventLog::emit, Clock.systemUTC(),
|
||||
getDataFolder().toPath(), failure->getLogger().log(Level.WARNING,"Rare event intent failed",failure));
|
||||
rareShop.attachEvents(rareEvents);rarePotions.attachEvents(rareEvents);rareReskills.attachEvents(rareEvents);admin.attachRareEvents(rareEvents);
|
||||
rareEvents.replay();
|
||||
games.dmg.fantasystick.shop.RareCatalog committedRareCatalog;
|
||||
while((committedRareCatalog=pendingRareCatalogs.poll())!=null)
|
||||
rareEvents.catalogRefreshed(committedRareCatalog);
|
||||
bookEvents = new games.dmg.fantasystick.events.BookEventBridge(eventLog::emit, Clock.systemUTC());books.attachEvents(bookEvents);
|
||||
progressionEvents = new games.dmg.fantasystick.events.ProgressionEventBridge(eventLog::emit, Clock.systemUTC());books.attachProgressionEvents(progressionEvents);
|
||||
abilityEvents = new games.dmg.fantasystick.events.AbilityEventBridge(eventLog::emit, Clock.systemUTC());books.attachAbilityEvents(abilityEvents);
|
||||
@@ -1026,6 +1088,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
fishingAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous fishing event intent drain failed", failure);
|
||||
if (replayFishingEvents != null) replayFishingEvents.replay();
|
||||
if (dungeonController != null) dungeonController.replayCompletions();
|
||||
});
|
||||
recipeEvents.replay(); // Serial worker replays old intents before new discoveries are captured.
|
||||
}
|
||||
@@ -1066,6 +1129,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingEvents = sessionEvents; sessionEvents = null;
|
||||
var closingEventLog = eventLog; eventLog = null; preferenceEvents = null;
|
||||
var closingFishingAuditEvents = fishingAuditEvents; fishingAuditEvents = null;
|
||||
var closingRareEvents = rareEvents; rareEvents = null;
|
||||
var closingNutritionAuditEvents = nutritionAuditEvents; nutritionAuditEvents = null;
|
||||
var closingSwitchAuditEvents = switchAuditEvents; switchAuditEvents = null;
|
||||
var closingSwitchConfigEvents = switchConfigEvents; switchConfigEvents = null;
|
||||
@@ -1078,7 +1142,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingAdminRevivalAuditEvents = adminRevivalEvents; adminRevivalEvents = null;
|
||||
var closingEquipmentAuditEvents = equipmentAuditEvents; equipmentAuditEvents = null;
|
||||
var closingSessionRecoveryAuditEvents = sessionRecoveryAuditEvents; sessionRecoveryAuditEvents = null;
|
||||
var closingEventCapture = eventCapture; eventCapture = null; var closingMessageAuditEvents = messageAuditEvents; messageAuditEvents = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; abilityEvents = null; passiveEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null;
|
||||
var closingEventCapture = eventCapture; eventCapture = null; var closingMessageAuditEvents = messageAuditEvents; messageAuditEvents = null; downedEvents = null; bookEvents = null; progressionEvents = null; abilityEvents = null; passiveEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null;
|
||||
var closingBooks = books; books = null;
|
||||
var closingPartyEvents = partyEvents; partyEvents = null;
|
||||
var closingPartyCombat = partyCombat; partyCombat = null;
|
||||
@@ -1499,6 +1563,18 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingFishingAuditEvents.drained());
|
||||
});
|
||||
if (closingRareEvents != null) cleanup("rare event intent drain", () -> {
|
||||
var marketDrained = closingRareMarket == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingRareMarket.drained();
|
||||
var inventoryDrained = closingRareInventory == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingRareInventory.drained();
|
||||
rareAuditDrain = java.util.concurrent.CompletableFuture.allOf(marketDrained.toCompletableFuture(),
|
||||
inventoryDrained.toCompletableFuture(), configurationDrain.toCompletableFuture())
|
||||
.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Rare owner drain failed", failure);
|
||||
closingRareEvents.close();return null;
|
||||
}).thenCompose(ignored -> closingRareEvents.drained());
|
||||
});
|
||||
if (closingEventLog != null) cleanup("local events", closingEventLog::close);
|
||||
if (closingVitals != null) cleanup("native health hooks", closingVitals::close);
|
||||
unregister(closingModes);
|
||||
|
||||
@@ -67,6 +67,9 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
|
||||
fishingEvents.trophyEarned(trophy.earned());
|
||||
});
|
||||
}
|
||||
public boolean earnedTrophy(UUID id){return fishing.trophy(id)
|
||||
.map(value->value.phase()==FishingState.TrophyPhase.EARNED)
|
||||
.orElseGet(()->fishing.snapshot().trophyReceipts().contains(id));}
|
||||
public void open(Player player){open(player,0,null);}
|
||||
public void open(Player player,AdventureQuestState.Giver giver){open(player,0,giver);}
|
||||
private void open(Player player,int requestedPage,AdventureQuestState.Giver giver){
|
||||
@@ -265,8 +268,7 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
|
||||
player.sendMessage("Trophy redemption failed; the earned trophy is retained.");return;}
|
||||
var status=receipt==null?null:receipt.status();
|
||||
if(status==FishingDirectory.TrophyRedeemStatus.APPLIED){
|
||||
if(captured!=null&&(captured.type()==FishingState.TrophyType.CHAMPION
|
||||
||captured.type()==FishingState.TrophyType.CAMP)&&fishingEvents!=null){
|
||||
if(captured!=null&&fishingEvents!=null){
|
||||
var data=new LinkedHashMap<String,Object>();data.put("receipt_id",captured.id().toString());
|
||||
data.put("trophy_type",captured.type().name().toLowerCase(java.util.Locale.ROOT)+"_trophy");
|
||||
data.put("trophy_name",captured.name());
|
||||
@@ -274,6 +276,10 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
|
||||
data.put("final_enemy_id",captured.audit().getOrDefault("final_enemy_id","unavailable_legacy_capture"));
|
||||
data.put("clear_location",games.dmg.fantasystick.events.FishingEventBridge.savedDefeatLocation(captured.audit()));
|
||||
data.put("arena",games.dmg.fantasystick.events.FishingEventBridge.savedArena(captured.audit()));
|
||||
}else if(captured.type()==FishingState.TrophyType.DUNGEON){
|
||||
data.put("boss_id",captured.audit().getOrDefault("boss_id","unavailable_legacy_capture"));
|
||||
data.put("room_id",captured.audit().getOrDefault("room_id","unavailable_legacy_capture"));
|
||||
data.put("boss_death_location",games.dmg.fantasystick.events.FishingEventBridge.savedDefeatLocation(captured.audit()));
|
||||
}
|
||||
data.put("source_name",captured.origin());data.put("carrier_character_id",captured.carrier().toString());
|
||||
data.put("catalog_id",captured.catalog().toString());data.put("site_id",captured.site().toString());
|
||||
|
||||
@@ -17,9 +17,10 @@ import org.bukkit.potion.PotionEffectType;
|
||||
/** Registered command adapter for durable dungeon authoring and catalog administration. */
|
||||
public final class DungeonCommands {
|
||||
private static final String PERMISSION="fantasystick.admin";
|
||||
private final Plugin plugin;private final DungeonDirectory directory;private final DungeonController controller;private final games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures;private final Consumer<Runnable> nextTick;private final games.dmg.fantasystick.adventure.AdventureLocationPresentation locations;private final Function<UUID,CompletableFuture<Optional<CharacterRecord>>> characters;private final Predicate<UUID> activeCharacter;private final Predicate<UUID> partyCharacter;private final CharacterSheets sheets=new CharacterSheets();private final AdventureAdminTeleport teleports;
|
||||
private final Plugin plugin;private final DungeonDirectory directory;private final DungeonController controller;private final games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures;private final Consumer<Runnable> nextTick;private final games.dmg.fantasystick.adventure.AdventureLocationPresentation locations;private final Function<UUID,CompletableFuture<Optional<CharacterRecord>>> characters;private final Predicate<UUID> activeCharacter;private final Predicate<UUID> partyCharacter;private final CharacterSheets sheets=new CharacterSheets();private final AdventureAdminTeleport teleports;private games.dmg.fantasystick.events.DungeonEventBridge auditEvents;
|
||||
public DungeonCommands(Plugin plugin,DungeonDirectory directory,DungeonController controller,games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures,Consumer<Runnable> nextTick){this(plugin,directory,controller,adventures,nextTick,id->CompletableFuture.completedFuture(Optional.empty()),id->false,id->false);}
|
||||
public DungeonCommands(Plugin plugin,DungeonDirectory directory,DungeonController controller,games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures,Consumer<Runnable> nextTick,Function<UUID,CompletableFuture<Optional<CharacterRecord>>> characters,Predicate<UUID> activeCharacter,Predicate<UUID> partyCharacter){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.controller=Objects.requireNonNull(controller);this.adventures=Objects.requireNonNull(adventures);this.nextTick=Objects.requireNonNull(nextTick);this.characters=Objects.requireNonNull(characters);this.activeCharacter=Objects.requireNonNull(activeCharacter);this.partyCharacter=Objects.requireNonNull(partyCharacter);locations=new games.dmg.fantasystick.adventure.AdventureLocationPresentation(plugin.getServer());teleports=new AdventureAdminTeleport(plugin,nextTick);}
|
||||
public void attachEvents(games.dmg.fantasystick.events.DungeonEventBridge events){this.auditEvents=Objects.requireNonNull(events);}
|
||||
public void execute(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usage(sender);return;}try{switch(args[1]){case"create"->create(sender,args);case"list"->list(sender,args);case"mode"->mode(sender,args);case"remove"->remove(sender,args);case"edit"->edit(sender,args);case"entrance"->entrance(sender,args);case"room"->room(sender,args);case"reward"->reward(sender,args);case"boss"->bossCommand(sender,args);case"teleport"->teleport(sender,args);case"enable"->enable(sender,args);case"disable"->disable(sender,args);case"open"->open(sender,args);case"close"->close(sender,args);default->usage(sender);}}catch(IllegalArgumentException invalid){sender.sendMessage("Dungeon command refused: "+invalid.getMessage()+".");}}
|
||||
private void create(CommandSender sender,String[] args){if(args.length<3||!(sender instanceof Player player)){usage(sender);return;}int nameStart=2;var mode=games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;if(args[2].equals("--mode")){if(args.length<5){usage(sender);return;}try{mode=games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){usage(sender);return;}nameStart=4;}else if(args[2].startsWith("--")){usage(sender);return;}String name=join(args,nameStart);if(name.isBlank()){usage(sender);return;}var location=player.getLocation();directory.create(name,Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch(),mode).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Created disabled dungeon "+value.id()+" ("+value.name()+"); mode="+value.participationMode().value()+".":"Dungeon creation failed; desired state is unchanged.")));}
|
||||
private void list(CommandSender sender,String[] args){if(args.length!=2){usage(sender);return;}if(directory.snapshot().dungeons().isEmpty()){sender.sendMessage("No dungeons registered.");return;}directory.snapshot().dungeons().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(value->sender.sendMessage(value.id()+" "+value.name()+" at "+locations.format(value.world(),value.entrance().x(),value.entrance().y(),value.entrance().z())+"; "+(value.enabled()?"enabled":"disabled")+"; mode="+value.participationMode().value()+"; rooms="+value.rooms().size()+"; completion="+(value.rewardConfigured()?value.completionReward().xp()+" XP, "+value.completionReward().coins()+" coins":"not configured")+"."));}
|
||||
@@ -33,7 +34,7 @@ public final class DungeonCommands {
|
||||
private static String roomFailure(Throwable failure){Throwable cause=failure;while((cause instanceof java.util.concurrent.CompletionException||cause instanceof java.util.concurrent.ExecutionException)&&cause.getCause()!=null)cause=cause.getCause();String detail=cause.getMessage();if(cause instanceof IllegalArgumentException&&"Rooms overlap".equals(detail))return "Room creation refused: it overlaps an existing room.";if(cause instanceof IllegalArgumentException&&detail!=null&&detail.startsWith("Room exceeds configured link distance of ")){String blocks=detail.substring("Room exceeds configured link distance of ".length()).replace(" blocks","");return "Room creation refused: nearest room is farther than the configured "+blocks+"-block link distance.";}if(cause instanceof IllegalArgumentException&&(detail!=null&&(detail.equals("Room dimensions out of range")||detail.equals("Room limit reached"))))return "Room creation refused: dimensions or room limit are out of range.";if(cause instanceof java.io.IOException)return "Room creation failed: persistence is unavailable; desired state is unchanged.";return "Room creation refused: geometry validation failed; desired state is unchanged.";}
|
||||
private void mob(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length>=5&&args[3].equals("add")){var type=entity(args[4]);if(!ordinary(type)){sender.sendMessage("Unsupported living mob type.");return;}var location=player.getLocation();directory.addMob(dungeon,room.id(),type,join(args,5),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->saved(sender,failure,"Added mob slot "+(value==null?"":value.id())+"."));return;}if(args.length==5&&args[3].equals("remove")){UUID id=id(args[4]);if(id==null){usage(sender);return;}directory.removeMob(dungeon,room.id(),id).whenComplete((value,failure)->saved(sender,failure,value!=null&&value.isPresent()?"Removed mob slot "+id+".":"Mob slot not found."));return;}if(args.length==4&&args[3].equals("list")){if(room.mobs().isEmpty())sender.sendMessage("No ordinary mob slots in this room.");else room.mobs().forEach(value->sender.sendMessage(value.id()+" "+value.type().name().toLowerCase(Locale.ROOT)+" "+value.name()));return;}usage(sender);}
|
||||
private void effect(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length>=7&&args[3].equals("add")){DungeonState.EffectTarget target;try{target=DungeonState.EffectTarget.valueOf(args[4].toUpperCase(Locale.ROOT));}catch(IllegalArgumentException invalid){usage(sender);return;}String key=args[5].contains(":")?args[5]:"minecraft:"+args[5];var namespaced=NamespacedKey.fromString(key);Integer level=integer(args[6]);boolean particles=args.length<8||Boolean.parseBoolean(args[7]);if(namespaced==null||PotionEffectType.getByKey(namespaced)==null||level==null||args.length>8||args.length==8&&!Set.of("true","false").contains(args[7])){usage(sender);return;}directory.addEffect(dungeon,room.id(),target,key,level,particles).whenComplete((value,failure)->saved(sender,failure,"Added room effect "+(value==null?"":value.id())+"."));return;}if(args.length==5&&args[3].equals("remove")){UUID id=id(args[4]);if(id==null){usage(sender);return;}directory.removeEffect(dungeon,room.id(),id).whenComplete((value,failure)->saved(sender,failure,value!=null&&value.isPresent()?"Removed room effect "+id+".":"Room effect not found."));return;}usage(sender);}
|
||||
private void reward(CommandSender sender,String[] args){if(args.length<3||args.length>5){usage(sender);return;}UUID dungeon=id(args[2]);var current=dungeon==null?null:directory.find(dungeon).orElse(null);if(current==null){sender.sendMessage("Dungeon not found.");return;}if(args.length==3){sender.sendMessage(current.rewardConfigured()?"Dungeon "+dungeon+" completion reward is "+current.completionReward().xp()+" XP and "+current.completionReward().coins()+" coins.":"Dungeon "+dungeon+" completion reward is not configured.");return;}if(current.enabled()||adventures.offerOpen(dungeon)||adventures.active().stream().anyMatch(quest->quest.site().equals(dungeon))){sender.sendMessage("Dungeon reward update requires disabled, closed dungeon state without accepted quests.");return;}if(controller.beingEdited(dungeon)&&(!(sender instanceof Player player)||!controller.editing(player.getUniqueId()).filter(dungeon::equals).isPresent())){sender.sendMessage("Dungeon reward update refused: another administrator owns the editing lease.");return;}BigInteger xp=args.length==5?whole(args[3]):null,coins=args.length==5?whole(args[4]):null;if(xp==null||coins==null){usage(sender);return;}directory.setCompletionReward(dungeon,xp,coins).whenComplete((value,failure)->saved(sender,failure,"Set dungeon completion reward to "+xp+" XP and "+coins+" coins."));}
|
||||
private void reward(CommandSender sender,String[] args){if(args.length<3||args.length>5){usage(sender);return;}UUID dungeon=id(args[2]);var current=dungeon==null?null:directory.find(dungeon).orElse(null);if(current==null){sender.sendMessage("Dungeon not found.");return;}if(args.length==3){sender.sendMessage(current.rewardConfigured()?"Dungeon "+dungeon+" completion reward is "+current.completionReward().xp()+" XP and "+current.completionReward().coins()+" coins.":"Dungeon "+dungeon+" completion reward is not configured.");return;}if(current.enabled()||adventures.offerOpen(dungeon)||adventures.active().stream().anyMatch(quest->quest.site().equals(dungeon))){sender.sendMessage("Dungeon reward update requires disabled, closed dungeon state without accepted quests.");return;}if(controller.beingEdited(dungeon)&&(!(sender instanceof Player player)||!controller.editing(player.getUniqueId()).filter(dungeon::equals).isPresent())){sender.sendMessage("Dungeon reward update refused: another administrator owns the editing lease.");return;}BigInteger xp=args.length==5?whole(args[3]):null,coins=args.length==5?whole(args[4]):null;if(xp==null||coins==null){usage(sender);return;}var audit=auditEvents;var actor=audit==null?null:audit.actor(sender);var operation=UUID.randomUUID();directory.setCompletionRewardWithEvidence(dungeon,xp,coins).whenComplete((change,failure)->{if(failure==null&&audit!=null)try{audit.rewardChanged(operation,actor,change);}catch(RuntimeException ignored){}saved(sender,failure,"Set dungeon completion reward to "+xp+" XP and "+coins+" coins.");});}
|
||||
private void boss(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length==5&&args[3].equals("assign-sheet")){assignCharacterBoss(sender,player,dungeon,room,args[4]);return;}if(args.length>=5&&args[3].equals("mob")){var type=entity(args[4]);if(!ordinary(type)){sender.sendMessage("Unsupported living boss type.");return;}var location=player.getLocation();directory.setBoss(dungeon,room.id(),type,join(args,5),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->saved(sender,failure,"Configured native boss "+type.name().toLowerCase(Locale.ROOT)+"."));return;}if(args.length==4&&args[3].equals("clear")){directory.clearBoss(dungeon,room.id()).whenComplete((value,failure)->saved(sender,failure,"Cleared the room boss."));return;}usage(sender);}
|
||||
private void assignCharacterBoss(CommandSender sender,Player player,UUID dungeon,DungeonState.Room room,String rawMultiplier){BigDecimal multiplier;try{if(!rawMultiplier.matches("(?:0|[1-9][0-9]*)(?:\\.[0-9]{1,3})?"))throw new NumberFormatException();multiplier=new BigDecimal(rawMultiplier);}catch(NumberFormatException invalid){usage(sender);return;}var held=player.getInventory().getItemInMainHand();var sheetIdentity=sheets.identity(held).orElse(null);var character=sheetIdentity==null?null:sheetIdentity.character();if(character==null||held.getAmount()!=1){sender.sendMessage("Hold exactly one authentic living character sheet in your main hand.");return;}if(physicalCopies(character)!=1){sender.sendMessage("Character boss assignment requires one unambiguous physical sheet.");return;}if(activeCharacter.test(character)){sender.sendMessage("That character has an active equipment session.");return;}if(partyCharacter.test(character)){sender.sendMessage("That character must leave its party before boss assignment.");return;}characters.apply(character).whenComplete((loaded,failure)->{if(failure!=null||loaded==null||loaded.isEmpty()||loaded.orElseThrow().dead()||loaded.orElseThrow().health().signum()<=0){respond(sender,()->sender.sendMessage("Character boss assignment refused: the saved living character could not be verified."));return;}var expected=loaded.orElseThrow();if(!sheetIdentity.generation().equals(expected.sheetGeneration())||(sheetIdentity.legacy()&&expected.sheetGeneration().signum()!=0)){respond(sender,()->sender.sendMessage("Character boss assignment refused: that sheet generation is no longer authorized."));return;}directory.assignCharacterBoss(dungeon,room.id(),expected,multiplier).whenComplete((custody,saveFailure)->{if(saveFailure!=null){respond(sender,()->sender.sendMessage("Character boss assignment failed before sheet custody changed."));return;}characters.apply(character).whenComplete((fresh,readFailure)->respond(sender,()->{if(readFailure!=null||fresh==null||fresh.isEmpty()||!fresh.orElseThrow().equals(expected)||activeCharacter.test(character)||partyCharacter.test(character)){directory.cancelAssignment(dungeon,custody.operation()).whenComplete((ignored,rollbackFailure)->respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the saved character changed.":"Character boss custody was saved but assignment rollback could not be confirmed; recover it before editing.")));return;}var current=player.getInventory().getItemInMainHand();if(!player.isOnline()||physicalCopies(character)!=1||current.getAmount()!=1||!sheets.authorizes(current,expected)){directory.cancelAssignment(dungeon,custody.operation()).whenComplete((ignored,rollbackFailure)->respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the held sheet changed.":"Character boss custody was saved but sheet consumption could not be confirmed; recover it before editing.")));return;}player.getInventory().setItemInMainHand(null);sender.sendMessage("Assigned character "+custody.character().name()+" as the dungeon boss at "+custody.multiplier().toPlainString()+"x stats; its sheet is now in durable custody.");}));});});}
|
||||
private void bossCommand(CommandSender sender,String[] args){if(args.length!=4||!args[2].equals("recover-sheet")||!(sender instanceof Player player)){usage(sender);return;}UUID dungeon=id(args[3]);if(dungeon==null){usage(sender);return;}recoverCharacterBoss(sender,player,dungeon);}
|
||||
|
||||
@@ -32,22 +32,40 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
|
||||
private record Edit(UUID dungeon,Map<UUID,Color> colors){}
|
||||
private static final String ENEMY="dungeon_enemy",PROJECTILE="dungeon_projectile";
|
||||
private record AttemptKey(UUID dungeon,UUID room){}
|
||||
private record PendingKill(UUID dungeon,UUID room,UUID generation,UUID slot){}
|
||||
private static final class Attempt {final UUID dungeon,room,generation;final Map<UUID,UUID> entities=new HashMap<>();final Set<UUID> represented=new HashSet<>();final Map<UUID,Double> threat=new HashMap<>();final Map<String,Long> cooldowns=new HashMap<>();final Map<String,Long> effects=new HashMap<>();UUID boss,target;long lastCombat=System.nanoTime();Attempt(UUID dungeon,UUID room,UUID generation){this.dungeon=dungeon;this.room=room;this.generation=generation;}}
|
||||
private record PendingKill(UUID entity,UUID dungeon,UUID room,UUID generation,UUID slot){}
|
||||
private static final class Attempt {final UUID dungeon,room,generation;final Map<UUID,UUID> entities=new HashMap<>();final Set<UUID> represented=new HashSet<>();final Map<UUID,Double> threat=new HashMap<>();final Map<String,Long> cooldowns=new HashMap<>();final Map<String,Long> effects=new HashMap<>();UUID boss,target,defeatedBoss;Map<String,String> defeatAudit=Map.of();long lastCombat=System.nanoTime();Attempt(UUID dungeon,UUID room,UUID generation){this.dungeon=dungeon;this.room=room;this.generation=generation;}}
|
||||
private record Context(UUID character,UUID party,Set<UUID> members){}
|
||||
private record OwnedEffect(UUID dungeon,UUID player,PotionEffectType type,int amplifier,PotionEffect prior){}
|
||||
private final Plugin plugin;private final DungeonDirectory dungeons;private final AdventureQuestDirectory adventures;private final EquipmentSessions sessions;private final PartyRoster parties;private final CharacterStorage characters;private final ProgressionRules progression;private final Consumer<Runnable> nextTick;private final java.util.random.RandomGenerator random;private final AbilityRegistry abilities;private final PassiveRegistry passives;private final java.util.function.Supplier<games.dmg.fantasystick.session.OutOfCombatRecovery.Settings> recoverySettings;private final java.util.function.Function<games.dmg.fantasystick.persistence.CharacterRecord,BigInteger> recoveryBonus;private final java.util.function.Supplier<BigInteger> healthStealHealing;private final games.dmg.fantasystick.presentation.ParticleDelivery particles;private FishingController fishing;
|
||||
private final Plugin plugin;private final DungeonDirectory dungeons;private final AdventureQuestDirectory adventures;private final EquipmentSessions sessions;private final PartyRoster parties;private final CharacterStorage characters;private final ProgressionRules progression;private final Consumer<Runnable> nextTick;private final java.util.random.RandomGenerator random;private final AbilityRegistry abilities;private final PassiveRegistry passives;private final java.util.function.Supplier<games.dmg.fantasystick.session.OutOfCombatRecovery.Settings> recoverySettings;private final java.util.function.Function<games.dmg.fantasystick.persistence.CharacterRecord,BigInteger> recoveryBonus;private final java.util.function.Supplier<BigInteger> healthStealHealing;private final games.dmg.fantasystick.presentation.ParticleDelivery particles;private FishingController fishing;private games.dmg.fantasystick.events.DungeonEventBridge auditEvents;
|
||||
private final NamespacedKey typeKey,dungeonKey,roomKey,slotKey,generationKey,bossKey;
|
||||
private final Map<UUID,Edit> playerEdits=new HashMap<>();private final Map<UUID,UUID> dungeonEditors=new HashMap<>();private final Map<AttemptKey,Attempt> attempts=new HashMap<>();private final Set<String> startingRuns=new HashSet<>();private final Set<AttemptKey> completing=new HashSet<>();private final Set<UUID> defeatingDungeons=new HashSet<>();private final Set<CompletableFuture<?>> inFlight=ConcurrentHashMap.newKeySet();private final Map<String,OwnedEffect> ownedEffects=new HashMap<>();private final Map<UUID,PendingKill> pendingKills=new ConcurrentHashMap<>();private final Set<Entity> relocating=Collections.newSetFromMap(new IdentityHashMap<>());private org.bukkit.scheduler.BukkitTask pulse;private boolean closed;private long ticks=9;
|
||||
private final Map<UUID,Edit> playerEdits=new HashMap<>();private final Map<UUID,UUID> dungeonEditors=new HashMap<>();private final Map<AttemptKey,Attempt> attempts=new HashMap<>();private final Set<String> startingRuns=new HashSet<>();private final Set<AttemptKey> completing=new HashSet<>();private final Set<UUID> defeatingDungeons=new HashSet<>();private final Set<CompletableFuture<?>> inFlight=ConcurrentHashMap.newKeySet();private final Map<String,OwnedEffect> ownedEffects=new HashMap<>();private final Map<UUID,PendingKill> pendingKills=new ConcurrentHashMap<>();private final Map<UUID,UUID> pendingBosses=new ConcurrentHashMap<>();private final Set<Entity> relocating=Collections.newSetFromMap(new IdentityHashMap<>());private org.bukkit.scheduler.BukkitTask pulse;private boolean closed;private long ticks=9;
|
||||
public DungeonController(Plugin plugin,DungeonDirectory dungeons,AdventureQuestDirectory adventures,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random){this(plugin,dungeons,adventures,sessions,parties,characters,progression,nextTick,random,new AbilityRegistry(),new PassiveRegistry(),games.dmg.fantasystick.session.OutOfCombatRecovery.Settings::defaults,ignored->BigInteger.ZERO,()->BigInteger.ONE);}
|
||||
public DungeonController(Plugin plugin,DungeonDirectory dungeons,AdventureQuestDirectory adventures,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random,AbilityRegistry abilities,PassiveRegistry passives){this(plugin,dungeons,adventures,sessions,parties,characters,progression,nextTick,random,abilities,passives,games.dmg.fantasystick.session.OutOfCombatRecovery.Settings::defaults,ignored->BigInteger.ZERO,()->BigInteger.ONE);}
|
||||
public DungeonController(Plugin plugin,DungeonDirectory dungeons,AdventureQuestDirectory adventures,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random,AbilityRegistry abilities,PassiveRegistry passives,java.util.function.Supplier<games.dmg.fantasystick.session.OutOfCombatRecovery.Settings> recoverySettings,java.util.function.Function<games.dmg.fantasystick.persistence.CharacterRecord,BigInteger> recoveryBonus,java.util.function.Supplier<BigInteger> healthStealHealing){this(plugin,dungeons,adventures,sessions,parties,characters,progression,nextTick,random,abilities,passives,recoverySettings,recoveryBonus,healthStealHealing,games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());}
|
||||
public DungeonController(Plugin plugin,DungeonDirectory dungeons,AdventureQuestDirectory adventures,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random,AbilityRegistry abilities,PassiveRegistry passives,java.util.function.Supplier<games.dmg.fantasystick.session.OutOfCombatRecovery.Settings> recoverySettings,java.util.function.Function<games.dmg.fantasystick.persistence.CharacterRecord,BigInteger> recoveryBonus,java.util.function.Supplier<BigInteger> healthStealHealing,games.dmg.fantasystick.presentation.ParticleDelivery particles){this.plugin=Objects.requireNonNull(plugin);this.dungeons=Objects.requireNonNull(dungeons);this.adventures=Objects.requireNonNull(adventures);this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.characters=Objects.requireNonNull(characters);this.progression=Objects.requireNonNull(progression);this.nextTick=Objects.requireNonNull(nextTick);this.random=Objects.requireNonNull(random);this.abilities=Objects.requireNonNull(abilities);this.passives=Objects.requireNonNull(passives);this.recoverySettings=Objects.requireNonNull(recoverySettings);this.recoveryBonus=Objects.requireNonNull(recoveryBonus);this.healthStealHealing=Objects.requireNonNull(healthStealHealing);this.particles=Objects.requireNonNull(particles);typeKey=new NamespacedKey(plugin,"managed_npc_type");dungeonKey=new NamespacedKey(plugin,"dungeon_id");roomKey=new NamespacedKey(plugin,"dungeon_room_id");slotKey=new NamespacedKey(plugin,"dungeon_spawn_id");generationKey=new NamespacedKey(plugin,"dungeon_generation_id");bossKey=new NamespacedKey(plugin,"dungeon_boss");}
|
||||
public void attachEvents(games.dmg.fantasystick.events.DungeonEventBridge events){this.auditEvents=Objects.requireNonNull(events);}
|
||||
public void registerFishing(FishingController fishing){if(closed||this.fishing!=null)throw new IllegalStateException("Fishing already attached or dungeon controller closed");this.fishing=Objects.requireNonNull(fishing);}
|
||||
public void install(){synchronizeEnabled();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(managed(entity))entity.remove();pulse=plugin.getServer().getScheduler().runTaskTimer(plugin,this::tick,1,1);}
|
||||
public boolean managed(Entity entity){var data=entity.getPersistentDataContainer();if(data==null)return false;var type=data.get(typeKey,PersistentDataType.STRING);return ENEMY.equals(type)||PROJECTILE.equals(type);}
|
||||
public Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin> encounterOrigin(Entity entity){if(!managedEnemy(entity))return Optional.empty();UUID dungeon=id(entity,dungeonKey),room=id(entity,roomKey),generation=id(entity,generationKey);if(dungeon==null||room==null||generation==null)return Optional.empty();var attempt=attempts.get(new AttemptKey(dungeon,room));if(attempt!=null&&!attempt.generation.equals(generation))return Optional.empty();UUID slot=id(entity,slotKey);if(slot==null&&(!Boolean.TRUE.equals(entity.getPersistentDataContainer().get(bossKey,PersistentDataType.BOOLEAN))||dungeons.room(dungeon,room).map(value->value.boss()==null).orElse(true)))return Optional.empty();if(slot!=null&&(dungeons.room(dungeon,room).stream().flatMap(value->value.mobs().stream()).noneMatch(value->value.id().equals(slot))||dungeons.runView(dungeon,generation).map(run->run.defeatedSlots().contains(slot)||run.defeated()).orElse(true)))return Optional.empty();String encounter=slot==null?"boss":slot.toString();UUID source=UUID.nameUUIDFromBytes((dungeon+":"+room+":"+generation+":"+encounter).getBytes(java.nio.charset.StandardCharsets.UTF_8));if(slot!=null&&attempt==null&&!pendingKills.containsKey(source))return Optional.empty();return Optional.of(new games.dmg.fantasystick.progression.ManagedEncounterOrigin(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,source));}
|
||||
public void settleKill(games.dmg.fantasystick.progression.ManagedEncounterOrigin origin){if(origin.kind()!=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON)return;var pending=pendingKills.remove(origin.source());if(pending==null)return;dungeons.defeatSlot(pending.dungeon(),pending.generation(),pending.slot()).whenComplete((run,failure)->schedule(()->{if(failure!=null)return;var room=dungeons.room(pending.dungeon(),pending.room()).orElse(null);var attempt=attempts.get(new AttemptKey(pending.dungeon(),pending.room()));var dungeon=dungeons.find(pending.dungeon()).orElse(null);if(room!=null&&attempt!=null&&dungeon!=null&&room.boss()==null&&room.mobs().stream().allMatch(value->run.defeatedSlots().contains(value.id())))clearRoom(dungeon,room,attempt,false);}));}
|
||||
public Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin> encounterOrigin(Entity entity){if(!managedEnemy(entity))return Optional.empty();UUID dungeon=id(entity,dungeonKey),room=id(entity,roomKey),generation=id(entity,generationKey);if(dungeon==null||room==null||generation==null)return Optional.empty();var attempt=attempts.get(new AttemptKey(dungeon,room));if(attempt!=null&&!attempt.generation.equals(generation))return Optional.empty();UUID slot=id(entity,slotKey);if(slot==null&&(!Boolean.TRUE.equals(entity.getPersistentDataContainer().get(bossKey,PersistentDataType.BOOLEAN))||dungeons.room(dungeon,room).map(value->value.boss()==null).orElse(true)))return Optional.empty();if(slot!=null&&(dungeons.room(dungeon,room).stream().flatMap(value->value.mobs().stream()).noneMatch(value->value.id().equals(slot))||dungeons.runView(dungeon,generation).map(run->run.defeatedSlots().contains(slot)||run.defeated()).orElse(true)))return Optional.empty();String encounter=slot==null?"boss":slot.toString();UUID source=UUID.nameUUIDFromBytes((dungeon+":"+room+":"+generation+":"+encounter).getBytes(java.nio.charset.StandardCharsets.UTF_8));if(slot!=null){var pending=pendingKills.get(source);if(!slot.equals(attempt==null?null:attempt.entities.get(entity.getUniqueId()))&&(pending==null||!entity.getUniqueId().equals(pending.entity())))return Optional.empty();}else if(!entity.getUniqueId().equals(attempt==null?null:attempt.boss)&&!entity.getUniqueId().equals(pendingBosses.get(source)))return Optional.empty();return Optional.of(new games.dmg.fantasystick.progression.ManagedEncounterOrigin(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,source));}
|
||||
public Map<String,Object> killEvidence(LivingEntity entity){
|
||||
var origin=encounterOrigin(entity).orElse(null);
|
||||
if(origin==null)return Map.of();
|
||||
UUID dungeon=id(entity,dungeonKey),room=id(entity,roomKey),generation=id(entity,generationKey);
|
||||
if(dungeon==null||room==null||generation==null)return Map.of();
|
||||
var definition=dungeons.find(dungeon).orElse(null);
|
||||
var roomDefinition=dungeons.room(dungeon,room).orElse(null);
|
||||
if(definition==null||roomDefinition==null)return Map.of();
|
||||
var slot=id(entity,slotKey);
|
||||
var result=new LinkedHashMap<String,Object>();
|
||||
result.put("managed_source_id",origin.source().toString());
|
||||
result.put("dungeon_id",dungeon.toString());result.put("dungeon_name",definition.name());
|
||||
result.put("room_id",room.toString());result.put("room_name",roomDefinition.displayName());
|
||||
result.put("generation_id",generation.toString());
|
||||
result.put("spawn_slot_or_boss",slot==null?"boss":slot.toString());
|
||||
return Map.copyOf(result);
|
||||
}
|
||||
public void settleKill(games.dmg.fantasystick.progression.ManagedEncounterOrigin origin){if(origin.kind()!=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON)return;pendingBosses.remove(origin.source());var pending=pendingKills.remove(origin.source());if(pending==null)return;dungeons.defeatSlot(pending.dungeon(),pending.generation(),pending.slot()).whenComplete((run,failure)->schedule(()->{if(failure!=null)return;var room=dungeons.room(pending.dungeon(),pending.room()).orElse(null);var attempt=attempts.get(new AttemptKey(pending.dungeon(),pending.room()));var dungeon=dungeons.find(pending.dungeon()).orElse(null);if(room!=null&&attempt!=null&&dungeon!=null&&room.boss()==null&&room.mobs().stream().allMatch(value->run.defeatedSlots().contains(value.id())))clearRoom(dungeon,room,attempt,false);}));}
|
||||
private void synchronizeEnabled(){for(var site:adventures.snapshot().sites())if(site.type()==AdventureQuestState.SiteType.DUNGEON)adventures.removeDungeonSite(site.id());}
|
||||
public CompletionStage<EditStatus> start(Player player,UUID dungeonId,boolean confirm){if(closed)return CompletableFuture.completedFuture(EditStatus.BUSY);UUID playerId=player.getUniqueId();var dungeon=dungeons.find(dungeonId).orElse(null);if(dungeon==null)return CompletableFuture.completedFuture(EditStatus.NOT_FOUND);synchronized(this){if(playerEdits.containsKey(playerId)||dungeonEditors.containsKey(dungeonId))return CompletableFuture.completedFuture(EditStatus.BUSY);}var catalog=adventures.snapshot().catalog();boolean offered=catalog!=null&&adventures.offerOpen(dungeonId);boolean work=offered||adventures.active().stream().anyMatch(q->q.site().equals(dungeonId))||dungeons.snapshot().runs().stream().anyMatch(run->run.dungeon().equals(dungeonId));if(work&&!confirm)return CompletableFuture.completedFuture(EditStatus.CONFIRM_REQUIRED);CompletionStage<?> transition=settleAwards().thenCompose(ignored->dungeons.disable(dungeonId)).thenCompose(ignored->adventures.closeOffer(dungeonId)).thenCompose(ignored->adventures.removeDungeonSite(dungeonId)).thenCompose(ignored->cleanupOnMain(dungeonId));return transition.thenApply(ignored->{synchronized(this){if(closed||playerEdits.containsKey(playerId)||dungeonEditors.containsKey(dungeonId))return EditStatus.BUSY;var colors=new HashMap<UUID,Color>();for(var room:dungeons.find(dungeonId).orElseThrow().rooms())colors.put(room.id(),randomColor());playerEdits.put(playerId,new Edit(dungeonId,colors));dungeonEditors.put(dungeonId,playerId);return EditStatus.STARTED;}});}
|
||||
public synchronized boolean done(UUID player){var edit=playerEdits.remove(player);if(edit==null)return false;dungeonEditors.remove(edit.dungeon(),player);return true;}
|
||||
@@ -124,9 +142,85 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void bossAccepted(EntityDamageByEntityEvent event){if(!(event.getDamager() instanceof Mob boss)||!managedEnemy(boss)||!(event.getEntity() instanceof Player player)||event.getFinalDamage()<=0)return;var room=dungeons.room(id(boss,dungeonKey),id(boss,roomKey)).orElse(null);var custody=room==null||room.boss()==null?null:room.boss().character();if(custody==null)return;if(custody.character().passives().contains("stick_knockback")&&passives.find("stick_knockback").isPresent()){double radians=Math.toRadians(boss.getLocation().getYaw()),x=Math.sin(radians),z=-Math.cos(radians);if(Double.isFinite(x)&&Double.isFinite(z))player.knockback(1,x,z);}}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void bossKill(org.bukkit.event.entity.PlayerDeathEvent event){if(!(event.getEntity().getLastDamageCause() instanceof EntityDamageByEntityEvent damage)||!(damage.getDamager() instanceof Mob boss)||!managedEnemy(boss))return;var room=dungeons.room(id(boss,dungeonKey),id(boss,roomKey)).orElse(null);var custody=room==null||room.boss()==null?null:room.boss().character();if(custody==null||!custody.character().passives().contains("health_steal")||passives.find("health_steal").isEmpty()||eligible(event.getPlayer(),id(boss,dungeonKey),id(boss,roomKey))==null)return;healBoss(boss,Math.min(Double.MAX_VALUE,healthStealHealing.get().doubleValue()));}
|
||||
public void accept(AbilityReport report){if(report.type()==AbilityReport.Type.DAMAGE)return;var active=sessions.activeCharacter(report.sourceCharacter()).filter(sessions::current).orElse(null);if(active==null)return;var player=plugin.getServer().getPlayer(active.player());if(player==null)return;double value=report.amount().doubleValue();if(!Double.isFinite(value)||value<=0)return;for(var entry:attempts.entrySet())if(eligible(player,entry.getKey().dungeon(),entry.getKey().room())!=null)entry.getValue().threat.merge(player.getUniqueId(),value*.5d,Double::sum);}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void death(EntityDeathEvent event){if(!managedEnemy(event.getEntity()))return;UUID dungeonId=id(event.getEntity(),dungeonKey),roomId=id(event.getEntity(),roomKey),generation=id(event.getEntity(),generationKey);if(dungeonId==null||roomId==null||generation==null)return;var dungeon=dungeons.find(dungeonId).orElse(null);var room=dungeons.room(dungeonId,roomId).orElse(null);var attempt=attempts.get(new AttemptKey(dungeonId,roomId));if(dungeon==null||room==null||attempt==null)return;boolean boss=Boolean.TRUE.equals(event.getEntity().getPersistentDataContainer().get(bossKey,PersistentDataType.BOOLEAN));if(boss){attempt.boss=null;defeatingDungeons.add(dungeonId);cleanupDungeon(dungeonId);clearRoom(dungeon,room,attempt,true);return;}UUID slot=id(event.getEntity(),slotKey);attempt.entities.remove(event.getEntity().getUniqueId());if(slot==null)return;encounterOrigin(event.getEntity()).ifPresent(origin->pendingKills.putIfAbsent(origin.source(),new PendingKill(dungeonId,roomId,generation,slot)));}
|
||||
private void clearRoom(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,boolean boss){var key=new AttemptKey(dungeon.id(),room.id());if(!completing.add(key))return;var quests=attempt.represented.stream().map(adventures::findQuest).flatMap(Optional::stream).toList();var awards=quests.stream().map(quest->awardQuest(dungeon,room,attempt.generation,quest,boss)).toArray(CompletableFuture[]::new);var completion=CompletableFuture.allOf(awards).thenCompose(ignored->dungeons.markCleared(dungeon.id(),attempt.generation,room.id())).thenCompose(run->boss?dungeons.defeat(dungeon.id(),attempt.generation):CompletableFuture.completedFuture(run)).thenCompose(run->boss?adventures.expireOfferQuests(dungeon.id()).thenApply(ignored->run):CompletableFuture.completedFuture(run)).whenComplete((run,failure)->schedule(()->{completing.remove(key);if(boss)defeatingDungeons.remove(dungeon.id());if(failure!=null){plugin.getLogger().log(java.util.logging.Level.WARNING,"Dungeon room completion failed",failure);return;}cleanupAttempt(key);if(boss)cleanupDungeon(dungeon.id());}));inFlight.add(completion);completion.whenComplete((ignored,failure)->inFlight.remove(completion));}
|
||||
private CompletableFuture<Void> awardQuest(DungeonState.Dungeon dungeon,DungeonState.Room room,UUID generation,AdventureQuestState.Quest quest,boolean completeQuest){var run=dungeons.runView(dungeon.id(),generation).orElse(null);if(run==null||run.unitClaims().contains(new DungeonState.UnitClaim(room.id(),quest.id())))return CompletableFuture.completedFuture(null);var recipients=recipients(dungeon,room,quest,run);if(recipients.isEmpty())return CompletableFuture.completedFuture(null);if(!completeQuest)return dungeons.clearRoom(dungeon.id(),generation,room.id(),quest.id(),Set.copyOf(recipients)).thenApply(ignored->null);if(fishing==null)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon trophy persistence unavailable"));var unit=new RewardAllocator.Unit<UUID>(quest.unit(),recipients);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(UUID character:recipients)rewards.put(character,new FishingState.Reward(xp.get(character),coins.get(character)));UUID carrier=quest.unitType()==AdventureQuestState.UnitType.SOLO?quest.unit():recipients.stream().map(parties::partyOfCharacter).flatMap(Optional::stream).filter(value->value.id().equals(quest.unit())).map(PartyRoster.Party::leaderCharacter).findFirst().orElse(null);if(carrier==null)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon trophy carrier unavailable"));UUID trophy=UUID.nameUUIDFromBytes(("dungeon:"+quest.id()).getBytes(StandardCharsets.UTF_8));var plan=new FishingState.Trophy(trophy,FishingState.TrophyType.DUNGEON,carrier,quest.catalog(),dungeon.id(),quest.id(),generation,"Trophy of "+dungeon.name(),dungeon.name(),rewards);return fishing.earnTrophy(plan).thenCompose(earned->{if(!earned)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon trophy persistence refused"));return dungeons.clearRoom(dungeon.id(),generation,room.id(),quest.id(),Set.copyOf(recipients));}).thenApply(ignored->(Void)null).toCompletableFuture();}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void death(EntityDeathEvent event){if(!managedEnemy(event.getEntity()))return;UUID dungeonId=id(event.getEntity(),dungeonKey),roomId=id(event.getEntity(),roomKey),generation=id(event.getEntity(),generationKey);if(dungeonId==null||roomId==null||generation==null)return;var dungeon=dungeons.find(dungeonId).orElse(null);var room=dungeons.room(dungeonId,roomId).orElse(null);var attempt=attempts.get(new AttemptKey(dungeonId,roomId));if(dungeon==null||room==null||attempt==null)return;boolean boss=Boolean.TRUE.equals(event.getEntity().getPersistentDataContainer().get(bossKey,PersistentDataType.BOOLEAN));if(boss){var origin=encounterOrigin(event.getEntity());if(origin.isEmpty())return;captureBossDefeat(attempt,event.getEntity(),room);pendingBosses.putIfAbsent(origin.orElseThrow().source(),event.getEntity().getUniqueId());attempt.boss=null;defeatingDungeons.add(dungeonId);cleanupDungeon(dungeonId);clearRoom(dungeon,room,attempt,true);return;}UUID slot=id(event.getEntity(),slotKey);var origin=encounterOrigin(event.getEntity());if(slot==null||origin.isEmpty())return;attempt.entities.remove(event.getEntity().getUniqueId());pendingKills.putIfAbsent(origin.orElseThrow().source(),new PendingKill(event.getEntity().getUniqueId(),dungeonId,roomId,generation,slot));}
|
||||
private void captureBossDefeat(Attempt attempt,LivingEntity entity,DungeonState.Room room){
|
||||
if(attempt.defeatedBoss!=null)return;
|
||||
attempt.defeatedBoss=entity.getUniqueId();var location=entity.getLocation();
|
||||
var audit=new LinkedHashMap<String,String>();audit.put("boss_id",attempt.defeatedBoss.toString());
|
||||
audit.put("room_id",room.id().toString());
|
||||
audit.put("defeated_at",java.time.Instant.now().truncatedTo(java.time.temporal.ChronoUnit.SECONDS).toString());
|
||||
if(location!=null&&location.getWorld()!=null){var world=location.getWorld();
|
||||
audit.put("defeat_world_id",world.getUID().toString());
|
||||
audit.put("defeat_world_name",Objects.toString(world.getName(),"unavailable"));
|
||||
audit.put("defeat_x",Double.toString(location.getX()));
|
||||
audit.put("defeat_y",Double.toString(location.getY()));
|
||||
audit.put("defeat_z",Double.toString(location.getZ()));
|
||||
}
|
||||
attempt.defeatAudit=Map.copyOf(audit);
|
||||
}
|
||||
private void clearRoom(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,boolean boss){
|
||||
var key=new AttemptKey(dungeon.id(),room.id());if(!completing.add(key))return;
|
||||
var quests=attempt.represented.stream().map(adventures::findQuest).flatMap(Optional::stream).toList();
|
||||
var awards=quests.stream().map(quest->awardQuest(dungeon,room,attempt,quest,boss))
|
||||
.toArray(CompletableFuture[]::new);
|
||||
var completion=CompletableFuture.allOf(awards)
|
||||
.thenCompose(ignored->dungeons.markCleared(dungeon.id(),attempt.generation,room.id()))
|
||||
.thenCompose(run->boss?dungeons.defeat(dungeon.id(),attempt.generation,
|
||||
completionAudit(dungeon,attempt,quests)):CompletableFuture.completedFuture(run))
|
||||
.thenCompose(run->boss?adventures.expireOfferQuests(dungeon.id()).thenApply(ignored->run):CompletableFuture.completedFuture(run))
|
||||
.whenComplete((run,failure)->schedule(()->{
|
||||
completing.remove(key);if(boss)defeatingDungeons.remove(dungeon.id());
|
||||
if(failure!=null){plugin.getLogger().log(java.util.logging.Level.WARNING,"Dungeon room completion failed",failure);return;}
|
||||
cleanupAttempt(key);
|
||||
if(boss){cleanupDungeon(dungeon.id());
|
||||
if(auditEvents!=null)try{auditEvents.completed(run);}catch(RuntimeException error){
|
||||
plugin.getLogger().log(java.util.logging.Level.WARNING,
|
||||
"Committed dungeon completion audit publication failed",error);
|
||||
}
|
||||
}
|
||||
}));
|
||||
inFlight.add(completion);completion.whenComplete((ignored,failure)->inFlight.remove(completion));
|
||||
}
|
||||
private Map<String,String> completionAudit(DungeonState.Dungeon dungeon,Attempt attempt,
|
||||
List<AdventureQuestState.Quest> quests){
|
||||
if(attempt.defeatedBoss==null||!attempt.defeatAudit.containsKey("defeated_at"))
|
||||
throw new IllegalStateException("Native dungeon boss proof unavailable");
|
||||
var audit=new LinkedHashMap<>(attempt.defeatAudit);
|
||||
audit.put("dungeon_name",dungeon.name());
|
||||
var represented=quests.stream().filter(quest->fishing.earnedTrophy(UUID.nameUUIDFromBytes(
|
||||
("dungeon:"+quest.id()).getBytes(StandardCharsets.UTF_8))))
|
||||
.sorted(Comparator.comparing(AdventureQuestState.Quest::id)).toList();
|
||||
audit.put("represented_count",Integer.toString(represented.size()));
|
||||
for(int index=0;index<represented.size();index++){
|
||||
var quest=represented.get(index);
|
||||
var prefix="represented_"+index+"_";
|
||||
audit.put(prefix+"quest_id",quest.id().toString());
|
||||
audit.put(prefix+"unit_kind",quest.unitType().name().toLowerCase(Locale.ROOT));
|
||||
audit.put(prefix+"unit_id",quest.unit().toString());
|
||||
audit.put(prefix+"accepted_roster",quest.snapshot().stream().sorted()
|
||||
.map(UUID::toString).collect(java.util.stream.Collectors.joining(",")));
|
||||
audit.put(prefix+"receipt_id",UUID.nameUUIDFromBytes(("dungeon:"+quest.id())
|
||||
.getBytes(StandardCharsets.UTF_8)).toString());
|
||||
}
|
||||
return audit;
|
||||
}
|
||||
public void replayCompletions(){
|
||||
var events=auditEvents;if(events==null)return;
|
||||
dungeons.pendingCompletions(run->{try{return games.dmg.fantasystick.events.DungeonEventBridge.exported(
|
||||
plugin.getDataFolder().toPath(),
|
||||
games.dmg.fantasystick.events.DungeonEventBridge.completionEventId(run),
|
||||
run.dungeon(),run.generation(),UUID.fromString(run.completionAudit().get("boss_id")));}
|
||||
catch(java.io.IOException error){throw new java.io.UncheckedIOException(error);}})
|
||||
.whenComplete((pending,failure)->{
|
||||
if(failure!=null){plugin.getLogger().log(java.util.logging.Level.WARNING,
|
||||
"Committed dungeon completion export reconciliation failed",failure);return;}
|
||||
for(var run:pending)try{events.completed(run);}catch(RuntimeException error){
|
||||
plugin.getLogger().log(java.util.logging.Level.WARNING,
|
||||
"Committed dungeon completion audit replay failed",error);
|
||||
}
|
||||
});
|
||||
}
|
||||
private CompletableFuture<Void> awardQuest(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,AdventureQuestState.Quest quest,boolean completeQuest){UUID generation=attempt.generation;var run=dungeons.runView(dungeon.id(),generation).orElse(null);if(run==null||run.unitClaims().contains(new DungeonState.UnitClaim(room.id(),quest.id())))return CompletableFuture.completedFuture(null);var recipients=recipients(dungeon,room,quest,run);if(recipients.isEmpty())return CompletableFuture.completedFuture(null);if(!completeQuest)return dungeons.clearRoom(dungeon.id(),generation,room.id(),quest.id(),Set.copyOf(recipients)).thenApply(ignored->null);if(fishing==null)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon trophy persistence unavailable"));var unit=new RewardAllocator.Unit<UUID>(quest.unit(),recipients);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(UUID character:recipients)rewards.put(character,new FishingState.Reward(xp.get(character),coins.get(character)));UUID carrier=quest.unitType()==AdventureQuestState.UnitType.SOLO?quest.unit():recipients.stream().map(parties::partyOfCharacter).flatMap(Optional::stream).filter(value->value.id().equals(quest.unit())).map(PartyRoster.Party::leaderCharacter).findFirst().orElse(null);if(carrier==null)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon trophy carrier unavailable"));UUID trophy=UUID.nameUUIDFromBytes(("dungeon:"+quest.id()).getBytes(StandardCharsets.UTF_8));var audit=new LinkedHashMap<String,String>(attempt.defeatAudit);audit.put("unit_kind",quest.unitType().name().toLowerCase(Locale.ROOT));audit.put("unit_id",quest.unit().toString());audit.put("accepted_roster",quest.snapshot().stream().sorted().map(UUID::toString).collect(java.util.stream.Collectors.joining(",")));var plan=new FishingState.Trophy(trophy,FishingState.TrophyType.DUNGEON,carrier,quest.catalog(),dungeon.id(),quest.id(),generation,"Trophy of "+dungeon.name(),dungeon.name(),rewards,FishingState.TrophyPhase.PREPARED,audit);return fishing.earnTrophy(plan).thenCompose(earned->{if(!earned)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon trophy persistence refused"));return dungeons.clearRoom(dungeon.id(),generation,room.id(),quest.id(),Set.copyOf(recipients));}).thenApply(ignored->(Void)null).toCompletableFuture();}
|
||||
private List<UUID> recipients(DungeonState.Dungeon dungeon,DungeonState.Room room,AdventureQuestState.Quest quest,DungeonState.Run run){var result=new ArrayList<UUID>();for(UUID character:quest.snapshot()){if(run.characterClaims().contains(new DungeonState.CharacterClaim(room.id(),character)))continue;var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);if(active==null)continue;var player=plugin.getServer().getPlayer(active.player());if(player==null||!inside(player.getLocation(),dungeon,room))continue;if(quest.unitType()==AdventureQuestState.UnitType.PARTY){var party=parties.partyOf(player.getUniqueId()).orElse(null);if(party==null||!party.id().equals(quest.unit())||!party.characters().contains(character))continue;}result.add(character);}return result.stream().distinct().sorted().toList();}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void target(EntityTargetLivingEntityEvent event){if(managedEnemy(event.getEntity())&&event.getTarget()!=null){UUID dungeon=id(event.getEntity(),dungeonKey);UUID room=id(event.getEntity(),roomKey);var attempt=dungeon==null||room==null?null:attempts.get(new AttemptKey(dungeon,room));if(!(event.getTarget() instanceof Player player)||dungeon==null||room==null||eligible(player,dungeon,room)==null||attempt!=null&&attempt.target!=null&&!attempt.target.equals(player.getUniqueId()))event.setCancelled(true);}}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void projectile(ProjectileLaunchEvent event){if(event.getEntity().getShooter() instanceof Entity shooter&&managedEnemy(shooter)){var data=event.getEntity().getPersistentDataContainer();data.set(typeKey,PersistentDataType.STRING,PROJECTILE);copy(data,dungeonKey,shooter);copy(data,roomKey,shooter);copy(data,generationKey,shooter);}}
|
||||
|
||||
@@ -36,7 +36,9 @@ public final class DungeonDirectory implements AutoCloseable,AdventureResetParti
|
||||
public CompletableFuture<Optional<DungeonState.Spawn>> removeMob(UUID id,UUID roomId,UUID spawn){var found=room(id,roomId).flatMap(value->value.mobs().stream().filter(v->v.id().equals(spawn)).findFirst());if(found.isEmpty())return CompletableFuture.completedFuture(Optional.empty());return updateRoom(id,roomId,room->new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs().stream().filter(v->!v.id().equals(spawn)).toList(),room.effects(),room.reward(),room.boss()),found);}
|
||||
public CompletableFuture<DungeonState.Effect> addEffect(UUID id,UUID roomId,DungeonState.EffectTarget target,String effect,int level,boolean particles){var value=new DungeonState.Effect(UUID.randomUUID(),target,effect,level,particles);return updateRoom(id,roomId,room->{var effects=new ArrayList<>(room.effects());if(effects.size()>=32)throw new IllegalArgumentException("Effect limit reached");effects.add(value);return new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),effects,room.reward(),room.boss());},value);}
|
||||
public CompletableFuture<Optional<DungeonState.Effect>> removeEffect(UUID id,UUID roomId,UUID effect){var found=room(id,roomId).flatMap(value->value.effects().stream().filter(v->v.id().equals(effect)).findFirst());if(found.isEmpty())return CompletableFuture.completedFuture(Optional.empty());return updateRoom(id,roomId,room->new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects().stream().filter(v->!v.id().equals(effect)).toList(),room.reward(),room.boss()),found);}
|
||||
public CompletableFuture<DungeonState.Reward> setCompletionReward(UUID id,BigInteger xp,BigInteger coins){var value=new DungeonState.Reward(xp,coins);return mutate(state->{var dungeon=editable(state,id);var changed=new DungeonState.Dungeon(dungeon.id(),dungeon.name(),dungeon.world(),dungeon.entrance(),false,UUID.randomUUID(),dungeon.rooms(),dungeon.participationMode(),value,true);return result(replace(state,changed),value);});}
|
||||
public record CompletionRewardChange(DungeonState.Dungeon before,DungeonState.Dungeon after){}
|
||||
public CompletableFuture<DungeonState.Reward> setCompletionReward(UUID id,BigInteger xp,BigInteger coins){return setCompletionRewardWithEvidence(id,xp,coins).thenApply(change->change.after().completionReward());}
|
||||
public CompletableFuture<CompletionRewardChange> setCompletionRewardWithEvidence(UUID id,BigInteger xp,BigInteger coins){var value=new DungeonState.Reward(xp,coins);return mutate(state->{var dungeon=editable(state,id);var changed=new DungeonState.Dungeon(dungeon.id(),dungeon.name(),dungeon.world(),dungeon.entrance(),false,UUID.randomUUID(),dungeon.rooms(),dungeon.participationMode(),value,true);return result(replace(state,changed),new CompletionRewardChange(dungeon,changed));});}
|
||||
public CompletableFuture<DungeonState.Boss> setBoss(UUID id,UUID roomId,EntityType type,String name,double x,double y,double z,float yaw,float pitch){var value=new DungeonState.Boss(type,name,new DungeonState.Position(x,y,z,yaw,pitch));return updateRoom(id,roomId,room->{if(room.boss()!=null&&room.boss().character()!=null)throw new IllegalArgumentException("Recover character boss custody first");return new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects(),room.reward(),value);},value);}
|
||||
public CompletableFuture<Boolean> clearBoss(UUID id,UUID roomId){return updateRoom(id,roomId,room->{if(room.boss()!=null&&room.boss().character()!=null)throw new IllegalArgumentException("Recover character boss custody first");return new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects(),room.reward(),null);},true);}
|
||||
public CompletableFuture<DungeonState.CharacterBoss> assignCharacterBoss(UUID id,UUID roomId,CharacterRecord character,BigDecimal multiplier){Objects.requireNonNull(character);var template=new DungeonState.CharacterBoss(UUID.randomUUID(),character,multiplier,null);return mutate(state->{var dungeon=editable(state,id);if(state.dungeons().stream().flatMap(v->v.rooms().stream()).map(DungeonState.Room::boss).filter(Objects::nonNull).map(DungeonState.Boss::character).filter(Objects::nonNull).anyMatch(custody->custody.character().id().equals(character.id())))throw new IllegalArgumentException("Character already assigned");var room=dungeon.rooms().stream().filter(v->v.id().equals(roomId)).findFirst().orElseThrow(()->new IllegalArgumentException("Room not found"));if(room.boss()==null||room.boss().character()!=null)throw new IllegalArgumentException("Configure one native boss room first");var value=new DungeonState.CharacterBoss(template.operation(),character,template.multiplier(),room.boss().type(),room.boss().name(),null);var boss=new DungeonState.Boss(EntityType.VINDICATOR,character.name(),room.boss().position(),value);var updated=new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects(),room.reward(),boss);var rooms=dungeon.rooms().stream().map(v->v.id().equals(roomId)?updated:v).toList();return result(replace(state,definition(dungeon,rooms,dungeon.entrance(),false)),value);});}
|
||||
@@ -48,10 +50,15 @@ public final class DungeonDirectory implements AutoCloseable,AdventureResetParti
|
||||
public CompletableFuture<Boolean> clearRun(UUID dungeon,UUID generation){return mutate(state->{var runs=state.runs().stream().filter(v->!(v.dungeon().equals(dungeon)&&v.generation().equals(generation))).toList();if(runs.size()==state.runs().size())return result(state,false);return result(new DungeonState(state.dungeons(),runs),true);});}
|
||||
public CompletableFuture<DungeonState.Run> run(UUID dungeon,UUID generation){return mutate(state->{requireGeneration(generation);var found=state.runs().stream().filter(v->v.dungeon().equals(dungeon)&&v.generation().equals(generation)).findFirst();if(found.isPresent())return result(state,found.orElseThrow());var value=new DungeonState.Run(dungeon,generation,Set.of(),Set.of(),Set.of(),Set.of(),false);var runs=new ArrayList<>(state.runs());runs.add(value);return result(new DungeonState(state.dungeons(),runs),value);});}
|
||||
public Optional<DungeonState.Run> runView(UUID dungeon,UUID generation){return snapshot.runs().stream().filter(v->v.dungeon().equals(dungeon)&&v.generation().equals(generation)).findFirst();}
|
||||
public CompletableFuture<DungeonState.Run> defeatSlot(UUID dungeon,UUID generation,UUID slot){return updateRun(dungeon,generation,run->{var defeated=new HashSet<>(run.defeatedSlots());defeated.add(slot);return new DungeonState.Run(run.dungeon(),run.generation(),defeated,run.clearedRooms(),run.unitClaims(),run.characterClaims(),run.defeated());});}
|
||||
public CompletableFuture<DungeonState.Run> markCleared(UUID dungeon,UUID generation,UUID room){return updateRun(dungeon,generation,run->{var cleared=new HashSet<>(run.clearedRooms());cleared.add(room);return new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),cleared,run.unitClaims(),run.characterClaims(),run.defeated());});}
|
||||
public CompletableFuture<DungeonState.Run> clearRoom(UUID dungeon,UUID generation,UUID room,UUID quest,Set<UUID> characters){return updateRun(dungeon,generation,run->{var cleared=new HashSet<>(run.clearedRooms());cleared.add(room);var units=new HashSet<>(run.unitClaims());units.add(new DungeonState.UnitClaim(room,quest));var claims=new HashSet<>(run.characterClaims());characters.forEach(value->claims.add(new DungeonState.CharacterClaim(room,value)));return new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),cleared,units,claims,run.defeated());});}
|
||||
public CompletableFuture<DungeonState.Run> defeat(UUID dungeon,UUID generation){return updateRun(dungeon,generation,run->new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),run.clearedRooms(),run.unitClaims(),run.characterClaims(),true));}
|
||||
public CompletableFuture<DungeonState.Run> defeatSlot(UUID dungeon,UUID generation,UUID slot){return updateRun(dungeon,generation,run->{var defeated=new HashSet<>(run.defeatedSlots());defeated.add(slot);return new DungeonState.Run(run.dungeon(),run.generation(),defeated,run.clearedRooms(),run.unitClaims(),run.characterClaims(),run.defeated(),run.completionAudit(),run.completionPublished());});}
|
||||
public CompletableFuture<DungeonState.Run> markCleared(UUID dungeon,UUID generation,UUID room){return updateRun(dungeon,generation,run->{var cleared=new HashSet<>(run.clearedRooms());cleared.add(room);return new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),cleared,run.unitClaims(),run.characterClaims(),run.defeated(),run.completionAudit(),run.completionPublished());});}
|
||||
public CompletableFuture<DungeonState.Run> clearRoom(UUID dungeon,UUID generation,UUID room,UUID quest,Set<UUID> characters){return updateRun(dungeon,generation,run->{var cleared=new HashSet<>(run.clearedRooms());cleared.add(room);var units=new HashSet<>(run.unitClaims());units.add(new DungeonState.UnitClaim(room,quest));var claims=new HashSet<>(run.characterClaims());characters.forEach(value->claims.add(new DungeonState.CharacterClaim(room,value)));return new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),cleared,units,claims,run.defeated(),run.completionAudit(),run.completionPublished());});}
|
||||
public CompletableFuture<DungeonState.Run> defeat(UUID dungeon,UUID generation){return defeat(dungeon,generation,Map.of());}
|
||||
public CompletableFuture<DungeonState.Run> defeat(UUID dungeon,UUID generation,Map<String,String> audit){var frozen=Map.copyOf(audit);return updateRun(dungeon,generation,run->run.defeated()?run:new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),run.clearedRooms(),run.unitClaims(),run.characterClaims(),true,frozen));}
|
||||
/** Acknowledgement is serialized with the run and bound to its committed native boss. */
|
||||
public CompletableFuture<Boolean> acknowledgeCompletion(UUID dungeon,UUID generation,UUID boss){return mutate(state->{var found=state.runs().stream().filter(v->v.dungeon().equals(dungeon)&&v.generation().equals(generation)).findFirst();if(found.isEmpty())return result(state,false);var run=found.orElseThrow();if(!run.defeated()||!boss.toString().equals(run.completionAudit().get("boss_id")))return result(state,false);if(run.completionPublished())return result(state,true);var published=new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),run.clearedRooms(),run.unitClaims(),run.characterClaims(),true,run.completionAudit(),true);var runs=state.runs().stream().map(value->value==run?published:value).toList();return result(new DungeonState(state.dungeons(),runs),true);});}
|
||||
/** Reconcile prior exported proof before replaying a missing completion, on this I/O worker. */
|
||||
public CompletableFuture<List<DungeonState.Run>> pendingCompletions(java.util.function.Predicate<DungeonState.Run> exported){return mutate(state->{var runs=new ArrayList<DungeonState.Run>();var pending=new ArrayList<DungeonState.Run>();boolean changed=false;for(var run:state.runs()){if(!run.defeated()||run.completionPublished()||run.completionAudit().isEmpty()){runs.add(run);continue;}if(exported.test(run)){runs.add(new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),run.clearedRooms(),run.unitClaims(),run.characterClaims(),true,run.completionAudit(),true));changed=true;}else{runs.add(run);pending.add(run);}}return result(changed?new DungeonState(state.dungeons(),runs):state,List.copyOf(pending));});}
|
||||
@Override public CompletionStage<Void> reset(AdventureQuestState.Reset reset){currentGeneration=reset.generation();return mutate(state->result(new DungeonState(state.dungeons(),List.of()),null));}
|
||||
private <T>CompletableFuture<T> updateRoom(UUID id,UUID roomId,java.util.function.UnaryOperator<DungeonState.Room> operation,T result){return mutate(state->{var dungeon=editable(state,id);var found=dungeon.rooms().stream().filter(v->v.id().equals(roomId)).findFirst().orElseThrow(()->new IllegalArgumentException("Room not found"));var updated=operation.apply(found);var rooms=dungeon.rooms().stream().map(v->v.id().equals(roomId)?updated:v).toList();return result(replace(state,definition(dungeon,rooms,dungeon.entrance(),false)),result);});}
|
||||
private CompletableFuture<DungeonState.Run> updateRun(UUID dungeon,UUID generation,java.util.function.UnaryOperator<DungeonState.Run> operation){return mutate(state->{requireGeneration(generation);var existing=state.runs().stream().filter(v->v.dungeon().equals(dungeon)&&v.generation().equals(generation)).findFirst().orElse(new DungeonState.Run(dungeon,generation,Set.of(),Set.of(),Set.of(),Set.of(),false));var updated=operation.apply(existing);var runs=state.runs().stream().filter(v->!(v.dungeon().equals(dungeon)&&v.generation().equals(generation))).collect(java.util.stream.Collectors.toCollection(ArrayList::new));runs.add(updated);return result(new DungeonState(state.dungeons(),runs),updated);});}
|
||||
|
||||
@@ -56,8 +56,10 @@ public record DungeonState(List<Dungeon> dungeons,List<Run> runs) {
|
||||
}
|
||||
public record UnitClaim(UUID room,UUID quest) {public UnitClaim {Objects.requireNonNull(room);Objects.requireNonNull(quest);}}
|
||||
public record CharacterClaim(UUID room,UUID character) {public CharacterClaim {Objects.requireNonNull(room);Objects.requireNonNull(character);}}
|
||||
public record Run(UUID dungeon,UUID generation,Set<UUID> defeatedSlots,Set<UUID> clearedRooms,Set<UnitClaim> unitClaims,Set<CharacterClaim> characterClaims,boolean defeated) {
|
||||
public Run {Objects.requireNonNull(dungeon);Objects.requireNonNull(generation);defeatedSlots=Set.copyOf(defeatedSlots);clearedRooms=Set.copyOf(clearedRooms);unitClaims=Set.copyOf(unitClaims);characterClaims=Set.copyOf(characterClaims);}
|
||||
public record Run(UUID dungeon,UUID generation,Set<UUID> defeatedSlots,Set<UUID> clearedRooms,Set<UnitClaim> unitClaims,Set<CharacterClaim> characterClaims,boolean defeated,Map<String,String> completionAudit,boolean completionPublished) {
|
||||
public Run {Objects.requireNonNull(dungeon);Objects.requireNonNull(generation);defeatedSlots=Set.copyOf(defeatedSlots);clearedRooms=Set.copyOf(clearedRooms);unitClaims=Set.copyOf(unitClaims);characterClaims=Set.copyOf(characterClaims);completionAudit=Map.copyOf(completionAudit);if(completionPublished&&(!defeated||completionAudit.isEmpty()))throw new IllegalArgumentException("Unproven dungeon completion publication");}
|
||||
public Run(UUID dungeon,UUID generation,Set<UUID> defeatedSlots,Set<UUID> clearedRooms,Set<UnitClaim> unitClaims,Set<CharacterClaim> characterClaims,boolean defeated,Map<String,String> completionAudit){this(dungeon,generation,defeatedSlots,clearedRooms,unitClaims,characterClaims,defeated,completionAudit,false);}
|
||||
public Run(UUID dungeon,UUID generation,Set<UUID> defeatedSlots,Set<UUID> clearedRooms,Set<UnitClaim> unitClaims,Set<CharacterClaim> characterClaims,boolean defeated){this(dungeon,generation,defeatedSlots,clearedRooms,unitClaims,characterClaims,defeated,Map.of(),false);}
|
||||
}
|
||||
public DungeonState {dungeons=List.copyOf(dungeons);runs=List.copyOf(runs);unique(dungeons.stream().map(Dungeon::id).toList());var keys=runs.stream().map(v->v.dungeon()+":"+v.generation()).toList();if(new HashSet<>(keys).size()!=keys.size())throw new IllegalArgumentException("Duplicate dungeon run");}
|
||||
public static DungeonState empty(){return new DungeonState(List.of(),List.of());}
|
||||
|
||||
@@ -21,8 +21,16 @@ public final class CombatRewardEventBridge {
|
||||
public record Capture(UUID targetId,String targetType,String experienceClass,String coinClass,
|
||||
CombatRewardRules.KillPosition killPosition,ManagedEncounterOrigin managedOrigin,
|
||||
String stage,Instant defeatedAt,java.util.List<UUID> directContributors,
|
||||
String rewardUnitId,int rewardUnitCount,BigInteger experiencePool,BigInteger coinPool){
|
||||
public Capture{Objects.requireNonNull(targetId);Objects.requireNonNull(targetType);Objects.requireNonNull(experienceClass);Objects.requireNonNull(coinClass);Objects.requireNonNull(stage);Objects.requireNonNull(defeatedAt);directContributors=java.util.List.copyOf(directContributors);Objects.requireNonNull(rewardUnitId);Objects.requireNonNull(experiencePool);Objects.requireNonNull(coinPool);if(rewardUnitCount<1||experiencePool.signum()<0||coinPool.signum()<0)throw new IllegalArgumentException("Invalid combat reward capture");}
|
||||
String rewardUnitId,int rewardUnitCount,BigInteger experiencePool,BigInteger coinPool,
|
||||
Map<String,Object> managedEvidence){
|
||||
public Capture(UUID targetId,String targetType,String experienceClass,String coinClass,
|
||||
CombatRewardRules.KillPosition killPosition,ManagedEncounterOrigin managedOrigin,
|
||||
String stage,Instant defeatedAt,java.util.List<UUID> directContributors,
|
||||
String rewardUnitId,int rewardUnitCount,BigInteger experiencePool,BigInteger coinPool){
|
||||
this(targetId,targetType,experienceClass,coinClass,killPosition,managedOrigin,stage,defeatedAt,
|
||||
directContributors,rewardUnitId,rewardUnitCount,experiencePool,coinPool,Map.of());
|
||||
}
|
||||
public Capture{managedEvidence=Map.copyOf(managedEvidence);Objects.requireNonNull(targetId);Objects.requireNonNull(targetType);Objects.requireNonNull(experienceClass);Objects.requireNonNull(coinClass);Objects.requireNonNull(stage);Objects.requireNonNull(defeatedAt);directContributors=java.util.List.copyOf(directContributors);Objects.requireNonNull(rewardUnitId);Objects.requireNonNull(experiencePool);Objects.requireNonNull(coinPool);if(rewardUnitCount<1||experiencePool.signum()<0||coinPool.signum()<0)throw new IllegalArgumentException("Invalid combat reward capture");}
|
||||
}
|
||||
private final Consumer<EventInput> sink;private final Clock clock;
|
||||
public CombatRewardEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
|
||||
@@ -30,7 +38,7 @@ public final class CombatRewardEventBridge {
|
||||
CombatRewardRules.KillPosition restricted,CombatRewardRules rules,Capture capture,
|
||||
CharacterRecord before,CharacterStorage.CombatAward award,EquipmentSessions.Active active,
|
||||
Player player,String nativeSync,String feedback){
|
||||
if(award==null||award.record().isEmpty()||before==null)return;var after=award.record().orElseThrow();var data=new LinkedHashMap<String,Object>();data.put("operation_id",operation.toString());data.put("reward_receipt",operation.toString());data.put("stage",capture.stage());data.put("recipient_character_id",character.toString());data.put("target_id",capture.targetId().toString());data.put("target_type",capture.targetType());data.put("experience_class",capture.experienceClass());data.put("coin_class",capture.coinClass());data.put("defeated_at",capture.defeatedAt().toString());data.put("direct_contributor_character_ids",capture.directContributors().stream().map(UUID::toString).sorted().toList());data.put("reward_unit_id",capture.rewardUnitId());data.put("reward_unit_count",capture.rewardUnitCount());data.put("experience_pool",capture.experiencePool().toString());data.put("coin_pool",capture.coinPool().toString());if(capture.killPosition()!=null)data.put("kill_location",position(capture.killPosition()));data.put("allocated_experience",allocatedExperience.toString());data.put("allocated_coins",allocatedCoins.toString());var appliedExperience=after.experience().subtract(before.experience()).max(BigInteger.ZERO);var appliedCoins=after.coins().subtract(before.coins()).max(BigInteger.ZERO);data.put("applied_experience",appliedExperience.toString());data.put("applied_coins",appliedCoins.toString());data.put("suppressed_experience",allocatedExperience.subtract(appliedExperience.min(allocatedExperience)).toString());data.put("suppressed_coins",allocatedCoins.subtract(appliedCoins.min(allocatedCoins)).toString());data.put("before_revision",before.revision().toString());data.put("after_revision",after.revision().toString());data.put("before_level",before.level());data.put("after_level",after.level());data.put("before_experience",before.experience().toString());data.put("after_experience",after.experience().toString());data.put("before_points",before.unspentPoints().toString());data.put("after_points",after.unspentPoints().toString());data.put("balance_before",before.coins().toString());data.put("balance_after",after.coins().toString());data.put("storage_status",award.status().name().toLowerCase(java.util.Locale.ROOT));data.put("levels_gained",award.levels());data.put("native_sync",nativeSync);data.put("feedback",feedback);if(capture.managedOrigin()!=null){data.put("resolution","managed_origin_exempt");data.put("managed_origin_kind",capture.managedOrigin().kind().name().toLowerCase(java.util.Locale.ROOT));data.put("managed_origin_source",capture.managedOrigin().source().toString());data.put("spacing_applicability","authenticated_managed_origin_exempt");}else if(restricted!=null){boolean blocked=award.status()==CharacterStorage.CombatAwardStatus.SPACING_BLOCKED;data.put("resolution",blocked?"ordinary_spacing_blocked":"ordinary_spacing_eligible");data.put("spacing_applicability","ordinary_restricted_kill");data.put("spacing_minimum",rules.restrictedSpacing().toPlainString());var prior=before.restrictedKills().get(restricted.world());if(prior!=null)data.put("ordinary_history_before",position(prior));var current=after.restrictedKills().get(restricted.world());if(current!=null)data.put("ordinary_history_after",position(current));}else{data.put("resolution","spacing_not_applicable");data.put("spacing_applicability","passive_or_fantasy_character");}data.put("loss_reason",loss(award.status()));var context=context(active,player,after.name());try{sink.accept(new EventInput(id(operation),operation,clock.instant(),"combat.reward_resolved",context,Map.copyOf(data)));}catch(RuntimeException ignored){}
|
||||
if(award==null||award.record().isEmpty()||before==null)return;var after=award.record().orElseThrow();var data=new LinkedHashMap<String,Object>();data.put("operation_id",operation.toString());data.put("reward_receipt",operation.toString());data.put("stage",capture.stage());data.put("recipient_character_id",character.toString());data.put("target_id",capture.targetId().toString());data.put("target_type",capture.targetType());data.put("experience_class",capture.experienceClass());data.put("coin_class",capture.coinClass());data.put("defeated_at",capture.defeatedAt().toString());data.put("direct_contributor_character_ids",capture.directContributors().stream().map(UUID::toString).sorted().toList());data.put("reward_unit_id",capture.rewardUnitId());data.put("reward_unit_count",capture.rewardUnitCount());data.put("experience_pool",capture.experiencePool().toString());data.put("coin_pool",capture.coinPool().toString());if(capture.killPosition()!=null)data.put("kill_location",position(capture.killPosition()));data.put("allocated_experience",allocatedExperience.toString());data.put("allocated_coins",allocatedCoins.toString());var appliedExperience=after.experience().subtract(before.experience()).max(BigInteger.ZERO);var appliedCoins=after.coins().subtract(before.coins()).max(BigInteger.ZERO);data.put("applied_experience",appliedExperience.toString());data.put("applied_coins",appliedCoins.toString());data.put("suppressed_experience",allocatedExperience.subtract(appliedExperience.min(allocatedExperience)).toString());data.put("suppressed_coins",allocatedCoins.subtract(appliedCoins.min(allocatedCoins)).toString());data.put("before_revision",before.revision().toString());data.put("after_revision",after.revision().toString());data.put("before_level",before.level());data.put("after_level",after.level());data.put("before_experience",before.experience().toString());data.put("after_experience",after.experience().toString());data.put("before_points",before.unspentPoints().toString());data.put("after_points",after.unspentPoints().toString());data.put("balance_before",before.coins().toString());data.put("balance_after",after.coins().toString());data.put("storage_status",award.status().name().toLowerCase(java.util.Locale.ROOT));data.put("levels_gained",award.levels());data.put("native_sync",nativeSync);data.put("feedback",feedback);if(capture.managedOrigin()!=null){data.put("resolution","managed_origin_exempt");data.put("managed_origin_kind",capture.managedOrigin().kind().name().toLowerCase(java.util.Locale.ROOT));data.put("managed_origin_source",capture.managedOrigin().source().toString());data.put("spacing_applicability","authenticated_managed_origin_exempt");if(capture.managedOrigin().kind()==ManagedEncounterOrigin.Kind.DUNGEON&&capture.managedOrigin().source().toString().equals(capture.managedEvidence().get("managed_source_id")))data.putAll(capture.managedEvidence());}else if(restricted!=null){boolean blocked=award.status()==CharacterStorage.CombatAwardStatus.SPACING_BLOCKED;data.put("resolution",blocked?"ordinary_spacing_blocked":"ordinary_spacing_eligible");data.put("spacing_applicability","ordinary_restricted_kill");data.put("spacing_minimum",rules.restrictedSpacing().toPlainString());var prior=before.restrictedKills().get(restricted.world());if(prior!=null)data.put("ordinary_history_before",position(prior));var current=after.restrictedKills().get(restricted.world());if(current!=null)data.put("ordinary_history_after",position(current));}else{data.put("resolution","spacing_not_applicable");data.put("spacing_applicability","passive_or_fantasy_character");}data.put("loss_reason",loss(award.status()));var context=context(active,player,after.name());try{sink.accept(new EventInput(id(operation),operation,clock.instant(),"combat.reward_resolved",context,Map.copyOf(data)));}catch(RuntimeException ignored){}
|
||||
}
|
||||
private EventInput.Context context(EquipmentSessions.Active active,Player player,String characterName){if(active==null||player==null)return null;var location=player.getLocation();if(location==null||location.getWorld()==null)return new EventInput.Context(active.player(),player.getName(),active.character(),characterName,active.session(),null);return new EventInput.Context(active.player(),player.getName(),active.character(),characterName,active.session(),new EventInput.Location(location.getWorld().getUID(),location.getWorld().getName(),location.getX(),location.getY(),location.getZ(),clock.instant(),"exact"));}
|
||||
private static Map<String,Object> position(CombatRewardRules.KillPosition value){return Map.of("world_id",value.world().toString(),"x",value.x().toPlainString(),"y",value.y().toPlainString(),"z",value.z().toPlainString());}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.dungeon.DungeonDirectory;
|
||||
import games.dmg.fantasystick.dungeon.DungeonState;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Dungeon-specific evidence passed to the shared recoverable feature-event outbox. */
|
||||
public final class DungeonEventBridge {
|
||||
private final FishingEventBridge outbox;
|
||||
|
||||
public DungeonEventBridge(FishingEventBridge outbox) { this.outbox = java.util.Objects.requireNonNull(outbox); }
|
||||
|
||||
public FishingEventBridge.Actor actor(org.bukkit.command.CommandSender sender) { return outbox.actor(sender); }
|
||||
|
||||
public void rewardChanged(UUID operation, FishingEventBridge.Actor actor,
|
||||
DungeonDirectory.CompletionRewardChange change) {
|
||||
var before = change.before();
|
||||
var after = change.after();
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("actor_kind", actor == null ? "system" : actor.kind());
|
||||
if (actor == null) data.put("actor_identity_status", "unavailable");
|
||||
data.put("dungeon_id", after.id().toString());
|
||||
data.put("dungeon_name", after.name());
|
||||
data.put("before_revision", before.revision().toString());
|
||||
data.put("after_revision", after.revision().toString());
|
||||
data.put("before_configured", before.rewardConfigured());
|
||||
data.put("before_xp", before.completionReward().xp().toString());
|
||||
data.put("before_coins", before.completionReward().coins().toString());
|
||||
data.put("after_xp", after.completionReward().xp().toString());
|
||||
data.put("after_coins", after.completionReward().coins().toString());
|
||||
data.put("application_scope", "future_acceptances");
|
||||
outbox.durable("dungeon.reward_changed", operation,
|
||||
actor == null ? new FishingEventBridge.Actor("system", null) : actor, data);
|
||||
}
|
||||
|
||||
/** Only an authoritative defeated run carrying captured native boss proof can attest closure. */
|
||||
public void completed(DungeonState.Run run) {
|
||||
if (!run.defeated() || run.completionPublished() || run.completionAudit().isEmpty()) return; // Older runs have no defensible proof.
|
||||
var audit = run.completionAudit();
|
||||
var operation = completionOperation(run);
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("dungeon_id", run.dungeon().toString());
|
||||
data.put("dungeon_name", required(audit, "dungeon_name"));
|
||||
data.put("room_id", UUID.fromString(required(audit, "room_id")).toString());
|
||||
data.put("generation_id", run.generation().toString());
|
||||
data.put("boss_id", UUID.fromString(required(audit, "boss_id")).toString());
|
||||
data.put("completed_at", required(audit, "defeated_at"));
|
||||
if (audit.containsKey("defeat_world_id")) {
|
||||
var location = new LinkedHashMap<String, Object>();
|
||||
location.put("world_id", UUID.fromString(required(audit, "defeat_world_id")).toString());
|
||||
location.put("world_name", required(audit, "defeat_world_name"));
|
||||
for (var coordinate : java.util.List.of("x", "y", "z"))
|
||||
location.put(coordinate, Double.parseDouble(required(audit, "defeat_" + coordinate)));
|
||||
data.put("death_location", location);
|
||||
} else data.put("death_location", Map.of("status", "unavailable"));
|
||||
int count = Integer.parseInt(required(audit, "represented_count"));
|
||||
if (count < 0 || count > 10_000) throw new IllegalArgumentException("Invalid represented unit count");
|
||||
var represented = new java.util.ArrayList<Map<String, Object>>(count);
|
||||
var receipts = new java.util.ArrayList<String>(count);
|
||||
for (int index = 0; index < count; index++) {
|
||||
var prefix = "represented_" + index + "_";
|
||||
var unit = new LinkedHashMap<String, Object>();
|
||||
unit.put("quest_id", UUID.fromString(required(audit, prefix + "quest_id")).toString());
|
||||
unit.put("unit_kind", required(audit, prefix + "unit_kind"));
|
||||
unit.put("unit_id", UUID.fromString(required(audit, prefix + "unit_id")).toString());
|
||||
var roster = required(audit, prefix + "accepted_roster");
|
||||
unit.put("accepted_roster", roster.isEmpty() ? java.util.List.of() :
|
||||
java.util.Arrays.stream(roster.split(",")).map(UUID::fromString).map(UUID::toString).toList());
|
||||
var receipt = UUID.fromString(required(audit, prefix + "receipt_id")).toString();
|
||||
unit.put("receipt_id", receipt);
|
||||
receipts.add(receipt);
|
||||
represented.add(unit);
|
||||
}
|
||||
data.put("represented_units", represented);
|
||||
data.put("receipt_ids", receipts);
|
||||
data.put("cleanup_status", "completed");
|
||||
outbox.durable("dungeon.completed", operation,
|
||||
new FishingEventBridge.Actor("system", null), data);
|
||||
}
|
||||
|
||||
public static UUID completionOperation(DungeonState.Run run) {
|
||||
return UUID.nameUUIDFromBytes(("dungeon:completed:" + run.dungeon() + ":" + run.generation())
|
||||
.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
public static UUID completionEventId(DungeonState.Run run) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:fishing:" + completionOperation(run)
|
||||
+ ":dungeon.completed").getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
/** Read only exported UTC-day segments, never an uncommitted pending intent. Call off the tick thread. */
|
||||
public static boolean exported(java.nio.file.Path data, UUID eventId, UUID dungeon,
|
||||
UUID generation, UUID boss) throws java.io.IOException {
|
||||
var events = data.toAbsolutePath().resolve("events");
|
||||
if (java.nio.file.Files.notExists(events)) return false;
|
||||
try (var files = java.nio.file.Files.walk(events)) {
|
||||
for (var path : files.filter(file -> java.nio.file.Files.isRegularFile(file,
|
||||
java.nio.file.LinkOption.NOFOLLOW_LINKS)
|
||||
&& file.getFileName().toString().endsWith(".jsonl")).sorted().toList()) {
|
||||
for (var line : java.nio.file.Files.readAllLines(path,java.nio.charset.StandardCharsets.UTF_8)) {
|
||||
if (line.isBlank()) continue;
|
||||
final com.google.gson.JsonObject record;
|
||||
try { record = com.google.gson.JsonParser.parseString(line).getAsJsonObject(); }
|
||||
catch (RuntimeException corrupt) { throw new java.io.IOException("Invalid exported event segment",corrupt); }
|
||||
if (!eventId.toString().equals(record.get("id").getAsString())) continue;
|
||||
var details = record.getAsJsonObject("data");
|
||||
if (record.get("type").getAsString().endsWith("dungeon.completed")
|
||||
&& dungeon.toString().equals(details.get("dungeon_id").getAsString())
|
||||
&& generation.toString().equals(details.get("generation_id").getAsString())
|
||||
&& boss.toString().equals(details.get("boss_id").getAsString())) return true;
|
||||
throw new java.io.IOException("Conflicting exported dungeon completion identity");
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException invalid) { throw new java.io.IOException("Invalid exported dungeon completion proof",invalid); }
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String required(Map<String, String> audit, String key) {
|
||||
var value = audit.get(key);
|
||||
if (value == null) throw new IllegalArgumentException("Missing committed dungeon completion " + key);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ final class FeatureEventOutbox implements AutoCloseable {
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor(task ->
|
||||
new Thread(task, "fantasy-feature-event-intents"));
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
private final Set<UUID> inFlight = java.util.concurrent.ConcurrentHashMap.newKeySet();
|
||||
|
||||
FeatureEventOutbox(Path data, String folder, Set<String> types,
|
||||
Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
@@ -239,12 +240,18 @@ final class FeatureEventOutbox implements AutoCloseable {
|
||||
}
|
||||
|
||||
private void publish(EventInput event) {
|
||||
if (!inFlight.add(event.id())) return;
|
||||
try { sink.apply(event).whenComplete((delivery, failure) -> {
|
||||
if (failure != null) { report(failure); return; }
|
||||
if (delivery == LocalEventLog.Delivery.REJECTED) return;
|
||||
try { if (Files.deleteIfExists(path(event))) forceDirectory(); }
|
||||
catch (IOException error) { report(error); }
|
||||
}); } catch (RuntimeException failure) { report(failure); }
|
||||
try {
|
||||
if (failure != null) { report(failure); return; }
|
||||
if (delivery == LocalEventLog.Delivery.REJECTED) return;
|
||||
try { if (Files.deleteIfExists(path(event))) forceDirectory(); }
|
||||
catch (IOException error) { report(error); }
|
||||
} finally { inFlight.remove(event.id()); }
|
||||
}); } catch (RuntimeException failure) {
|
||||
inFlight.remove(event.id());
|
||||
report(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void forceDirectory() throws IOException {
|
||||
|
||||
@@ -26,7 +26,8 @@ public final class FishingEventBridge implements AutoCloseable {
|
||||
private static final Set<String> DURABLE = Set.of("adventure.site.registered", "adventure.site.updated", "adventure.site.mode_changed",
|
||||
"adventure.quest.accepted", "config.changed", "fishing.catch_resolved",
|
||||
"adventure.items_submitted", "adventure.reward_claimed",
|
||||
"adventure.trophy_earned", "adventure.trophy_redeemed", "combat.reward_resolved");
|
||||
"adventure.trophy_earned", "adventure.trophy_redeemed", "combat.reward_resolved",
|
||||
"dungeon.reward_changed", "dungeon.completed");
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final Clock clock;
|
||||
private final EquipmentSessions sessions;
|
||||
@@ -130,7 +131,8 @@ public final class FishingEventBridge implements AutoCloseable {
|
||||
/** Reconstruct a combat-trophy outcome solely from the saved receipt, including after a prepared retry. */
|
||||
public void trophyEarned(FishingState.Trophy receipt) {
|
||||
if ((receipt.type() != FishingState.TrophyType.CHAMPION
|
||||
&& receipt.type() != FishingState.TrophyType.CAMP)
|
||||
&& receipt.type() != FishingState.TrophyType.CAMP
|
||||
&& receipt.type() != FishingState.TrophyType.DUNGEON)
|
||||
|| receipt.phase() != FishingState.TrophyPhase.EARNED) return;
|
||||
var audit = receipt.audit();
|
||||
var evidence = new LinkedHashMap<String, Object>();
|
||||
@@ -148,11 +150,16 @@ public final class FishingEventBridge implements AutoCloseable {
|
||||
evidence.put("champion_id", audit.getOrDefault("champion_id", "unavailable_legacy_capture"));
|
||||
evidence.put("defeat_location", savedDefeatLocation(audit));
|
||||
evidence.put("defeated_at", audit.getOrDefault("defeated_at", "unavailable_legacy_capture"));
|
||||
} else {
|
||||
} else if (receipt.type() == FishingState.TrophyType.CAMP) {
|
||||
evidence.put("final_enemy_id", audit.getOrDefault("final_enemy_id", "unavailable_legacy_capture"));
|
||||
evidence.put("clear_location", savedDefeatLocation(audit));
|
||||
evidence.put("cleared_at", audit.getOrDefault("defeated_at", "unavailable_legacy_capture"));
|
||||
evidence.put("arena", savedArena(audit));
|
||||
} else {
|
||||
evidence.put("boss_id", audit.getOrDefault("boss_id", "unavailable_legacy_capture"));
|
||||
evidence.put("room_id", audit.getOrDefault("room_id", "unavailable_legacy_capture"));
|
||||
evidence.put("boss_death_location", savedDefeatLocation(audit));
|
||||
evidence.put("completed_at", audit.getOrDefault("defeated_at", "unavailable_legacy_capture"));
|
||||
}
|
||||
evidence.put("unit_kind", audit.getOrDefault("unit_kind", "unavailable_legacy_capture"));
|
||||
evidence.put("unit_id", audit.getOrDefault("unit_id", "unavailable_legacy_capture"));
|
||||
|
||||
@@ -3,19 +3,166 @@ package games.dmg.fantasystick.events;
|
||||
import games.dmg.fantasystick.shop.RareCatalog;
|
||||
import games.dmg.fantasystick.shop.RareProduct;
|
||||
import java.math.BigInteger;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Function;
|
||||
import java.time.Clock;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/** Failure-isolated Phoenix Down catalog and acknowledged purchase event adapter. */
|
||||
public final class RareEventBridge {
|
||||
private final Consumer<EventInput> sink;private final Clock clock;
|
||||
public RareEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
|
||||
public void catalogRefreshed(RareCatalog catalog){if(!catalog.available(RareProduct.PHOENIX_DOWN))return;var offer=catalog.offer(RareProduct.PHOENIX_DOWN);var data=new LinkedHashMap<String,Object>();data.put("actor_kind","system");data.put("product_id",RareProduct.PHOENIX_DOWN.id());data.put("catalog_revision",catalog.revision().toString());data.put("catalog_day",catalog.day().toString());data.put("refresh_reason",catalog.reason().name().toLowerCase(java.util.Locale.ROOT));data.put("price",offer.price().toString());data.put("stock",Integer.toString(offer.remaining()));data.put("outcome","acknowledged");emit(stable(catalog.revision(),"phoenix-catalog"),catalog.revision(),catalog.refreshedAt(),"rare_catalog.refreshed",null,data);}
|
||||
public void purchased(UUID operation,UUID catalog,UUID player,String playerName,UUID character,String characterName,UUID session,BigInteger price,int stockBefore,int stockAfter,BigInteger quantityBefore,BigInteger quantityAfter){var data=new LinkedHashMap<String,Object>();data.put("actor_kind","player");data.put("product_id",RareProduct.PHOENIX_DOWN.id());data.put("catalog_revision",catalog.toString());data.put("purchase_operation_id",operation.toString());data.put("price",price.toString());data.put("stock_before",Integer.toString(stockBefore));data.put("stock_after",Integer.toString(stockAfter));data.put("quantity_before",quantityBefore.toString());data.put("quantity_after",quantityAfter.toString());data.put("outcome","acknowledged");var context=new EventInput.Context(player,playerName,character,characterName,session,null);emit(stable(operation,"phoenix-purchased"),operation,clock.instant(),"rare_item.purchased",context,data);}
|
||||
private void emit(UUID id,UUID action,java.time.Instant time,String type,EventInput.Context context,java.util.Map<String,Object> data){try{sink.accept(new EventInput(id,action,time,type,context,data));}catch(RuntimeException ignored){}}
|
||||
/** Failure-isolated rare catalog and acknowledged purchase event adapter. */
|
||||
public final class RareEventBridge implements AutoCloseable {
|
||||
private final Consumer<EventInput> sink;private final Clock clock;private final FeatureEventOutbox outbox;
|
||||
private final java.util.Set<UUID> catalogEvents=java.util.concurrent.ConcurrentHashMap.newKeySet();
|
||||
public RareEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);this.outbox=null;}
|
||||
public RareEventBridge(Function<EventInput,CompletionStage<LocalEventLog.Delivery>> sink,Clock clock,Path data,Consumer<Throwable> failures){
|
||||
this.sink=null;this.clock=Objects.requireNonNull(clock);
|
||||
this.outbox=new FeatureEventOutbox(data,"rare-event-intents",Set.of("rare_catalog.refreshed",
|
||||
"rare_shop.placed","rare_shop.removed","rare_item.purchased","rare_item.used",
|
||||
"config.read","config.changed","action.observed","action.resolved"),sink,failures);
|
||||
}
|
||||
public void replay(){if(outbox!=null)outbox.replay();}
|
||||
@Override public void close(){if(outbox!=null)outbox.close();}
|
||||
public CompletionStage<Void> drained(){return outbox==null?CompletableFuture.completedFuture(null):outbox.drained();}
|
||||
public void catalogRefreshed(RareCatalog catalog){
|
||||
if(!catalogEvents.add(catalog.revision()))return;
|
||||
var products=new java.util.ArrayList<java.util.Map<String,String>>();
|
||||
for(var product:RareProduct.values())if(catalog.available(product)){
|
||||
var offer=catalog.offer(product);
|
||||
products.add(java.util.Map.of("product_id",product.id(),"price",offer.price().toString(),
|
||||
"stock",Integer.toString(offer.remaining())));
|
||||
}
|
||||
var data=new LinkedHashMap<String,Object>();data.put("actor_kind","system");
|
||||
data.put("catalog_revision",catalog.revision().toString());data.put("catalog_day",catalog.day().toString());
|
||||
data.put("refresh_reason",catalog.reason().name().toLowerCase(java.util.Locale.ROOT));
|
||||
data.put("next_midnight",catalog.day().plusDays(1).atStartOfDay(java.time.ZoneOffset.UTC).toInstant().toString());
|
||||
data.put("products",products);data.put("outcome","acknowledged");
|
||||
emit(stable(catalog.revision(),"rare-catalog"),catalog.revision(),catalog.refreshedAt(),
|
||||
"rare_catalog.refreshed",null,data);
|
||||
}
|
||||
public void purchased(UUID operation,UUID catalog,UUID player,String playerName,UUID character,String characterName,UUID session,BigInteger price,int stockBefore,int stockAfter,BigInteger quantityBefore,BigInteger quantityAfter){
|
||||
purchased(RareProduct.PHOENIX_DOWN,operation,catalog,null,player,playerName,character,characterName,
|
||||
session,price,stockBefore,stockAfter,quantityBefore,quantityAfter,null,null,null,null);
|
||||
}
|
||||
public void purchased(RareProduct product,UUID operation,UUID catalog,UUID shop,UUID player,String playerName,
|
||||
UUID character,String characterName,UUID session,BigInteger price,int stockBefore,int stockAfter,
|
||||
BigInteger quantityBefore,BigInteger quantityAfter,BigInteger coinsBefore,BigInteger coinsAfter,
|
||||
BigInteger revisionBefore,BigInteger revisionAfter){
|
||||
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());
|
||||
data.put("price",price.toString());data.put("stock_before",Integer.toString(stockBefore));
|
||||
data.put("stock_after",Integer.toString(stockAfter));data.put("quantity_before",quantityBefore.toString());
|
||||
data.put("quantity_after",quantityAfter.toString());
|
||||
if(coinsBefore!=null&&coinsAfter!=null){data.put("coins_before",coinsBefore.toString());data.put("coins_after",coinsAfter.toString());}
|
||||
if(revisionBefore!=null&&revisionAfter!=null){data.put("revision_before",revisionBefore.toString());data.put("revision_after",revisionAfter.toString());}
|
||||
data.put("outcome","acknowledged");
|
||||
var context=new EventInput.Context(player,playerName,character,characterName,session,location);
|
||||
emit(stable(operation,product==RareProduct.PHOENIX_DOWN?"phoenix-purchased":"rare-purchased"),operation,
|
||||
clock.instant(),"rare_item.purchased",context,data);
|
||||
}
|
||||
public void configRead(String actorKind,UUID player,String playerName,String key,String value){
|
||||
config("config.read",actorKind,player,playerName,key,null,value);
|
||||
}
|
||||
public void configChanged(String actorKind,UUID player,String playerName,String key,String before,String after){
|
||||
config("config.changed",actorKind,player,playerName,key,before,after);
|
||||
}
|
||||
private void config(String type,String actorKind,UUID player,String playerName,String key,String before,String after){
|
||||
var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);
|
||||
if(player!=null)data.put("actor_player_id",player.toString());
|
||||
if(playerName!=null)data.put("actor_player_name",playerName);
|
||||
data.put("key",key);if(before!=null)data.put("before",before);data.put("after",after);
|
||||
data.put("application",key.endsWith("-healing-percent")?"future_potion_uses":"next_rare_restock");
|
||||
data.put("outcome","acknowledged");var operation=UUID.randomUUID();
|
||||
var context=player==null?null:new EventInput.Context(player,playerName,null,null,null,null);
|
||||
emit(stable(operation,type),operation,clock.instant(),type,context,data);
|
||||
}
|
||||
public void shopPlaced(games.dmg.fantasystick.shop.RareMerchant shop,UUID administrator,String name){
|
||||
shopChanged("rare_shop.placed",shop.id(),shop,administrator,name);
|
||||
}
|
||||
public void shopRemoved(UUID operation,games.dmg.fantasystick.shop.RareMerchant shop,UUID administrator,String name){
|
||||
shopChanged("rare_shop.removed",operation,shop,administrator,name);
|
||||
}
|
||||
private void shopChanged(String type,UUID operation,games.dmg.fantasystick.shop.RareMerchant shop,UUID administrator,String name){
|
||||
var data=new LinkedHashMap<String,Object>();data.put("actor_kind",administrator==null?"console":"player");
|
||||
if(name!=null)data.put("administrator_name",name);
|
||||
data.put("shop_id",shop.id().toString());data.put("shop_name",shop.name());
|
||||
data.put("world_id",shop.world().toString());data.put("x",shop.x());data.put("y",shop.y());
|
||||
data.put("z",shop.z());data.put("yaw",(double)shop.yaw());data.put("pitch",(double)shop.pitch());
|
||||
data.put("outcome","acknowledged");
|
||||
var context=administrator==null?null:new EventInput.Context(administrator,name,null,null,null,null);
|
||||
emit(stable(operation,type),operation,clock.instant(),type,context,data);
|
||||
}
|
||||
public EventInput.Context potionContext(org.bukkit.entity.Player player,UUID character,String name,UUID session){
|
||||
var nativeLocation=player.getLocation();var world=nativeLocation.getWorld();
|
||||
var location=world==null?null:new EventInput.Location(world.getUID(),world.getName(),
|
||||
nativeLocation.getX(),nativeLocation.getY(),nativeLocation.getZ(),clock.instant(),"native");
|
||||
return new EventInput.Context(player.getUniqueId(),player.getName(),character,name,session,location);
|
||||
}
|
||||
public void potionUsed(UUID operation,RareProduct product,EventInput.Context context,BigInteger quantityBefore,
|
||||
int percent,BigDecimal healthBefore,BigDecimal healthAfter,BigDecimal maximum,
|
||||
BigDecimal requested,BigDecimal applied,BigInteger revisionBefore,BigInteger revisionAfter){
|
||||
var data=new LinkedHashMap<String,Object>();data.put("actor_kind","player");data.put("product_id",product.id());
|
||||
data.put("use_operation_id",operation.toString());data.put("quantity_before",quantityBefore.toString());
|
||||
data.put("quantity_after",quantityBefore.subtract(BigInteger.ONE).toString());
|
||||
data.put("healing_percent",Integer.toString(percent));data.put("health_before",healthBefore.stripTrailingZeros().toPlainString());
|
||||
data.put("health_after",healthAfter.stripTrailingZeros().toPlainString());
|
||||
data.put("maximum_health",maximum.stripTrailingZeros().toPlainString());
|
||||
data.put("requested_healing",requested.stripTrailingZeros().toPlainString());
|
||||
data.put("applied_healing",applied.stripTrailingZeros().toPlainString());
|
||||
data.put("revision_before",revisionBefore.toString());data.put("revision_after",revisionAfter.toString());
|
||||
data.put("outcome","acknowledged");
|
||||
emit(stable(operation,"potion-used"),operation,clock.instant(),"rare_item.used",context,data);
|
||||
}
|
||||
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));}
|
||||
}
|
||||
@@ -79,6 +79,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
private java.util.function.Predicate<org.bukkit.entity.Entity> excluded = ignored -> false;
|
||||
private java.util.function.Function<LivingEntity,java.util.Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin>> managedOrigins=ignored->java.util.Optional.empty();
|
||||
private Consumer<games.dmg.fantasystick.progression.ManagedEncounterOrigin> managedSettlement=ignored->{};
|
||||
private java.util.function.Function<LivingEntity,Map<String,Object>> managedRewardEvidence=ignored->Map.of();
|
||||
private games.dmg.fantasystick.events.CombatRewardEventBridge events;
|
||||
private boolean closed;
|
||||
|
||||
@@ -142,6 +143,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
public void attachManagedOrigins(java.util.function.Function<LivingEntity,java.util.Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin>> classifier){
|
||||
if(closed)throw new IllegalStateException("Combat experience is closed");managedOrigins=Objects.requireNonNull(classifier);
|
||||
}
|
||||
public void attachManagedRewardEvidence(java.util.function.Function<LivingEntity,Map<String,Object>> evidence){if(closed)throw new IllegalStateException("Combat experience is closed");managedRewardEvidence=Objects.requireNonNull(evidence);}
|
||||
public void attachManagedSettlement(Consumer<games.dmg.fantasystick.progression.ManagedEncounterOrigin> settlement){if(closed)throw new IllegalStateException("Combat experience is closed");managedSettlement=Objects.requireNonNull(settlement);}
|
||||
public void attachEvents(games.dmg.fantasystick.events.CombatRewardEventBridge value){if(closed||events!=null)throw new IllegalStateException("Combat reward events already attached or combat is closed");events=Objects.requireNonNull(value);}
|
||||
|
||||
@@ -236,6 +238,9 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
BigInteger defeatedCoins) {
|
||||
Instant now = clock.instant();
|
||||
java.util.Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin> managed;try{managed=managedOrigins.apply(target);}catch(RuntimeException invalid){managed=java.util.Optional.empty();}
|
||||
Map<String,Object> managedEvidence=Map.of();
|
||||
if(managed.isPresent())try{managedEvidence=Map.copyOf(managedRewardEvidence.apply(target));}
|
||||
catch(RuntimeException ignored){}
|
||||
var direct = new ArrayList<>(contributions.consumeDirect(target.getUniqueId(), now));
|
||||
if (defeatedCharacter != null) direct.remove(defeatedCharacter);
|
||||
if (direct.isEmpty()) {managed.ifPresent(this::settled);return;}
|
||||
@@ -255,7 +260,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
for (UUID character : xp.keySet()) {
|
||||
String receiptAuthority=managed.isPresent()?authority:targetReward+":"+now+":"+authority;
|
||||
UUID receipt = UUID.nameUUIDFromBytes((receiptAuthority+":"+character).getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
var unit=units.stream().filter(value->value.recipients().contains(character)).findFirst().orElseThrow();var capture=new games.dmg.fantasystick.events.CombatRewardEventBridge.Capture(target.getUniqueId(),target.getType().name().toLowerCase(java.util.Locale.ROOT),kind.name().toLowerCase(java.util.Locale.ROOT),coinTarget(target).name().toLowerCase(java.util.Locale.ROOT),defeatedCharacter==null?position(target.getLocation()):null,managed.orElse(null),defeatedCharacter==null?"mob_defeat":"fantasy_character_defeat",now,direct,String.valueOf(unit.key()),units.size(),experiencePool,coinPool);
|
||||
var unit=units.stream().filter(value->value.recipients().contains(character)).findFirst().orElseThrow();var capture=new games.dmg.fantasystick.events.CombatRewardEventBridge.Capture(target.getUniqueId(),target.getType().name().toLowerCase(java.util.Locale.ROOT),kind.name().toLowerCase(java.util.Locale.ROOT),coinTarget(target).name().toLowerCase(java.util.Locale.ROOT),defeatedCharacter==null?position(target.getLocation()):null,managed.orElse(null),defeatedCharacter==null?"mob_defeat":"fantasy_character_defeat",now,direct,String.valueOf(unit.key()),units.size(),experiencePool,coinPool,managedEvidence);
|
||||
var proposed = new PendingAward(receipt, character, xp.get(character), coins.get(character), restricted,capturedRewards,capture);
|
||||
var existing=pending.putIfAbsent(receipt,proposed);var operation=existing==null?proposed:existing;settlements.add(operation.settled);attempt(operation);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ public final class YamlDungeonRepository {
|
||||
public DungeonState load()throws IOException{
|
||||
if(Files.notExists(file))return DungeonState.empty();
|
||||
try{var root=map(yaml().load(Files.readString(file,StandardCharsets.UTF_8)));int schema=integer(root.get("schema-version"));if(schema!=1&&schema!=2)throw new IllegalArgumentException();var dungeons=new ArrayList<DungeonState.Dungeon>();for(var item:list(root.get("dungeons"))){var row=map(item);var rooms=new ArrayList<DungeonState.Room>();for(var roomItem:list(row.get("rooms"))){var room=map(roomItem);var mobs=new ArrayList<DungeonState.Spawn>();for(var mobItem:list(room.get("mobs"))){var mob=map(mobItem);mobs.add(new DungeonState.Spawn(uuid(mob,"id"),EntityType.valueOf(text(mob.get("type"))),text(mob.get("name")),position(map(mob.get("position")))));}var effects=new ArrayList<DungeonState.Effect>();for(var effectItem:list(room.get("effects"))){var effect=map(effectItem);effects.add(new DungeonState.Effect(uuid(effect,"id"),DungeonState.EffectTarget.valueOf(text(effect.get("target"))),text(effect.get("effect")),integer(effect.get("level")),bool(effect.get("particles"))));}DungeonState.Boss boss=null;if(room.get("boss")!=null){var value=map(room.get("boss"));DungeonState.CharacterBoss character=null;if(value.get("character")!=null){var custody=map(value.get("character"));DungeonState.Recovery recovery=null;if(custody.get("recovery")!=null){var pending=map(custody.get("recovery"));recovery=new DungeonState.Recovery(uuid(pending,"operation"),uuid(pending,"administrator"));}character=new DungeonState.CharacterBoss(uuid(custody,"operation"),CharacterYaml.decode(text(custody.get("record"))),new java.math.BigDecimal(text(custody.get("multiplier"))),custody.containsKey("prior-type")?EntityType.valueOf(text(custody.get("prior-type"))):EntityType.HUSK,custody.containsKey("prior-name")?text(custody.get("prior-name")):"",recovery);}boss=new DungeonState.Boss(EntityType.valueOf(text(value.get("type"))),text(value.get("name")),position(map(value.get("position"))),character);}rooms.add(new DungeonState.Room(uuid(room,"id"),text(room.get("name")),bounds(map(room.get("bounds"))),mobs,effects,new DungeonState.Reward(new BigInteger(text(room.get("reward-xp"))),new BigInteger(text(room.get("reward-coins")))),boss));}var mode=row.containsKey("participation-mode")?games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.parse(text(row.get("participation-mode"))):games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;var completion=schema==2?new DungeonState.Reward(new BigInteger(text(row.get("completion-reward-xp"))),new BigInteger(text(row.get("completion-reward-coins")))):rooms.stream().filter(room->room.boss()!=null).map(DungeonState.Room::reward).findFirst().orElse(DungeonState.Reward.empty());if(schema==1)for(int index=0;index<rooms.size();index++){var room=rooms.get(index);rooms.set(index,new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects(),DungeonState.Reward.empty(),room.boss()));}dungeons.add(new DungeonState.Dungeon(uuid(row,"id"),text(row.get("name")),uuid(row,"world"),position(map(row.get("entrance"))),bool(row.get("enabled")),uuid(row,"revision"),rooms,mode,completion,schema==1||bool(row.get("completion-reward-configured"))));}
|
||||
var runs=new ArrayList<DungeonState.Run>();for(var item:list(root.get("runs"))){var row=map(item);var unitClaims=new HashSet<DungeonState.UnitClaim>();for(var value:list(row.get("unit-claims"))){var claim=map(value);unitClaims.add(new DungeonState.UnitClaim(uuid(claim,"room"),uuid(claim,"quest")));}var characterClaims=new HashSet<DungeonState.CharacterClaim>();for(var value:list(row.get("character-claims"))){var claim=map(value);characterClaims.add(new DungeonState.CharacterClaim(uuid(claim,"room"),uuid(claim,"character")));}runs.add(new DungeonState.Run(uuid(row,"dungeon"),uuid(row,"generation"),uuids(row.get("defeated-slots")),uuids(row.get("cleared-rooms")),unitClaims,characterClaims,bool(row.get("defeated"))));}
|
||||
var runs=new ArrayList<DungeonState.Run>();for(var item:list(root.get("runs"))){var row=map(item);var unitClaims=new HashSet<DungeonState.UnitClaim>();for(var value:list(row.get("unit-claims"))){var claim=map(value);unitClaims.add(new DungeonState.UnitClaim(uuid(claim,"room"),uuid(claim,"quest")));}var characterClaims=new HashSet<DungeonState.CharacterClaim>();for(var value:list(row.get("character-claims"))){var claim=map(value);characterClaims.add(new DungeonState.CharacterClaim(uuid(claim,"room"),uuid(claim,"character")));}var audit=new LinkedHashMap<String,String>();if(row.get("completion-audit")!=null)for(var entry:map(row.get("completion-audit")).entrySet())audit.put(text(entry.getKey()),text(entry.getValue()));runs.add(new DungeonState.Run(uuid(row,"dungeon"),uuid(row,"generation"),uuids(row.get("defeated-slots")),uuids(row.get("cleared-rooms")),unitClaims,characterClaims,bool(row.get("defeated")),audit,row.containsKey("completion-published")&&bool(row.get("completion-published"))));}
|
||||
return new DungeonState(dungeons,runs);
|
||||
}catch(RuntimeException invalid){throw new IOException("Invalid dungeon file",invalid);}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ public final class YamlDungeonRepository {
|
||||
private static Map<String,Object> dungeon(DungeonState.Dungeon value){return row("id",value.id(),"name",value.name(),"world",value.world(),"entrance",position(value.entrance()),"enabled",value.enabled(),"revision",value.revision(),"participation-mode",value.participationMode().value(),"completion-reward-xp",value.completionReward().xp(),"completion-reward-coins",value.completionReward().coins(),"completion-reward-configured",value.rewardConfigured(),"rooms",value.rooms().stream().sorted(Comparator.comparing(DungeonState.Room::id)).map(YamlDungeonRepository::room).toList());}
|
||||
private static Map<String,Object> room(DungeonState.Room value){return row("id",value.id(),"name",value.name(),"bounds",bounds(value.bounds()),"mobs",value.mobs().stream().sorted(Comparator.comparing(DungeonState.Spawn::id)).map(v->row("id",v.id(),"type",v.type(),"name",v.name(),"position",position(v.position()))).toList(),"effects",value.effects().stream().sorted(Comparator.comparing(DungeonState.Effect::id)).map(v->row("id",v.id(),"target",v.target(),"effect",v.effect(),"level",v.level(),"particles",v.particles())).toList(),"reward-xp",value.reward().xp(),"reward-coins",value.reward().coins(),"boss",value.boss()==null?null:row("type",value.boss().type(),"name",value.boss().name(),"position",position(value.boss().position()),"character",value.boss().character()==null?null:character(value.boss().character())));}
|
||||
private static Map<String,Object> character(DungeonState.CharacterBoss value){return row("operation",value.operation(),"record",CharacterYaml.encode(value.character()),"multiplier",value.multiplier().toPlainString(),"prior-type",value.priorType(),"prior-name",value.priorName(),"recovery",value.recovery()==null?null:row("operation",value.recovery().operation(),"administrator",value.recovery().administrator()));}
|
||||
private static Map<String,Object> run(DungeonState.Run value){return row("dungeon",value.dungeon(),"generation",value.generation(),"defeated-slots",value.defeatedSlots().stream().sorted().toList(),"cleared-rooms",value.clearedRooms().stream().sorted().toList(),"unit-claims",value.unitClaims().stream().sorted(Comparator.comparing(v->v.room().toString()+v.quest())).map(v->row("room",v.room(),"quest",v.quest())).toList(),"character-claims",value.characterClaims().stream().sorted(Comparator.comparing(v->v.room().toString()+v.character())).map(v->row("room",v.room(),"character",v.character())).toList(),"defeated",value.defeated());}
|
||||
private static Map<String,Object> run(DungeonState.Run value){return row("dungeon",value.dungeon(),"generation",value.generation(),"defeated-slots",value.defeatedSlots().stream().sorted().toList(),"cleared-rooms",value.clearedRooms().stream().sorted().toList(),"unit-claims",value.unitClaims().stream().sorted(Comparator.comparing(v->v.room().toString()+v.quest())).map(v->row("room",v.room(),"quest",v.quest())).toList(),"character-claims",value.characterClaims().stream().sorted(Comparator.comparing(v->v.room().toString()+v.character())).map(v->row("room",v.room(),"character",v.character())).toList(),"defeated",value.defeated(),"completion-audit",new java.util.TreeMap<>(value.completionAudit()),"completion-published",value.completionPublished());}
|
||||
private static Map<String,Object> position(DungeonState.Position value){return row("x",value.x(),"y",value.y(),"z",value.z(),"yaw",value.yaw(),"pitch",value.pitch());}
|
||||
private static DungeonState.Position position(Map<?,?> value){return new DungeonState.Position(decimal(value.get("x")),decimal(value.get("y")),decimal(value.get("z")),(float)decimal(value.get("yaw")),(float)decimal(value.get("pitch")));}
|
||||
private static Map<String,Object> bounds(DungeonState.Bounds value){return row("min-x",value.minX(),"min-y",value.minY(),"min-z",value.minZ(),"max-x",value.maxX(),"max-y",value.maxY(),"max-z",value.maxZ());}
|
||||
|
||||
@@ -111,6 +111,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private games.dmg.fantasystick.shop.ArmorMarket armorMarket;
|
||||
private games.dmg.fantasystick.shop.RareMerchantDirectory rareMerchants;
|
||||
private games.dmg.fantasystick.shop.RareMerchantController rareMerchantController;
|
||||
private games.dmg.fantasystick.events.RareEventBridge rareAuditEvents;
|
||||
private games.dmg.fantasystick.shop.RareMarket rareMarket;
|
||||
private games.dmg.fantasystick.persistence.RareSettings rareSettings;
|
||||
private games.dmg.fantasystick.persistence.DownedSettings downedSettings;
|
||||
@@ -491,6 +492,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
public void attachArmorers(games.dmg.fantasystick.shop.FantasyArmorerDirectory directory,games.dmg.fantasystick.shop.FantasyArmorerController controller){if(closed||armorers!=null)throw new IllegalStateException("Armorers already attached or command closed");armorers=Objects.requireNonNull(directory);armorerController=Objects.requireNonNull(controller);}
|
||||
public void attachArmorMarket(games.dmg.fantasystick.shop.ArmorMarket market){if(closed||armorMarket!=null)throw new IllegalStateException("Armor market already attached or command closed");armorMarket=Objects.requireNonNull(market);}
|
||||
public void attachRareMarket(games.dmg.fantasystick.shop.RareMerchantDirectory directory,games.dmg.fantasystick.shop.RareMerchantController controller,games.dmg.fantasystick.shop.RareMarket market,games.dmg.fantasystick.persistence.RareSettings settings){if(closed||rareMerchants!=null)throw new IllegalStateException("Rare market already attached or command closed");rareMerchants=Objects.requireNonNull(directory);rareMerchantController=Objects.requireNonNull(controller);rareMarket=Objects.requireNonNull(market);rareSettings=Objects.requireNonNull(settings);}
|
||||
public void attachRareEvents(games.dmg.fantasystick.events.RareEventBridge events){if(rareAuditEvents!=null)throw new IllegalStateException("Rare events already attached");rareAuditEvents=Objects.requireNonNull(events);}
|
||||
public void attachDownedSettings(games.dmg.fantasystick.persistence.DownedSettings settings){if(closed||downedSettings!=null)throw new IllegalStateException("Downed settings already attached or command closed");downedSettings=Objects.requireNonNull(settings);}
|
||||
public void attachDownedEvents(games.dmg.fantasystick.events.DownedEventBridge events){if(closed||downedEvents!=null)throw new IllegalStateException("Downed events already attached or command closed");downedEvents=Objects.requireNonNull(events);}
|
||||
|
||||
@@ -868,7 +870,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private void placeShopResponse(org.bukkit.entity.Player player,org.bukkit.Location location,games.dmg.fantasystick.shop.FantasyArmorer saved,Throwable failure){if(failure!=null)report(player.getUniqueId(),"shop-place",failure);if(closed)return;nextTick.accept(()->{if(closed)return;if(failure!=null){if(player.isOnline())player.sendMessage("Fantasy Armorer placement failed; no registration or entity changed.");return;}try{int x=location.getBlockX()>>4,z=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(x,z))armorerController.reconcile(location.getWorld().getChunkAt(x,z));if(player.isOnline())player.sendMessage("Placed Fantasy Armorer "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"shop-entity",entityFailure);armorers.remove(saved.id()).whenComplete((ignored,rollback)->nextTick.accept(()->{armorerController.removeLoaded(saved.id());if(player.isOnline())player.sendMessage("Fantasy Armorer placement failed; no registration or entity changed.");}));}});}
|
||||
private void removeShopResponse(CommandSender sender,UUID id,java.util.Optional<games.dmg.fantasystick.shop.FantasyArmorer> removed,Throwable failure){if(failure!=null)report(null,"shop-remove",failure);if(closed)return;nextTick.accept(()->{if(failure!=null)sender.sendMessage("Fantasy Armorer removal failed; registration and entity are unchanged.");else if(removed.isEmpty())sender.sendMessage("Fantasy Armorer not found.");else try{armorerController.removeLoaded(id);sender.sendMessage("Removed Fantasy Armorer "+id+".");}catch(RuntimeException entityFailure){var value=removed.orElseThrow();report(null,"shop-entity-remove",entityFailure);armorers.restore(value).whenComplete((restored,rollback)->{if(rollback!=null)report(null,"shop-remove-rollback",rollback);if(closed)return;nextTick.accept(()->{if(rollback==null){var world=server.getWorld(value.world());if(world!=null&&world.isChunkLoaded(((int)Math.floor(value.x()))>>4,((int)Math.floor(value.z()))>>4))armorerController.reconcile(world.getChunkAt(((int)Math.floor(value.x()))>>4,((int)Math.floor(value.z()))>>4));sender.sendMessage("Fantasy Armorer removal failed; registration and entity are unchanged.");}else sender.sendMessage("Fantasy Armorer removal rollback failed; inspect diagnostics.");});});}});}
|
||||
|
||||
private void rareShop(CommandSender sender,String[] args){if(rareMerchants==null||rareMerchantController==null||rareMarket==null||args.length<2){rareShopUsage(sender);return;}if(args[1].equals("restock")&&args.length==2){rareMarket.restock().whenComplete((catalog,failure)->{if(failure!=null)report(null,"rare-shop-restock",failure);if(closed)return;nextTick.accept(()->{if(closed||!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Restocked Rare Items catalog "+catalog.revision()+"; next automatic restock remains 00:00 UTC.":"Rare Items restock failed; prior catalog is unchanged.");});});return;}if(args[1].equals("list")&&args.length==2){var values=rareMerchants.all();if(values.isEmpty())sender.sendMessage("No Rare Items merchants are registered.");else for(var value:values)sender.sendMessage(value.id()+" "+(value.name().isBlank()?"Rare Items":value.name())+" @ "+value.world()+" "+value.x()+" "+value.y()+" "+value.z());return;}if(args[1].equals("place")&&sender instanceof org.bukkit.entity.Player player){String name=args.length<3?"":String.join(" ",java.util.Arrays.copyOfRange(args,2,args.length));var location=player.getLocation().clone();try{rareMerchants.place(name,location.getWorld().getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((saved,failure)->{if(failure!=null)report(player.getUniqueId(),"rare-shop-place",failure);if(closed)return;nextTick.accept(()->{if(closed||!player.hasPermission(PERMISSION)||!player.isOnline())return;if(failure!=null){player.sendMessage("Rare Items placement failed; prior state is unchanged.");return;}try{int x=location.getBlockX()>>4,z=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(x,z))rareMerchantController.reconcile(location.getWorld().getChunkAt(x,z));player.sendMessage("Placed Rare Items merchant "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"rare-shop-entity",entityFailure);rareMerchants.remove(saved.id());rareMerchantController.removeLoaded(saved.id());player.sendMessage("Rare Items placement failed; registration rollback was requested.");}});});}catch(IllegalArgumentException invalid){sender.sendMessage("Rare Items merchant name is invalid.");}return;}if(args[1].equals("remove")&&args.length==3){UUID id;try{id=UUID.fromString(args[2]);if(!id.toString().equals(args[2]))throw new IllegalArgumentException();}catch(IllegalArgumentException invalid){sender.sendMessage("Rare Items merchant UUID is invalid.");return;}rareMerchants.remove(id).whenComplete((removed,failure)->{if(failure!=null)report(null,"rare-shop-remove",failure);if(closed)return;nextTick.accept(()->{if(closed||!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Rare Items removal failed; registration is unchanged.");else if(removed.isEmpty())sender.sendMessage("Rare Items merchant not found.");else{rareMerchantController.removeLoaded(id);sender.sendMessage("Removed Rare Items merchant "+id+".");}});});return;}rareShopUsage(sender);}
|
||||
private void rareShop(CommandSender sender,String[] args){if(rareMerchants==null||rareMerchantController==null||rareMarket==null||args.length<2){rareShopUsage(sender);return;}if(args[1].equals("restock")&&args.length==2){rareMarket.restock().whenComplete((catalog,failure)->{if(failure!=null)report(null,"rare-shop-restock",failure);if(closed)return;nextTick.accept(()->{if(closed||!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Restocked Rare Items catalog "+catalog.revision()+"; next automatic restock remains 00:00 UTC.":"Rare Items restock failed; prior catalog is unchanged.");});});return;}if(args[1].equals("list")&&args.length==2){var values=rareMerchants.all();if(values.isEmpty())sender.sendMessage("No Rare Items merchants are registered.");else for(var value:values)sender.sendMessage(value.id()+" "+(value.name().isBlank()?"Rare Items":value.name())+" @ "+value.world()+" "+value.x()+" "+value.y()+" "+value.z());return;}if(args[1].equals("place")&&sender instanceof org.bukkit.entity.Player player){String name=args.length<3?"":String.join(" ",java.util.Arrays.copyOfRange(args,2,args.length));var location=player.getLocation().clone();try{rareMerchants.place(name,location.getWorld().getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((saved,failure)->{if(failure!=null)report(player.getUniqueId(),"rare-shop-place",failure);if(closed)return;nextTick.accept(()->{if(closed||!player.hasPermission(PERMISSION)||!player.isOnline())return;if(failure!=null){player.sendMessage("Rare Items placement failed; prior state is unchanged.");return;}try{int x=location.getBlockX()>>4,z=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(x,z))rareMerchantController.reconcile(location.getWorld().getChunkAt(x,z));if(rareAuditEvents!=null)rareAuditEvents.shopPlaced(saved,player.getUniqueId(),player.getName());player.sendMessage("Placed Rare Items merchant "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"rare-shop-entity",entityFailure);rareMerchants.remove(saved.id());rareMerchantController.removeLoaded(saved.id());player.sendMessage("Rare Items placement failed; registration rollback was requested.");}});});}catch(IllegalArgumentException invalid){sender.sendMessage("Rare Items merchant name is invalid.");}return;}if(args[1].equals("remove")&&args.length==3){UUID id;try{id=UUID.fromString(args[2]);if(!id.toString().equals(args[2]))throw new IllegalArgumentException();}catch(IllegalArgumentException invalid){sender.sendMessage("Rare Items merchant UUID is invalid.");return;}rareMerchants.remove(id).whenComplete((removed,failure)->{if(failure!=null)report(null,"rare-shop-remove",failure);if(closed)return;nextTick.accept(()->{if(closed||!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Rare Items removal failed; registration is unchanged.");else if(removed.isEmpty())sender.sendMessage("Rare Items merchant not found.");else{rareMerchantController.removeLoaded(id);if(rareAuditEvents!=null)rareAuditEvents.shopRemoved(UUID.randomUUID(),removed.orElseThrow(),sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null,sender instanceof org.bukkit.entity.Player namedPlayer?namedPlayer.getName():null);sender.sendMessage("Removed Rare Items merchant "+id+".");}});});return;}rareShopUsage(sender);}
|
||||
private static void rareShopUsage(CommandSender sender){sender.sendMessage("Usage: /fstickadmin rare-shop <place [name...]|remove <shop-uuid>|list|restock>");}
|
||||
|
||||
private void trainingDummy(CommandSender sender, String[] args) {
|
||||
@@ -1899,7 +1901,16 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private void partyEconomyConfiguration(CommandSender sender,String[] args){String key=args[1],before=partyEconomySettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;if(args.length==2){if(partyEvents!=null)partyEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+" (applies to future party transactions only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PartyEconomySettings.Update> operation;try{operation=partyEconomySettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Party price is invalid; enter a whole number from 0 through 2147483647.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=failure==null&&partyEconomySettings.publish(saved);if(applied&&partyEvents!=null)partyEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Party price save failed; the effective price is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+partyEconomySettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for future party transactions.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
|
||||
private void gearConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+gearSettings.value(key)+" (applies to the next armor restock only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.GearSettings.Update> operation;try{operation=gearSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Armor market setting is invalid; variance is 0–100, prices are nonnegative, and positive stock minima cannot exceed maxima.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;nextTick.accept(()->{boolean applied=failure==null&&gearSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Armor market setting save failed; effective settings and the current catalog are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+gearSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for the next automatic or administrative restock; the current catalog is unchanged.");});});}
|
||||
private void rareConfiguration(CommandSender sender,String[] args){String key=args[1],before=rareSettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;boolean phoenix=key.contains("phoenix-down");if(args.length==2){if(phoenix&&downedEvents!=null)downedEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+(key.endsWith("-healing-percent")||key.equals(games.dmg.fantasystick.persistence.RareSettings.PHOENIX_PROTECTION)?" (applies to future accepted uses)":" (applies to the next rare restock only)"));return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RareSettings.Update> operation;try{operation=rareSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Rare setting is invalid; percentages are 0–100, values are nonnegative, and stock minima cannot exceed maxima.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;nextTick.accept(()->{boolean applied=failure==null&&rareSettings.publish(saved);if(applied&&phoenix&&downedEvents!=null)downedEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Rare setting save failed; prior effective state is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+rareSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+"; current catalog and accepted uses are unchanged.");});});}
|
||||
private static boolean potionConfig(String key){
|
||||
if(key.equals("rare-price-variance-percent"))return true;
|
||||
for(var product:games.dmg.fantasystick.shop.RareProduct.values())if(product.heals()){
|
||||
var id=product.id();
|
||||
if(key.equals("rare-price-"+id)||key.equals("rare-stock-"+id+"-min")
|
||||
||key.equals("rare-stock-"+id+"-max")||key.equals("rare-"+id+"-healing-percent"))return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
private void rareConfiguration(CommandSender sender,String[] args){String key=args[1],before=rareSettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;boolean phoenix=key.contains("phoenix-down");if(args.length==2){if(phoenix&&downedEvents!=null)downedEvents.configRead(actorKind,actorPlayer,actorName,key,before);else if((potionConfig(key)||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);}});}
|
||||
|
||||
@@ -14,17 +14,19 @@ public final class RareMarket implements AutoCloseable {
|
||||
public enum PurchaseStatus{APPLIED,SOLD_OUT,STALE,INSUFFICIENT,UNAVAILABLE,FAILED}
|
||||
public record PurchaseProof(UUID operation,RareProduct product,BigInteger price,int stockAfter){}
|
||||
public record PurchaseOutcome(PurchaseStatus status,Optional<CharacterRecord> record,RareCatalog catalog,PurchaseProof proof){public PurchaseOutcome(PurchaseStatus status,Optional<CharacterRecord> record,RareCatalog catalog){this(status,record,catalog,null);}}
|
||||
private final RareCatalogRepository catalogs;private final RarePurchaseOperationStore operations;private final CharacterStorage characters;private final RareInventoryDirectory inventory;private final Supplier<RareMarketRules> rules;private final Clock clock;private final RareCatalogGenerator.InclusiveRoll random;private final ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-rare-market"));private final CompletionStage<Void> readiness;private volatile RareCatalog snapshot;private volatile boolean initialized;private volatile Consumer<RareCatalog> listener=ignored->{};private boolean closed,operationsClosing;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
private final RareCatalogRepository catalogs;private final RarePurchaseOperationStore operations;private final CharacterStorage characters;private final RareInventoryDirectory inventory;private final Supplier<RareMarketRules> rules;private final Clock clock;private final RareCatalogGenerator.InclusiveRoll random;private final ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-rare-market"));private final CompletionStage<Void> readiness;private volatile RareCatalog snapshot;private Consumer<RareCatalog> listener=ignored->{};private final ArrayDeque<RareCatalog> pendingRefreshes=new ArrayDeque<>();private boolean listenerAttached,closed,operationsClosing;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
public RareMarket(RareCatalogRepository catalogs,RarePurchaseOperationStore operations,CharacterStorage characters,RareInventoryDirectory inventory,Supplier<RareMarketRules> rules,Clock clock,RareCatalogGenerator.InclusiveRoll random){this.catalogs=Objects.requireNonNull(catalogs);this.operations=Objects.requireNonNull(operations);this.characters=Objects.requireNonNull(characters);this.inventory=Objects.requireNonNull(inventory);this.rules=Objects.requireNonNull(rules);this.clock=Objects.requireNonNull(clock);this.random=Objects.requireNonNull(random);readiness=submit(()->{initialize();return null;});}
|
||||
public void attachListener(Consumer<RareCatalog> listener){this.listener=Objects.requireNonNull(listener);}public Optional<RareCatalog> snapshot(){return Optional.ofNullable(snapshot);}public Instant now(){return clock.instant();}public CompletionStage<Void> ready(){return readiness;}public CompletionStage<RareCatalog> current(){return readiness.thenCompose(ignored->submit(()->ensureCurrent(RareCatalog.RefreshReason.SCHEDULED)));}
|
||||
public synchronized void attachListener(Consumer<RareCatalog> listener){this.listener=Objects.requireNonNull(listener);listenerAttached=true;while(!pendingRefreshes.isEmpty())deliver(pendingRefreshes.removeFirst());}public Optional<RareCatalog> snapshot(){return Optional.ofNullable(snapshot);}public Instant now(){return clock.instant();}public CompletionStage<Void> ready(){return readiness;}public CompletionStage<RareCatalog> current(){return readiness.thenCompose(ignored->submit(()->ensureCurrent(RareCatalog.RefreshReason.SCHEDULED)));}
|
||||
public CompletionStage<RareCatalog> restock(){return readiness.thenCompose(ignored->submit(()->{var current=ensureCurrent(RareCatalog.RefreshReason.SCHEDULED);if(!current.transactions().isEmpty())throw new IOException("Rare purchases are unresolved");var generated=RareCatalogGenerator.generate(LocalDate.now(clock.withZone(ZoneOffset.UTC)),clock.instant().truncatedTo(java.time.temporal.ChronoUnit.SECONDS),RareCatalog.RefreshReason.ADMINISTRATIVE,rules.get(),random);persist(generated,true);return generated;}));}
|
||||
public static Instant nextScheduledRestock(Instant now){return now.atZone(ZoneOffset.UTC).toLocalDate().plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();}
|
||||
public CompletionStage<PurchaseOutcome> purchase(UUID character,BigInteger expectedCharacterRevision,UUID expectedCatalogRevision,RareProduct product){return readiness.thenCompose(ignored->submit(()->purchaseBlocking(character,expectedCharacterRevision,expectedCatalogRevision,product)));}
|
||||
private void initialize()throws Exception{snapshot=catalogs.load().orElse(null);var plans=operations.load().toCompletableFuture().join();if(snapshot==null&&!plans.isEmpty())throw new IOException("Rare catalog missing for pending purchases");for(var plan:plans)execute(plan);if(snapshot!=null&&!snapshot.transactions().isEmpty()){var next=snapshot;for(var entry:snapshot.transactions().entrySet()){if(entry.getValue().state()==RareCatalog.TransactionState.RESERVED)throw new IOException("Orphan reserved rare purchase "+entry.getKey());next=next.prune(entry.getKey());}persist(next,false);}ensureCurrent(RareCatalog.RefreshReason.STARTUP);initialized=true;}
|
||||
private void initialize()throws Exception{snapshot=catalogs.load().orElse(null);var plans=operations.load().toCompletableFuture().join();if(snapshot==null&&!plans.isEmpty())throw new IOException("Rare catalog missing for pending purchases");for(var plan:plans)execute(plan);if(snapshot!=null&&!snapshot.transactions().isEmpty()){var next=snapshot;for(var entry:snapshot.transactions().entrySet()){if(entry.getValue().state()==RareCatalog.TransactionState.RESERVED)throw new IOException("Orphan reserved rare purchase "+entry.getKey());next=next.prune(entry.getKey());}persist(next,false);}ensureCurrent(RareCatalog.RefreshReason.STARTUP);}
|
||||
private PurchaseOutcome purchaseBlocking(UUID character,BigInteger expectedCharacterRevision,UUID expectedCatalogRevision,RareProduct product)throws Exception{var catalog=ensureCurrent(RareCatalog.RefreshReason.SCHEDULED);if(!catalog.transactions().isEmpty())return new PurchaseOutcome(PurchaseStatus.FAILED,Optional.empty(),catalog);if(!catalog.revision().equals(expectedCatalogRevision))return new PurchaseOutcome(PurchaseStatus.STALE,Optional.empty(),catalog);if(!catalog.available(product))return new PurchaseOutcome(PurchaseStatus.UNAVAILABLE,Optional.empty(),catalog);if(catalog.offer(product).remaining()==0)return new PurchaseOutcome(PurchaseStatus.SOLD_OUT,Optional.empty(),catalog);var plan=new RarePurchaseOperationStore.Plan(UUID.randomUUID(),character,expectedCharacterRevision,catalog.revision(),product,catalog.offer(product).price());operations.prepare(plan).toCompletableFuture().join();return execute(plan);}
|
||||
private PurchaseOutcome execute(RarePurchaseOperationStore.Plan plan)throws Exception{var catalog=Objects.requireNonNull(snapshot,"Rare catalog unavailable");if(!catalog.revision().equals(plan.catalogRevision()))throw new IOException("Pending rare purchase belongs to another catalog");var transaction=catalog.transactions().get(plan.id());if(transaction==null){try{catalog=catalog.reserve(plan.id(),plan.product());}catch(IllegalStateException soldOut){operations.complete(plan.id()).toCompletableFuture().join();return new PurchaseOutcome(PurchaseStatus.SOLD_OUT,Optional.empty(),catalog);}persist(catalog,false);transaction=catalog.transactions().get(plan.id());}if(transaction.product()!=plan.product())throw new IOException("Rare reservation product mismatch");if(transaction.state()!=RareCatalog.TransactionState.RESERVED){if(transaction.state()==RareCatalog.TransactionState.CONSUMED)inventory.add(plan.character(),plan.product(),plan.id()).join();operations.complete(plan.id()).toCompletableFuture().join();var pruned=catalog.prune(plan.id());persist(pruned,false);return new PurchaseOutcome(transaction.state()==RareCatalog.TransactionState.CONSUMED?PurchaseStatus.APPLIED:PurchaseStatus.FAILED,characters.find(plan.character()).join(),pruned,transaction.state()==RareCatalog.TransactionState.CONSUMED?new PurchaseProof(plan.id(),plan.product(),plan.price(),pruned.offer(plan.product()).remaining()):null);}var purchase=characters.purchaseRare(plan.character(),plan.expectedCharacterRevision(),plan.id(),plan.price()).join();boolean consumed=purchase.status()==CharacterStorage.RarePurchaseStatus.APPLIED||purchase.status()==CharacterStorage.RarePurchaseStatus.DUPLICATE;if(consumed)inventory.add(plan.character(),plan.product(),plan.id()).join();catalog=catalog.settle(plan.id(),consumed);persist(catalog,consumed);operations.complete(plan.id()).toCompletableFuture().join();catalog=catalog.prune(plan.id());persist(catalog,false);var status=switch(purchase.status()){case APPLIED,DUPLICATE->PurchaseStatus.APPLIED;case INSUFFICIENT->PurchaseStatus.INSUFFICIENT;case STALE->PurchaseStatus.STALE;case UNAVAILABLE->PurchaseStatus.UNAVAILABLE;};return new PurchaseOutcome(status,purchase.record(),catalog,status==PurchaseStatus.APPLIED?new PurchaseProof(plan.id(),plan.product(),plan.price(),catalog.offer(plan.product()).remaining()):null);}
|
||||
private RareCatalog ensureCurrent(RareCatalog.RefreshReason reason)throws IOException{var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));if(snapshot!=null&&snapshot.day().equals(today))return snapshot;if(snapshot!=null&&!snapshot.transactions().isEmpty())throw new IOException("Rare purchases unresolved at rollover");var generated=RareCatalogGenerator.generate(today,clock.instant().truncatedTo(java.time.temporal.ChronoUnit.SECONDS),reason,rules.get(),random);persist(generated,true);return generated;}
|
||||
private void persist(RareCatalog catalog,boolean notify)throws IOException{catalogs.save(catalog);snapshot=catalog;if(notify&&initialized)try{listener.accept(catalog);}catch(RuntimeException ignored){}}
|
||||
private void persist(RareCatalog catalog,boolean notify)throws IOException{catalogs.save(catalog);snapshot=catalog;if(notify)notifyRefresh(catalog);}
|
||||
private synchronized void notifyRefresh(RareCatalog catalog){if(!listenerAttached)pendingRefreshes.addLast(catalog);else deliver(catalog);}
|
||||
private void deliver(RareCatalog catalog){try{listener.accept(catalog);}catch(RuntimeException ignored){}}
|
||||
private <T>CompletionStage<T> submit(Callable<T> action){synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Rare market closed"));pending++;}return CompletableFuture.supplyAsync(()->{try{return action.call();}catch(Exception failure){throw new CompletionException(failure);}finally{finished();}},worker).minimalCompletionStage();}
|
||||
private synchronized void finished(){pending--;if(closed&&pending==0)closeOperations();}private void closeOperations(){if(operationsClosing)return;operationsClosing=true;operations.close();operations.drained().whenComplete((ignored,failure)->{if(failure==null)drained.complete(null);else drained.completeExceptionally(failure);});}public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)closeOperations();}
|
||||
}
|
||||
@@ -20,13 +20,18 @@ public final class RarePotionService {
|
||||
public enum Status{APPLIED,FULL_HEALTH,NO_EFFECT,NOT_AVAILABLE,STALE,FAILED}
|
||||
public record Outcome(Status status,BigDecimal healed){}
|
||||
private record Context(EquipmentSessions.Active active,CharacterRecord record){}
|
||||
private final RareInventoryDirectory inventory;private final RareSettings settings;private final CharacterStorage characters;private final EquipmentSessions sessions;private final Consumer<EntityRegainHealthEvent> healingEvents;private final Consumer<Runnable> nextTick;private final Consumer<Throwable> failures;
|
||||
private final RareInventoryDirectory inventory;private final RareSettings settings;private final CharacterStorage characters;private final EquipmentSessions sessions;private final Consumer<EntityRegainHealthEvent> healingEvents;private final Consumer<Runnable> nextTick;private final Consumer<Throwable> failures;private games.dmg.fantasystick.events.RareEventBridge events;
|
||||
public RarePotionService(RareInventoryDirectory inventory,RareSettings settings,CharacterStorage characters,EquipmentSessions sessions,Consumer<EntityRegainHealthEvent> healingEvents,Consumer<Runnable> nextTick,Consumer<Throwable> failures){this.inventory=Objects.requireNonNull(inventory);this.settings=Objects.requireNonNull(settings);this.characters=Objects.requireNonNull(characters);this.sessions=Objects.requireNonNull(sessions);this.healingEvents=Objects.requireNonNull(healingEvents);this.nextTick=Objects.requireNonNull(nextTick);this.failures=Objects.requireNonNull(failures);}
|
||||
public void attachEvents(games.dmg.fantasystick.events.RareEventBridge events){if(this.events!=null)throw new IllegalStateException("Rare potion events already attached");this.events=Objects.requireNonNull(events);}
|
||||
/** Ambiguous pre-restart native delivery is consumed but never replayed onto a replacement holder. */
|
||||
public void install(){for(var use:inventory.unresolvedUses())if(RareProduct.find(use.product()).map(RareProduct::heals).orElse(false))inventory.commitUse(use.operation()).exceptionally(failure->{report(failure);return RareInventoryDirectory.UseStatus.RESERVED;});}
|
||||
public BigInteger quantity(UUID character,RareProduct product){return inventory.quantity(character,product);}
|
||||
public void use(Player player,RareProduct product,Consumer<Outcome> result){if(!product.heals()){result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO));return;}var context=context(player);if(context.isEmpty()){result.accept(new Outcome(Status.STALE,BigDecimal.ZERO));return;}var captured=context.orElseThrow();var attribute=player.getAttribute(Attribute.MAX_HEALTH);double current=player.getHealth(),maximum=attribute==null?Double.NaN:attribute.getValue();if(!Double.isFinite(current)||!Double.isFinite(maximum)||maximum<=0||current>=maximum){result.accept(new Outcome(Status.FULL_HEALTH,BigDecimal.ZERO));return;}int percent=settings.rules().healingPercent().get(product);double proposed=Math.min(maximum-current,maximum*percent/100d);if(!Double.isFinite(proposed)||proposed<=0){result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO));return;}var event=new EntityRegainHealthEvent(player,proposed,EntityRegainHealthEvent.RegainReason.CUSTOM);healingEvents.accept(event);double accepted=Math.min(proposed,Math.min(maximum-current,event.getAmount()));if(event.isCancelled()||!Double.isFinite(accepted)||accepted<=0){result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO));return;}UUID operation=UUID.randomUUID();BigInteger expectedQuantity=inventory.quantity(captured.record().id(),product);BigDecimal before=BigDecimal.valueOf(current),limit=BigDecimal.valueOf(maximum).min(NativeHealthLimit.read()),target=BigDecimal.valueOf(current+accepted).min(limit);inventory.reserveUse(captured.record().id(),product,operation,expectedQuantity,before,limit,percent).whenComplete((reservation,reserveFailure)->{if(reserveFailure!=null){report(reserveFailure);publish(()->result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO)));return;}if(reservation.status()!=RareInventoryDirectory.UseStatus.RESERVED){publish(()->result.accept(new Outcome(reservation.status()==RareInventoryDirectory.UseStatus.NOT_AVAILABLE?Status.NOT_AVAILABLE:Status.STALE,BigDecimal.ZERO)));return;}characters.saveHealth(captured.record().id(),captured.record().revision(),target,limit).whenComplete((saved,saveFailure)->publish(()->complete(player,captured,operation,accepted,saved,saveFailure,result)));});}
|
||||
private void complete(Player player,Context captured,UUID operation,double accepted,Optional<CharacterRecord> saved,Throwable failure,Consumer<Outcome> result){if(failure!=null||saved==null||saved.isEmpty()){if(failure!=null)report(failure);inventory.refundUse(operation).whenComplete((ignored,refundFailure)->{if(refundFailure!=null)report(refundFailure);publish(()->result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO)));});return;}var persisted=saved.orElseThrow();var current=context(player);boolean acknowledged=false;try{acknowledged=current.isPresent()&¤t.orElseThrow().active().session().equals(captured.active().session())&&sessions.acknowledgeProgress(captured.active(),captured.record().revision(),persisted);}catch(RuntimeException uncertain){report(uncertain);}if(!acknowledged){inventory.commitUse(operation).whenComplete((ignored,commitFailure)->{if(commitFailure!=null)report(commitFailure);publish(()->result.accept(new Outcome(Status.STALE,BigDecimal.ZERO)));});return;}var attribute=player.getAttribute(Attribute.MAX_HEALTH);double now=player.getHealth(),maximum=attribute==null?Double.NaN:attribute.getValue(),desired=Math.min(maximum,persisted.health().doubleValue()),restored=Math.min(accepted,desired-now);if(!Double.isFinite(now)||!Double.isFinite(maximum)||!Double.isFinite(restored)||restored<=0){inventory.refundUse(operation).whenComplete((ignored,refundFailure)->{if(refundFailure!=null)report(refundFailure);publish(()->result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO)));});return;}try{player.setHealth(Math.min(maximum,now+restored));}catch(RuntimeException uncertain){report(uncertain);inventory.commitUse(operation);result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO));return;}inventory.commitUse(operation).whenComplete((status,commitFailure)->{if(commitFailure!=null){report(commitFailure);publish(()->result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO)));return;}publish(()->result.accept(new Outcome(Status.APPLIED,BigDecimal.valueOf(restored).stripTrailingZeros())));});}
|
||||
public void use(Player player,RareProduct product,Consumer<Outcome> result){if(!product.heals()){result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO));return;}var context=context(player);if(context.isEmpty()){result.accept(new Outcome(Status.STALE,BigDecimal.ZERO));return;}var captured=context.orElseThrow();var attribute=player.getAttribute(Attribute.MAX_HEALTH);double current=player.getHealth(),maximum=attribute==null?Double.NaN:attribute.getValue();if(!Double.isFinite(current)||!Double.isFinite(maximum)||maximum<=0||current>=maximum){result.accept(new Outcome(Status.FULL_HEALTH,BigDecimal.ZERO));return;}int percent=settings.rules().healingPercent().get(product);double proposed=Math.min(maximum-current,maximum*percent/100d);if(!Double.isFinite(proposed)||proposed<=0){result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO));return;}var event=new EntityRegainHealthEvent(player,proposed,EntityRegainHealthEvent.RegainReason.CUSTOM);healingEvents.accept(event);double accepted=Math.min(proposed,Math.min(maximum-current,event.getAmount()));if(event.isCancelled()||!Double.isFinite(accepted)||accepted<=0){result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO));return;}UUID operation=UUID.randomUUID();BigInteger expectedQuantity=inventory.quantity(captured.record().id(),product);BigDecimal before=BigDecimal.valueOf(current),limit=BigDecimal.valueOf(maximum).min(NativeHealthLimit.read()),target=BigDecimal.valueOf(current+accepted).min(limit);inventory.reserveUse(captured.record().id(),product,operation,expectedQuantity,before,limit,percent).whenComplete((reservation,reserveFailure)->{if(reserveFailure!=null){report(reserveFailure);publish(()->result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO)));return;}if(reservation.status()!=RareInventoryDirectory.UseStatus.RESERVED){publish(()->result.accept(new Outcome(reservation.status()==RareInventoryDirectory.UseStatus.NOT_AVAILABLE?Status.NOT_AVAILABLE:Status.STALE,BigDecimal.ZERO)));return;}characters.saveHealth(captured.record().id(),captured.record().revision(),target,limit).whenComplete((saved,saveFailure)->publish(()->complete(player,captured,operation,product,percent,expectedQuantity,accepted,saved,saveFailure,result)));});}
|
||||
private void complete(Player player,Context captured,UUID operation,RareProduct product,int percent,BigInteger quantityBefore,double accepted,Optional<CharacterRecord> saved,Throwable failure,Consumer<Outcome> result){if(failure!=null||saved==null||saved.isEmpty()){if(failure!=null)report(failure);inventory.refundUse(operation).whenComplete((ignored,refundFailure)->{if(refundFailure!=null)report(refundFailure);publish(()->result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO)));});return;}var persisted=saved.orElseThrow();var current=context(player);boolean acknowledged=false;try{acknowledged=current.isPresent()&¤t.orElseThrow().active().session().equals(captured.active().session())&&sessions.acknowledgeProgress(captured.active(),captured.record().revision(),persisted);}catch(RuntimeException uncertain){report(uncertain);}if(!acknowledged){inventory.commitUse(operation).whenComplete((ignored,commitFailure)->{if(commitFailure!=null)report(commitFailure);publish(()->result.accept(new Outcome(Status.STALE,BigDecimal.ZERO)));});return;}var attribute=player.getAttribute(Attribute.MAX_HEALTH);double now=player.getHealth(),maximum=attribute==null?Double.NaN:attribute.getValue(),desired=Math.min(maximum,persisted.health().doubleValue()),restored=Math.min(accepted,desired-now);if(!Double.isFinite(now)||!Double.isFinite(maximum)||!Double.isFinite(restored)||restored<=0){inventory.refundUse(operation).whenComplete((ignored,refundFailure)->{if(refundFailure!=null)report(refundFailure);publish(()->result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO)));});return;}try{player.setHealth(Math.min(maximum,now+restored));}catch(RuntimeException uncertain){report(uncertain);inventory.commitUse(operation);result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO));return;}var audit=events;var actor=audit==null?null:safePotionContext(audit,player,captured);var nativeBefore=BigDecimal.valueOf(now);var nativeAfter=BigDecimal.valueOf(now+restored);var nativeMaximum=BigDecimal.valueOf(maximum);inventory.commitUse(operation).whenComplete((status,commitFailure)->{if(commitFailure!=null){report(commitFailure);publish(()->result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO)));return;}if(status==RareInventoryDirectory.UseStatus.COMMITTED&&audit!=null&&actor!=null)audit.potionUsed(operation,product,actor,quantityBefore,percent,nativeBefore,nativeAfter,nativeMaximum,BigDecimal.valueOf(accepted),BigDecimal.valueOf(restored),captured.record().revision(),persisted.revision());publish(()->result.accept(new Outcome(Status.APPLIED,BigDecimal.valueOf(restored).stripTrailingZeros())));});}
|
||||
private games.dmg.fantasystick.events.EventInput.Context safePotionContext(games.dmg.fantasystick.events.RareEventBridge audit,Player player,Context captured){
|
||||
try{return audit.potionContext(player,captured.record().id(),captured.record().name(),captured.active().session());}
|
||||
catch(RuntimeException failure){report(failure);return null;}
|
||||
}
|
||||
private Optional<Context> context(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);return record==null?Optional.empty():Optional.of(new Context(active,record));}
|
||||
private void publish(Runnable action){try{nextTick.accept(action);}catch(RuntimeException failure){report(failure);}}private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}
|
||||
}
|
||||
@@ -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==RareProduct.PHOENIX_DOWN&&purchase.proof()!=null&&events!=null){var after=inventory.quantity(captured.record().id(),product);events.purchased(purchase.proof().operation(),purchase.catalog().revision(),player.getUniqueId(),player.getName(),captured.record().id(),captured.record().name(),captured.active().session(),purchase.proof().price(),purchase.proof().stockAfter()+1,purchase.proof().stockAfter(),after.subtract(java.math.BigInteger.ONE),after);}}result.accept(new Outcome(status,purchase.record()));});});}
|
||||
public void buy(Player player,UUID shopId,UUID catalogRevision,RareProduct product,Consumer<Outcome> result){var lease=shops.acquire(shopId);if(lease.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var held=lease.orElseThrow();var context=context(player,shopId);if(context.isEmpty()){held.close();result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var captured=context.orElseThrow();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){}}
|
||||
}
|
||||
@@ -393,11 +393,14 @@ class AdventureQuestNativeTest {
|
||||
await(restarted,()->!fishing.trophies(carrier).isEmpty());
|
||||
assertEquals(finalEnemy.toString(),fishing.trophies(carrier).getFirst().audit()
|
||||
.get("final_enemy_id"));
|
||||
awaitCampExport(restarted,data,receiptId);
|
||||
restarted.plugin.setEnabled(false);
|
||||
}
|
||||
var earned=exportedCampEvents(data).stream().filter(event->event.get("type").getAsString()
|
||||
.endsWith("adventure.trophy_earned")).toList();
|
||||
assertEquals(1,earned.size());
|
||||
assertEquals(1,earned.size(),()->"Exported camp earned count="+earned.size()+" records="
|
||||
+earned.stream().map(event->"id="+event.get("id").getAsString()+",receipt="
|
||||
+event.getAsJsonObject("data").get("receipt_id").getAsString()).toList());
|
||||
var details=earned.getFirst().getAsJsonObject("data");
|
||||
assertEquals(receiptId.toString(),details.get("receipt_id").getAsString());
|
||||
assertEquals(finalEnemy.toString(),details.get("final_enemy_id").getAsString());
|
||||
@@ -779,6 +782,30 @@ class AdventureQuestNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static void awaitCampExport(FantasyStickLifecycleNativeTest.Fixture fixture,Path root,
|
||||
UUID receipt)throws Exception{
|
||||
var eventId=UUID.nameUUIDFromBytes(("fantasystick:fishing:"+receipt
|
||||
+":adventure.trophy_earned").getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
var ownerIntent=root.resolve("fishing-event-intents").resolve(eventId+".pending");
|
||||
var logIntent=root.resolve("events/pending").resolve(eventId+".pending");
|
||||
for(int attempt=0;attempt<200;attempt++){
|
||||
if(hasExportedCampReceipt(root,receipt)&&java.nio.file.Files.notExists(ownerIntent)
|
||||
&&java.nio.file.Files.notExists(logIntent))return;
|
||||
var task=fixture.tasks.poll(25,java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
if(task!=null)task.run();
|
||||
}
|
||||
assertTrue(hasExportedCampReceipt(root,receipt)
|
||||
&&java.nio.file.Files.notExists(ownerIntent)&&java.nio.file.Files.notExists(logIntent),
|
||||
"Committed camp receipt "+receipt+" did not complete exported JSONL/intent handoff before shutdown"
|
||||
+"; owner_pending="+java.nio.file.Files.exists(ownerIntent)
|
||||
+"; log_pending="+java.nio.file.Files.exists(logIntent));
|
||||
}
|
||||
private static boolean hasExportedCampReceipt(Path root,UUID receipt){
|
||||
try{return exportedCampEvents(root).stream().anyMatch(event->event.get("type").getAsString()
|
||||
.endsWith("adventure.trophy_earned")&&event.getAsJsonObject("data")
|
||||
.get("receipt_id").getAsString().equals(receipt.toString()));}
|
||||
catch(Exception transientExport){return false;}
|
||||
}
|
||||
private static List<com.google.gson.JsonObject> exportedCampEvents(Path root)throws Exception{
|
||||
try(var files=java.nio.file.Files.walk(root.resolve("events"))){
|
||||
return files.filter(path->path.toString().endsWith(".jsonl"))
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestState;
|
||||
import games.dmg.fantasystick.dungeon.DungeonDirectory;
|
||||
import games.dmg.fantasystick.dungeon.DungeonState;
|
||||
import games.dmg.fantasystick.events.DungeonEventBridge;
|
||||
import games.dmg.fantasystick.events.FishingEventBridge;
|
||||
import games.dmg.fantasystick.events.LocalEventLog;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class DungeonEventBridgePartsNativeTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void rejectedCommittedRewardAndSingleBossClosureReplaySameProofIntoExportedJsonl() throws Exception {
|
||||
UUID dungeonId=UUID.randomUUID(),roomId=UUID.randomUUID(),generation=UUID.randomUUID();
|
||||
UUID boss=UUID.randomUUID(),receipt=UUID.randomUUID();
|
||||
var empty=new DungeonState.Reward(BigInteger.ZERO,BigInteger.ZERO);
|
||||
var payout=new DungeonState.Reward(BigInteger.valueOf(30),BigInteger.valueOf(5));
|
||||
var entrance=new DungeonState.Position(1,64,1,0,0);
|
||||
var before=new DungeonState.Dungeon(dungeonId,"Frozen Dungeon",UUID.randomUUID(),
|
||||
entrance,false,UUID.randomUUID(),List.of(),AdventureQuestState.ParticipationMode.PARTY_AVAILABLE,
|
||||
empty,false);
|
||||
var after=new DungeonState.Dungeon(dungeonId,before.name(),before.world(),entrance,false,
|
||||
UUID.randomUUID(),List.of(),before.participationMode(),payout,true);
|
||||
var room=new DungeonState.Room(roomId,"Boss Room",
|
||||
new DungeonState.Bounds(0,63,0,3,68,3),List.of(),List.of(),empty,null);
|
||||
var unit=UUID.randomUUID();
|
||||
var audit=new LinkedHashMap<String,String>();
|
||||
audit.put("dungeon_name",after.name());audit.put("room_id",roomId.toString());
|
||||
audit.put("boss_id",boss.toString());
|
||||
audit.put("defeated_at",Clock.systemUTC().instant().truncatedTo(
|
||||
java.time.temporal.ChronoUnit.SECONDS).toString());
|
||||
audit.put("defeat_world_id",before.world().toString());
|
||||
audit.put("defeat_world_name","world");
|
||||
audit.put("defeat_x","2.0");audit.put("defeat_y","64.0");audit.put("defeat_z","2.0");
|
||||
audit.put("represented_count","1");audit.put("represented_0_quest_id",UUID.randomUUID().toString());
|
||||
audit.put("represented_0_unit_kind","solo");audit.put("represented_0_unit_id",unit.toString());
|
||||
audit.put("represented_0_accepted_roster",unit.toString());
|
||||
audit.put("represented_0_receipt_id",receipt.toString());
|
||||
var run=new DungeonState.Run(dungeonId,generation,Set.of(),Set.of(roomId),Set.of(),Set.of(),true,audit);
|
||||
var operation=UUID.randomUUID();
|
||||
var rejected=new FishingEventBridge(event->CompletableFuture.completedFuture(
|
||||
LocalEventLog.Delivery.REJECTED),Clock.systemUTC(),data,mock(EquipmentSessions.class),ignored->{});
|
||||
var bridge=new DungeonEventBridge(rejected);
|
||||
bridge.rewardChanged(operation,null,
|
||||
new DungeonDirectory.CompletionRewardChange(before,after));
|
||||
bridge.completed(run);
|
||||
bridge.completed(new DungeonState.Run(dungeonId,UUID.randomUUID(),Set.of(),Set.of(roomId),
|
||||
Set.of(),Set.of(),true)); // Legacy defeated runs have no captured native proof.
|
||||
rejected.close();rejected.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
|
||||
var log=LocalEventLog.open(data,Clock.systemUTC(),
|
||||
new LocalEventLog.RuntimeInfo("test","test","test"),128,1_000_000,ignored->{});
|
||||
log.ready().toCompletableFuture().get(5,TimeUnit.SECONDS);
|
||||
var recovered=new FishingEventBridge(log::emit,Clock.systemUTC(),data,
|
||||
mock(EquipmentSessions.class),ignored->{});
|
||||
recovered.replay();recovered.close();recovered.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
|
||||
log.close();
|
||||
var rows=new ArrayList<JsonObject>();
|
||||
try(var files=Files.walk(data.resolve("events"))){
|
||||
for(var path:files.filter(value->value.toString().endsWith(".jsonl")).toList())
|
||||
for(var line:Files.readAllLines(path))rows.add(JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
var changes=rows.stream().filter(row->row.get("type").getAsString()
|
||||
.endsWith("dungeon.reward_changed")).toList();
|
||||
var completions=rows.stream().filter(row->row.get("type").getAsString()
|
||||
.endsWith("dungeon.completed")).toList();
|
||||
assertEquals(1,changes.size());assertEquals(1,completions.size());
|
||||
assertEquals("system",changes.getFirst().getAsJsonObject("data")
|
||||
.get("actor_kind").getAsString());
|
||||
assertEquals("unavailable",changes.getFirst().getAsJsonObject("data")
|
||||
.get("actor_identity_status").getAsString(),
|
||||
"missing actor identity must not erase a committed change or invent a console");
|
||||
assertEquals(before.revision().toString(),changes.getFirst().getAsJsonObject("data")
|
||||
.get("before_revision").getAsString());
|
||||
assertEquals(after.revision().toString(),changes.getFirst().getAsJsonObject("data")
|
||||
.get("after_revision").getAsString());
|
||||
assertEquals(boss.toString(),completions.getFirst().getAsJsonObject("data")
|
||||
.get("boss_id").getAsString());
|
||||
assertEquals(receipt.toString(),completions.getFirst().getAsJsonObject("data")
|
||||
.getAsJsonArray("receipt_ids").get(0).getAsString());
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import games.dmg.fantasystick.dungeon.DungeonDirectory;
|
||||
import games.dmg.fantasystick.dungeon.DungeonState;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@@ -67,7 +68,7 @@ class DungeonNativeTest {
|
||||
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);doReturn(true).when(actor.player).hasPermission("fantasystick.admin");
|
||||
var actorEffects=new HashMap<org.bukkit.potion.PotionEffectType,org.bukkit.potion.PotionEffect>();doAnswer(call->Set.copyOf(actorEffects.values())).when(actor.player).getActivePotionEffects();doAnswer(call->{var effect=(org.bukkit.potion.PotionEffect)call.getArgument(0);actorEffects.put(effect.getType(),effect);return true;}).when(actor.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());doAnswer(call->{actorEffects.remove((org.bukkit.potion.PotionEffectType)call.getArgument(0));return null;}).when(actor.player).removePotionEffect(any(org.bukkit.potion.PotionEffectType.class));UUID worldId=UUID.randomUUID();when(actor.world.getUID()).thenReturn(worldId);when(actor.world.getName()).thenReturn("Dungeon Realm");when(actor.world.isChunkLoaded(anyInt(),anyInt())).thenReturn(true);
|
||||
var spawned=new ArrayList<LivingEntity>();
|
||||
doAnswer(call->{Class<?> type=call.getArgument(1);LivingEntity entity=mock(type.asSubclass(LivingEntity.class));var pdc=new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());when(entity.getPersistentDataContainer()).thenReturn(pdc);when(entity.getUniqueId()).thenReturn(UUID.randomUUID());when(entity.getType()).thenReturn(type==Husk.class?EntityType.HUSK:EntityType.VINDICATOR);when(entity.getWorld()).thenReturn(actor.world);when(entity.getLocation()).thenReturn(((Location)call.getArgument(0)).clone());((java.util.function.Consumer<LivingEntity>)call.getArgument(2)).accept(entity);spawned.add(entity);return entity;}).when(actor.world).spawn(any(Location.class),any(Class.class),any(java.util.function.Consumer.class));
|
||||
doAnswer(call->{Class<?> type=call.getArgument(1);LivingEntity entity=mock(type.asSubclass(LivingEntity.class));var pdc=new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());when(entity.getPersistentDataContainer()).thenReturn(pdc);when(entity.getUniqueId()).thenReturn(UUID.randomUUID());when(entity.getType()).thenReturn(type==Husk.class?EntityType.HUSK:type==Villager.class?EntityType.VILLAGER:EntityType.VINDICATOR);when(entity.getWorld()).thenReturn(actor.world);when(entity.getLocation()).thenReturn(((Location)call.getArgument(0)).clone());((java.util.function.Consumer<LivingEntity>)call.getArgument(2)).accept(entity);spawned.add(entity);return entity;}).when(actor.world).spawn(any(Location.class),any(Class.class),any(java.util.function.Consumer.class));
|
||||
when(actor.world.getEntities()).thenAnswer(call->new ArrayList<Entity>(spawned));
|
||||
assertEquals(List.of("dungeon"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"dung"}));var denied=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(denied.core.id,denied.player);doReturn(false).when(denied.player).hasPermission("fantasystick.admin");f.command.execute(denied.player,"fantasystick",new String[]{"dungeon","create","Forbidden"});assertTrue(denied.messages.getLast().contains("Permission denied"));assertTrue(f.command.tabComplete(denied.player,"fantasystick",new String[]{"dungeon",""}).isEmpty());
|
||||
command(f,actor,"dungeon","create","Sunken","Keep");var dungeons=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var dungeon=dungeons.snapshot().dungeons().getFirst();command(f,actor,"dungeon","reward",dungeon.id().toString());assertTrue(actor.messages.getLast().contains("not configured"));
|
||||
@@ -82,12 +83,124 @@ class DungeonNativeTest {
|
||||
var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");assertTrue(adventures.snapshot().catalog().sites().contains(dungeon.id()));assertEquals(new AdventureQuestDirectory.Reward(BigInteger.valueOf(30),BigInteger.valueOf(5)),adventures.reward(adventures.findSite(dungeon.id()).orElseThrow()));
|
||||
f.activate(actor);var questController=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");questController.open(actor.player);drain(f);var menu=actor.inventories.getLast();assertEquals(Material.PAPER,menu.getItem(0).getType());var offerLore=menu.getItem(0).getItemMeta().lore().toString();assertTrue(offerLore.contains("Completion trophy pool: 30 XP, 5 coins"));assertTrue(offerLore.contains("additional variable XP, coins and native loot"));var click=mock(InventoryClickEvent.class);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getInventory()).thenReturn(menu);when(click.getCurrentItem()).thenReturn(menu.getItem(0));questController.click(click);drain(f);assertEquals(dungeon.id(),adventures.active().getFirst().site());var partyA=new FantasyStickLifecycleNativeTest.Actor(data);var partyB=new FantasyStickLifecycleNativeTest.Actor(data);for(var member:List.of(partyA,partyB)){member.core.location.setWorld(actor.world);member.core.location.setX(100);f.players.put(member.core.id,member.player);doReturn(java.util.Set.of()).when(member.player).getActivePotionEffects();doReturn(true).when(member.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());f.activate(member);}var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(partyA.core.id).orElseThrow();assertTrue(parties.accept(partyB.core.id,parties.invite(partyA.core.id,party.id(),partyB.core.id).orElseThrow().id()));var catalog=adventures.snapshot().catalog();assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.PARTY,party.id(),Set.of(partyA.core.record.id(),partyB.core.record.id())).get(5,TimeUnit.SECONDS).status());
|
||||
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var before=storage.find(actor.core.record.id()).get().orElseThrow();var partyABefore=storage.find(partyA.core.record.id()).get().orElseThrow();var partyBBefore=storage.find(partyB.core.record.id()).get().orElseThrow();for(var member:List.of(actor,partyA)){member.core.location.setX(first.bounds().minX()+.5);member.core.location.setY(first.bounds().minY()+1);member.core.location.setZ(first.bounds().minZ()+.5);}
|
||||
pulse(f);drain(f);pulse(f);drain(f);var initialMobs=spawned.stream().filter(Husk.class::isInstance).toList();assertEquals(2,initialMobs.size());var ordinary=initialMobs.getFirst();assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,dungeonController.encounterOrigin(ordinary).orElseThrow().kind());var survivor=initialMobs.getLast();assertNotEquals(dungeonController.encounterOrigin(ordinary),dungeonController.encounterOrigin(survivor),"each slot has independent kill authority");var survivorOrigin=dungeonController.encounterOrigin(survivor);var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);var partySource=mock(org.bukkit.damage.DamageSource.class);when(partySource.getCausingEntity()).thenReturn(partyA.player);when(partySource.getDirectEntity()).thenReturn(partyA.player);f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,ordinary,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,ordinary,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));var drops=new ArrayList<ItemStack>();drops.add(new ItemStack(Material.ROTTEN_FLESH));var ordinaryDeath=new EntityDeathEvent(ordinary,source,drops,7);f.dispatcher.accept(ordinaryDeath);await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).map(run->run.defeatedSlots().size()==1).orElse(false),actor.messages);assertEquals(7,ordinaryDeath.getDroppedExp());assertFalse(ordinaryDeath.getDrops().isEmpty());var partial=dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow();assertEquals(1,partial.defeatedSlots().size());assertFalse(partial.clearedRooms().contains(first.id()));for(var member:List.of(actor,partyA))member.core.location.setX(100);pulse(f);verify(survivor,atLeastOnce()).remove();assertFalse(actorEffects.containsKey(org.bukkit.potion.PotionEffectType.SPEED));for(var member:List.of(actor,partyA)){member.core.location.setX(first.bounds().minX()+.5);member.core.location.setY(first.bounds().minY()+1);member.core.location.setZ(first.bounds().minZ()+.5);}pulse(f);drain(f);var respawned=spawned.stream().filter(Husk.class::isInstance).toList().getLast();assertNotSame(survivor,respawned);assertEquals(survivorOrigin,dungeonController.encounterOrigin(respawned),"respawned slot retains duplicate-safe kill authority");f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,respawned,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,respawned,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));f.dispatcher.accept(new EntityDeathEvent(respawned,source,new ArrayList<>(),0));await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow().clearedRooms().contains(first.id()),actor.messages);assertTrue(storage.find(partyB.core.record.id()).get().orElseThrow().experience().compareTo(partyBBefore.experience())>0,"ordinary party combat sharing remains active");
|
||||
pulse(f);drain(f);pulse(f);drain(f);var initialMobs=spawned.stream().filter(Husk.class::isInstance).toList();assertEquals(2,initialMobs.size());var ordinary=initialMobs.getFirst();assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,dungeonController.encounterOrigin(ordinary).orElseThrow().kind());var copiedSlot=mock(Husk.class);when(copiedSlot.getUniqueId()).thenReturn(UUID.randomUUID());var copiedProof=ordinary.getPersistentDataContainer();when(copiedSlot.getPersistentDataContainer()).thenReturn(copiedProof);assertTrue(dungeonController.encounterOrigin(copiedSlot).isEmpty(),"a copied valid dungeon slot tag is not an authenticated registered spawn");var nativeDrop=new ItemStack(Material.ROTTEN_FLESH);var forgedDeath=new EntityDeathEvent(copiedSlot,mock(org.bukkit.damage.DamageSource.class),new ArrayList<>(List.of(nativeDrop)),7);f.dispatcher.accept(forgedDeath);assertEquals(7,forgedDeath.getDroppedExp());assertEquals(List.of(nativeDrop),forgedDeath.getDrops());assertTrue(dungeonController.encounterOrigin(ordinary).isPresent(),"copied callback did not consume the real dungeon slot");var survivor=initialMobs.getLast();assertNotEquals(dungeonController.encounterOrigin(ordinary),dungeonController.encounterOrigin(survivor),"each slot has independent kill authority");var survivorOrigin=dungeonController.encounterOrigin(survivor);var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);var partySource=mock(org.bukkit.damage.DamageSource.class);when(partySource.getCausingEntity()).thenReturn(partyA.player);when(partySource.getDirectEntity()).thenReturn(partyA.player);f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,ordinary,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,ordinary,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));var drops=new ArrayList<ItemStack>();drops.add(new ItemStack(Material.ROTTEN_FLESH));var ordinaryDeath=new EntityDeathEvent(ordinary,source,drops,7);f.dispatcher.accept(ordinaryDeath);await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).map(run->run.defeatedSlots().size()==1).orElse(false),actor.messages);assertEquals(7,ordinaryDeath.getDroppedExp());assertFalse(ordinaryDeath.getDrops().isEmpty());var partial=dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow();assertEquals(1,partial.defeatedSlots().size());assertFalse(partial.clearedRooms().contains(first.id()));for(var member:List.of(actor,partyA))member.core.location.setX(100);pulse(f);verify(survivor,atLeastOnce()).remove();assertFalse(actorEffects.containsKey(org.bukkit.potion.PotionEffectType.SPEED));for(var member:List.of(actor,partyA)){member.core.location.setX(first.bounds().minX()+.5);member.core.location.setY(first.bounds().minY()+1);member.core.location.setZ(first.bounds().minZ()+.5);}pulse(f);drain(f);var respawned=spawned.stream().filter(Husk.class::isInstance).toList().getLast();assertNotSame(survivor,respawned);assertEquals(survivorOrigin,dungeonController.encounterOrigin(respawned),"respawned slot retains duplicate-safe kill authority");f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,respawned,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,respawned,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));f.dispatcher.accept(new EntityDeathEvent(respawned,source,new ArrayList<>(),0));await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow().clearedRooms().contains(first.id()),actor.messages);assertTrue(storage.find(partyB.core.record.id()).get().orElseThrow().experience().compareTo(partyBBefore.experience())>0,"ordinary party combat sharing remains active");
|
||||
var actorAfterKills=storage.find(actor.core.record.id()).get().orElseThrow();var partyAAfterKills=storage.find(partyA.core.record.id()).get().orElseThrow();var partyBAfterKills=storage.find(partyB.core.record.id()).get().orElseThrow();for(var member:List.of(actor,partyA,partyB)){member.core.location.setX(bossRoom.bounds().minX()+.5);member.core.location.setY(bossRoom.bounds().minY()+1);member.core.location.setZ(bossRoom.bounds().minZ()+.5);}pulse(f);drain(f);var firstBoss=spawned.stream().filter(Vindicator.class::isInstance).findFirst().orElseThrow();for(var member:List.of(actor,partyA,partyB))member.core.location.setX(100);pulse(f);verify(firstBoss,atLeastOnce()).remove();for(var member:List.of(actor,partyA,partyB)){member.core.location.setX(bossRoom.bounds().minX()+.5);member.core.location.setY(bossRoom.bounds().minY()+1);member.core.location.setZ(bossRoom.bounds().minZ()+.5);}pulse(f);drain(f);var boss=spawned.stream().filter(Vindicator.class::isInstance).toList().getLast();assertNotSame(firstBoss,boss);f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,boss,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,boss,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDeathEvent(boss,source,new ArrayList<>(),0));assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,dungeonController.encounterOrigin(boss).orElseThrow().kind(),"boss cleanup retains immutable managed origin authority");
|
||||
await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).map(DungeonState.Run::defeated).orElse(false)&&adventures.active().isEmpty()&&storage.find(actor.core.record.id()).join().orElseThrow().experience().compareTo(actorAfterKills.experience())>0,actor.messages);var run=dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow();assertTrue(run.defeated(),"dungeon run not defeated");assertFalse(run.clearedRooms().contains(skipped.id()),"boss death completes without clearing every room");assertTrue(adventures.active().isEmpty(),"boss did not close all dungeon quests");assertTrue(adventures.characterClaimed(catalog.revision(),dungeon.id(),actor.core.record.id()),"solo completion claim missing");assertTrue(adventures.characterClaimed(catalog.revision(),dungeon.id(),partyA.core.record.id()),"represented party member claim missing");assertTrue(adventures.characterClaimed(catalog.revision(),dungeon.id(),partyB.core.record.id()),"present snapshotted party member claim missing");var after=storage.find(actor.core.record.id()).get().orElseThrow();assertTrue(after.experience().compareTo(before.experience())>0,"ordinary dungeon kills award standard combat XP");assertTrue(after.coins().compareTo(before.coins())>0,"ordinary dungeon kills award standard character coins");assertTrue(after.experience().compareTo(actorAfterKills.experience())>0,"genuinely killed dungeon boss grants native-classified per-kill XP");assertTrue(after.coins().compareTo(actorAfterKills.coins())>0,"genuinely killed dungeon boss grants native-classified per-kill coins");assertEquals(AdventureQuestDirectory.Reward.empty(),adventures.unclaimed(catalog.revision(),dungeon.id(),actor.core.record.id()));var partyAAfter=storage.find(partyA.core.record.id()).get().orElseThrow();var partyBAfter=storage.find(partyB.core.record.id()).get().orElseThrow();assertTrue(partyAAfter.experience().compareTo(partyABefore.experience())>0,"party contributor receives normal kill XP");assertTrue(partyBAfter.experience().compareTo(partyBBefore.experience())>0);assertTrue(partyAAfter.experience().compareTo(partyAAfterKills.experience())>0);assertTrue(partyBAfter.experience().compareTo(partyBAfterKills.experience())>0);var trophies=(games.dmg.fantasystick.adventure.FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");var soloTrophy=trophies.trophies(actor.core.record.id()).getFirst();var partyTrophy=trophies.trophies(partyA.core.record.id()).getFirst();assertEquals(games.dmg.fantasystick.adventure.FishingState.TrophyType.DUNGEON,soloTrophy.type());assertEquals("Trophy of Sunken Keep",soloTrophy.name());assertEquals(new games.dmg.fantasystick.adventure.FishingState.Reward(BigInteger.valueOf(30),BigInteger.valueOf(5)),soloTrophy.total());assertEquals(new games.dmg.fantasystick.adventure.FishingState.Reward(BigInteger.valueOf(30),BigInteger.valueOf(5)),partyTrophy.total());assertEquals(Set.of(partyA.core.record.id(),partyB.core.record.id()),partyTrophy.rewards().keySet());assertEquals(AdventureQuestDirectory.Reward.empty(),adventures.unclaimed(catalog.revision(),dungeon.id(),partyA.core.record.id()));var defeatedGeneration=adventures.snapshot().catalog().revision();command(f,actor,"dungeon","close",dungeon.id().toString(),"confirm");command(f,actor,"dungeon","open",dungeon.id().toString());assertTrue(actor.messages.getLast().contains("open refused"),actor.messages.toString());assertTrue(dungeons.runView(dungeon.id(),defeatedGeneration).orElseThrow().defeated(),"defeated generation changed");command(f,actor,"adventure","reroll","confirm");await(f,()->adventures.snapshot().reset()==null&&!adventures.snapshot().catalog().revision().equals(defeatedGeneration),actor.messages);assertEquals(1,trophies.trophies(actor.core.record.id()).size(),"reroll preserves earned dungeon trophy");var claimBase=storage.find(actor.core.record.id()).get().orElseThrow();var turn=(games.dmg.fantasystick.adventure.FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");turn.open(actor.player);var trophyMenu=actor.inventories.getLast();var plain=net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText();var redeem=java.util.stream.IntStream.range(0,trophyMenu.getSize()).mapToObj(trophyMenu::getItem).filter(Objects::nonNull).filter(item->item.getItemMeta().displayName()!=null&&plain.serialize(item.getItemMeta().displayName()).equals("Redeem Trophy of Sunken Keep")).findFirst().orElseThrow();var redeemClick=mock(InventoryClickEvent.class);when(redeemClick.getWhoClicked()).thenReturn(actor.player);when(redeemClick.getInventory()).thenReturn(trophyMenu);when(redeemClick.getCurrentItem()).thenReturn(redeem);turn.click(redeemClick);await(f,()->trophies.trophies(actor.core.record.id()).isEmpty()&&trophies.unclaimed(actor.core.record.id()).equals(new games.dmg.fantasystick.adventure.FishingState.Reward(BigInteger.valueOf(30),BigInteger.valueOf(5))),actor.meLine truncated
|
||||
|
||||
await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).map(DungeonState.Run::defeated).orElse(false)&&adventures.active().isEmpty()&&storage.find(actor.core.record.id()).join().orElseThrow().experience().compareTo(actorAfterKills.experience())>0,actor.messages);var run=dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow();assertTrue(run.defeated(),"dungeon run not defeated");assertEquals(boss.getUniqueId().toString(),run.completionAudit().get("boss_id"),"global completion must retain immutable native boss proof for recovery");assertFalse(run.clearedRooms().contains(skipped.id()),"boss death completes without clearing every room");assertTrue(adventures.active().isEmpty(),"boss did not close all dungeon quests");assertTrue(adventures.characterClaimed(catalog.revision(),dungeon.id(),actor.core.record.id()),"solo completion claim missing");assertTrue(adventures.characterClaimed(catalog.revision(),dungeon.id(),partyA.core.record.id()),"represented party member claim missing");assertTrue(adventures.characterClaimed(catalog.revision(),dungeon.id(),partyB.core.record.id()),"present snapshotted party member claim missing");var after=storage.find(actor.core.record.id()).get().orElseThrow();assertTrue(after.experience().compareTo(before.experience())>0,"ordinary dungeon kills award standard combat XP");assertTrue(after.coins().compareTo(before.coins())>0,"ordinary dungeon kills award standard character coins");assertTrue(after.experience().compareTo(actorAfterKills.experience())>0,"genuinely killed dungeon boss grants native-classified per-kill XP");assertTrue(after.coins().compareTo(actorAfterKills.coins())>0,"genuinely killed dungeon boss grants native-classified per-kill coins");assertEquals(AdventureQuestDirectory.Reward.empty(),adventures.unclaimed(catalog.revision(),dungeon.id(),actor.core.record.id()));var partyAAfter=storage.find(partyA.core.record.id()).get().orElseThrow();var partyBAfter=storage.find(partyB.core.record.id()).get().orElseThrow();assertTrue(partyAAfter.experience().compareTo(partyABefore.experience())>0,"party contributor receives normal kill XP");assertTrue(partyBAfter.experience().compareTo(partyBBefore.experience())>0);assertTrue(partyAAfter.experience().compareTo(partyAAfterKills.experience())>0);assertTrue(partyBAfter.experience().compareTo(partyBAfterKills.experience())>0);var trophies=(games.dmg.fantasystick.adventure.FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");var soloTrophy=trophies.trophies(actor.core.record.id()).getFirst();var partyTrophy=trophies.trophies(partyA.core.record.id()).getFirst();assertEquals(games.dmg.fantasystick.adventure.FishingState.TrophyType.DUNGEON,soloTrophy.type());assertEquals("Trophy of Sunken Keep",soloTrophy.name());assertEquals(new games.dmg.fantasystick.adventure.FishingState.Reward(BigInteger.valueOf(30),BigInteger.valueOf(5)),soloTrophy.total());assertEquals(new games.dmg.fantasystick.adventure.FishingState.Reward(BigInteger.valueOf(30),BigInteger.valueOf(5)),partyTrophy.total());assertEquals(Set.of(partyA.core.record.id(),partyB.core.record.id()),partyTrophy.rewards().keySet());assertEquals(AdventureQuestDirectory.Reward.empty(),adventures.unclaimed(catalog.revision(),dungeon.id(),partyA.core.record.id()));var defeatedGeneration=adventures.snapshot().catalog().revision();command(f,actor,"dungeon","close",dungeon.id().toString(),"confirm");command(f,actor,"dungeon","open",dungeon.id().toString());assertTrue(actor.messages.getLast().contains("open refused"),actor.messages.toString());assertTrue(dungeons.runView(dungeon.id(),defeatedGeneration).orElseThrow().defeated(),"defeated generation changed");command(f,actor,"adventure","reroll","confirm");await(f,()->adventures.snapshot().reset()==null&&!adventures.snapshot().catalog().revision().equals(defeatedGeneration),actor.messages);assertEquals(1,trophies.trophies(actor.core.record.id()).size(),"reroll preserves earned dungeon trophy");var claimBase=storage.find(actor.core.record.id()).get().orElseThrow();var turn=(games.dmg.fantasystick.adventure.FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");var npcChunk=mock(Chunk.class);when(npcChunk.getWorld()).thenReturn(actor.world);when(npcChunk.getX()).thenReturn(actor.core.location.getBlockX()>>4);when(npcChunk.getZ()).thenReturn(actor.core.location.getBlockZ()>>4);when(npcChunk.getEntities()).thenReturn(new Entity[0]);when(actor.world.getChunkAt(anyInt(),anyInt())).thenReturn(npcChunk);when(actor.world.getLoadedChunks()).thenReturn(new Chunk[]{npcChunk});command(f,actor,"quest-giver","place","Dungeon Steward");var giverLocation=adventures.snapshot().givers().getFirst();actor.core.location.setX(giverLocation.x());actor.core.location.setZ(giverLocation.z());questController.reconcile(npcChunk);var giver=spawned.stream().filter(Villager.class::isInstance).map(Villager.class::cast).findFirst().orElseThrow(Line truncated
|
||||
await(f,()->exportedDungeonJourneyReady(data),actor.messages);
|
||||
}
|
||||
var exported=exportedDungeonEvents(data);
|
||||
var changes=exported.stream().filter(event->event.get("type").getAsString()
|
||||
.endsWith("dungeon.reward_changed")).toList();
|
||||
assertEquals(1,changes.size(),
|
||||
"the registered persisted dungeon-wide reward change needs one exported event");
|
||||
var changed=changes.getFirst().getAsJsonObject("data");
|
||||
assertFalse(changed.get("before_configured").getAsBoolean());
|
||||
assertEquals("0",changed.get("before_xp").getAsString());
|
||||
assertEquals("30",changed.get("after_xp").getAsString());
|
||||
assertEquals("5",changed.get("after_coins").getAsString());
|
||||
assertNotEquals(changed.get("before_revision").getAsString(),
|
||||
changed.get("after_revision").getAsString());
|
||||
assertEquals("future_acceptances",changed.get("application_scope").getAsString());
|
||||
assertEquals(2,exported.stream().filter(event->event.get("type").getAsString()
|
||||
.endsWith("adventure.trophy_earned")&&event.getAsJsonObject("data")
|
||||
.get("trophy_type").getAsString().equals("dungeon_trophy")).count(),
|
||||
"the two represented units need distinct exported earned receipts");
|
||||
assertEquals(1,exported.stream().filter(event->event.get("type").getAsString()
|
||||
.endsWith("dungeon.completed")).count(),
|
||||
"boss death closes the whole dungeon once, not once per trophy");
|
||||
var kills=exported.stream().filter(event->event.get("type").getAsString()
|
||||
.endsWith("combat.reward_resolved")&&event.getAsJsonObject("data")
|
||||
.get("managed_origin_kind").getAsString().equals("dungeon")).toList();
|
||||
assertTrue(kills.size()>=3,"ordinary room and boss kills each keep their per-recipient result");
|
||||
assertTrue(kills.stream().allMatch(event->event.getAsJsonObject("data").has("dungeon_id")
|
||||
&&event.getAsJsonObject("data").has("room_id")
|
||||
&&event.getAsJsonObject("data").has("generation_id")
|
||||
&&event.getAsJsonObject("data").has("spawn_slot_or_boss")),
|
||||
"managed kill awards must preserve authenticated room and spawn evidence");
|
||||
var earned=exported.stream().filter(event->event.get("type").getAsString()
|
||||
.endsWith("adventure.trophy_earned")&&event.getAsJsonObject("data")
|
||||
.get("trophy_type").getAsString().equals("dungeon_trophy")).toList();
|
||||
assertTrue(earned.stream().allMatch(event->event.getAsJsonObject("data")
|
||||
.getAsJsonObject("boss_death_location").has("world_id")));
|
||||
var completed=exported.stream().filter(event->event.get("type").getAsString()
|
||||
.endsWith("dungeon.completed")).findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals(2,completed.getAsJsonArray("receipt_ids").size());
|
||||
assertEquals(2,completed.getAsJsonArray("represented_units").size());
|
||||
assertTrue(completed.getAsJsonObject("death_location").has("world_id"));
|
||||
assertTrue(earned.stream().allMatch(event->event.getAsJsonObject("data")
|
||||
.get("boss_id").getAsString().equals(completed.get("boss_id").getAsString())));
|
||||
var redemptions=exported.stream().filter(event->event.get("type").getAsString()
|
||||
.endsWith("adventure.trophy_redeemed")&&event.getAsJsonObject("data")
|
||||
.get("trophy_type").getAsString().equals("dungeon_trophy")).toList();
|
||||
assertEquals(1,redemptions.size(),
|
||||
"the exact consumed dungeon head must have one independent redemption event");
|
||||
assertEquals("entered_via_registered_giver",redemptions.getFirst().getAsJsonObject("data")
|
||||
.get("giver_status").getAsString());
|
||||
assertTrue(exported.stream().anyMatch(event->event.get("type").getAsString()
|
||||
.endsWith("adventure.reward_claimed")&&event.getAsJsonObject("data")
|
||||
.getAsJsonArray("sources").asList().stream().anyMatch(source->source.getAsJsonObject()
|
||||
.get("source_kind").getAsString().equals("dungeon_trophy"))
|
||||
&&event.getAsJsonObject("data").get("giver_status").getAsString()
|
||||
.equals("entered_via_registered_giver")),
|
||||
"the later individual claim must retain dungeon receipt provenance and registered Giver origin");
|
||||
}
|
||||
private static boolean exportedDungeonJourneyReady(Path root){
|
||||
try{var events=exportedDungeonEvents(root);
|
||||
return events.stream().filter(value->value.get("type").getAsString()
|
||||
.endsWith("dungeon.completed")).count()==1
|
||||
&&events.stream().filter(value->value.get("type").getAsString()
|
||||
.endsWith("adventure.trophy_earned")&&value.getAsJsonObject("data")
|
||||
.get("trophy_type").getAsString().equals("dungeon_trophy")).count()==2
|
||||
&&events.stream().filter(value->value.get("type").getAsString()
|
||||
.endsWith("adventure.trophy_redeemed")&&value.getAsJsonObject("data")
|
||||
.get("trophy_type").getAsString().equals("dungeon_trophy")).count()==1
|
||||
&&events.stream().anyMatch(value->value.get("type").getAsString()
|
||||
.endsWith("adventure.reward_claimed")&&value.getAsJsonObject("data")
|
||||
.getAsJsonArray("sources").asList().stream().anyMatch(source->source.getAsJsonObject()
|
||||
.get("source_kind").getAsString().equals("dungeon_trophy")));
|
||||
}catch(Exception transientExport){return false;}
|
||||
}
|
||||
private static List<com.google.gson.JsonObject> exportedDungeonEvents(Path root)throws Exception{
|
||||
try(var files=java.nio.file.Files.walk(root.resolve("events"))){
|
||||
return files.filter(path->path.toString().endsWith(".jsonl"))
|
||||
.flatMap(path->{try{return java.nio.file.Files.lines(path);}
|
||||
catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}})
|
||||
.map(line->com.google.gson.JsonParser.parseString(line).getAsJsonObject()).toList();
|
||||
}
|
||||
}
|
||||
@Test void failedDungeonRewardSaveAndReadOnlyAdminCommandsCannotInventChangeEvent() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
|
||||
UUID dungeonId;
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(actor.core.id,actor.player);
|
||||
doReturn(true).when(actor.player).hasPermission("fantasystick.admin");
|
||||
when(actor.world.getUID()).thenReturn(UUID.randomUUID());
|
||||
command(f,actor,"dungeon","create","Acknowledged Vault");
|
||||
var dungeons=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");
|
||||
dungeonId=dungeons.snapshot().dungeons().getFirst().id();
|
||||
command(f,actor,"dungeon","reward",dungeonId.toString());
|
||||
f.command.execute(actor.player,"fantasystick",
|
||||
new String[]{"dungeon","reward",dungeonId.toString(),"-1","4"});
|
||||
assertFalse(dungeons.find(dungeonId).orElseThrow().rewardConfigured());
|
||||
var file=data.resolve("dungeons.yml");var prior=java.nio.file.Files.readAllBytes(file);
|
||||
java.nio.file.Files.delete(file);java.nio.file.Files.createDirectory(file);
|
||||
command(f,actor,"dungeon","reward",dungeonId.toString(),"6","4");
|
||||
assertFalse(dungeons.find(dungeonId).orElseThrow().rewardConfigured());
|
||||
java.nio.file.Files.delete(file);java.nio.file.Files.write(file,prior);
|
||||
command(f,actor,"dungeon","reward",dungeonId.toString(),"6","4");
|
||||
assertEquals(BigInteger.valueOf(6),dungeons.find(dungeonId).orElseThrow()
|
||||
.completionReward().xp());
|
||||
}
|
||||
var changes=exportedDungeonEvents(data).stream().filter(event->event.get("type")
|
||||
.getAsString().endsWith("dungeon.reward_changed")).toList();
|
||||
assertEquals(1,changes.size(),"only a saved administrator change can publish");
|
||||
var evidence=changes.getFirst().getAsJsonObject("data");
|
||||
assertEquals(dungeonId.toString(),evidence.get("dungeon_id").getAsString());
|
||||
assertEquals("6",evidence.get("after_xp").getAsString());
|
||||
assertEquals("4",evidence.get("after_coins").getAsString());
|
||||
}
|
||||
|
||||
@Test void roomCreationReportsOverlapDistanceLimitsAndPersistenceSeparately() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\ndungeon-room-link-distance: 10\n");
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
@@ -107,21 +220,202 @@ class DungeonNativeTest {
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(root)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(root);f.players.put(actor.core.id,actor.player);doReturn(true).when(actor.player).hasPermission("fantasystick.admin");UUID world=UUID.randomUUID();when(actor.world.getUID()).thenReturn(world);var dungeons=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var dungeon=dungeons.create("Custody",world,0,64,0,0,0).get(5,TimeUnit.SECONDS);var room=dungeons.addRadiusRoom(dungeon.id(),"Boss",0,64,0,3,3).get(5,TimeUnit.SECONDS);dungeons.setBoss(dungeon.id(),room.id(),EntityType.HUSK,"Temporary",0,65,0,0,0).get(5,TimeUnit.SECONDS);command(f,actor,"dungeon","edit","start",dungeon.id().toString());var controller=(games.dmg.fantasystick.dungeon.DungeonController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeonController");await(f,()->controller.editing(actor.core.id).isPresent(),actor.messages);actor.core.location.setX(.5);actor.core.location.setY(65);actor.core.location.setZ(.5);assertEquals(List.of("assign-sheet"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"dungeon","room","boss","ass"}));assertEquals(List.of("0.1","1","1.5","10","100"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"dungeon","room","boss","assign-sheet",""}));var sheet=actor.core.inventory.getItemInOffHand();actor.core.inventory.setItemInOffHand(null);actor.core.inventory.setItemInMainHand(sheet);actor.core.inventory.setItem(5,sheet.clone());command(f,actor,"dungeon","room","boss","assign-sheet","1.125");assertTrue(dungeons.custody(actor.core.record.id()).isEmpty());actor.core.inventory.setItem(5,null);var partyDirectory=(games.dmg.fantasystick.party.CharacterPartyDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterParties");var forbiddenParty=partyDirectory.create(actor.core.record.id(),"Before Custody").get(5,TimeUnit.SECONDS).party().orElseThrow();command(f,actor,"dungeon","room","boss","assign-sheet","1.125");assertTrue(dungeons.custody(actor.core.record.id()).isEmpty());assertFalse(actor.core.inventory.getItemInMainHand().isEmpty());partyDirectory.disband(actor.core.record.id(),forbiddenParty.id()).get(5,TimeUnit.SECONDS);command(f,actor,"dungeon","room","boss","assign-sheet","1.125");await(f,()->dungeons.custody(actor.core.record.id()).isPresent(),actor.messages);assertTrue(actor.core.inventory.getItemInMainHand().isEmpty());assertEquals(actor.core.record,dungeons.custody(actor.core.record.id()).orElseThrow().character());actor.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(actor.core.record));var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");sessions.enter(actor.player);assertTrue(sessions.active(actor.core.id).isEmpty());assertTrue(actor.messages.stream().anyMatch(message->message.contains("dungeon boss")));var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertThrows(java.util.concurrent.ExecutionException.class,()->storage.awardExperience(actor.core.record.id(),actor.core.record.revision(),BigInteger.ONE,games.dmg.fantasystick.progression.ProgressionRules.defaults()).get(5,TimeUnit.SECONDS));assertEquals(games.dmg.fantasystick.party.CharacterPartyDirectory.Status.INVALID,partyDirectory.create(actor.core.record.id(),"Forbidden").get(5,TimeUnit.SECONDS).status());actor.core.inventory.setItemInOffHand(null);actor.core.location.setX(100);command(f,actor,"dungeon","edit","done");assertEquals(List.of(dungeon.id().toString()),f.command.tabComplete(actor.player,"fantasystick",new String[]{"dungeon","boss","recover-sheet",""}));command(f,actor,"dungeon","boss","recover-sheet",dungeon.id().toString());await(f,()->dungeons.custody(actor.core.record.id()).isEmpty(),actor.messages);var returned=Arrays.stream(actor.core.inventory.getContents()).filter(Objects::nonNull).filter(item->new games.dmg.fantasystick.items.CharacterSheets().identify(item).filter(actor.core.record.id()::equals).isPresent()).toList();assertEquals(1,returned.size());assertEquals(actor.core.record,storage.find(actor.core.record.id()).get(5,TimeUnit.SECONDS).orElseThrow());assertNull(dungeons.room(dungeon.id(),room.id()).orElseThrow().boss());}
|
||||
}
|
||||
|
||||
@SuppressWarnings({"unchecked","rawtypes"})
|
||||
@Test void committedNativeBossDefeatReplaysCompletionAfterMissingLivePublication() throws Exception {
|
||||
var root=data.resolve("completion-replay");Files.createDirectories(root);
|
||||
Files.writeString(root.resolve("config.yml"),
|
||||
"adventure-daily-site-count: 0\nmaximum-level: 1\nhostile-coin-drop-min: 1\nhostile-coin-drop-max: 1\n");
|
||||
var defeatedBoss=new UUID[1];var absentLeader=new UUID[1];var presentMember=new UUID[1];
|
||||
var completionOperation=new UUID[1];
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(root)){
|
||||
f.plugin.setEnabled(true);
|
||||
var actor=new FantasyStickLifecycleNativeTest.Actor(root);
|
||||
f.players.put(actor.core.id,actor.player);
|
||||
doReturn(true).when(actor.player).hasPermission("fantasystick.admin");
|
||||
var world=UUID.randomUUID();when(actor.world.getUID()).thenReturn(world);
|
||||
when(actor.world.isChunkLoaded(anyInt(),anyInt())).thenReturn(true);
|
||||
actor.core.location.setX(.5);actor.core.location.setY(65);actor.core.location.setZ(.5);
|
||||
var spawned=new ArrayList<Vindicator>();
|
||||
doAnswer(call->{var mob=mock(Vindicator.class);
|
||||
when(mob.getPersistentDataContainer()).thenReturn(
|
||||
new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
|
||||
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
|
||||
when(mob.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(mob.getType()).thenReturn(EntityType.VINDICATOR);
|
||||
when(mob.getWorld()).thenReturn(actor.world);
|
||||
when(mob.getLocation()).thenReturn(((Location)call.getArgument(0)).clone());
|
||||
((java.util.function.Consumer<Vindicator>)call.getArgument(2)).accept(mob);
|
||||
spawned.add(mob);return mob;
|
||||
}).when(actor.world).spawn(any(Location.class),eq(Vindicator.class),any(java.util.function.Consumer.class));
|
||||
when(actor.world.getEntities()).thenAnswer(ignored->new ArrayList<Entity>(spawned));
|
||||
var dungeons=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");
|
||||
var dungeon=dungeons.create("Replay Vault",world,0,64,0,0,0).get(5,TimeUnit.SECONDS);
|
||||
var room=dungeons.addRadiusRoom(dungeon.id(),"Boss",0,64,0,3,3).get(5,TimeUnit.SECONDS);
|
||||
dungeons.setBoss(dungeon.id(),room.id(),EntityType.VINDICATOR,"Keeper",0,65,0,0,0)
|
||||
.get(5,TimeUnit.SECONDS);
|
||||
dungeons.setCompletionReward(dungeon.id(),BigInteger.ONE,BigInteger.ZERO)
|
||||
.get(5,TimeUnit.SECONDS);
|
||||
command(f,actor,"dungeon","enable",dungeon.id().toString());
|
||||
command(f,actor,"dungeon","open",dungeon.id().toString());
|
||||
var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(
|
||||
f.plugin,"adventureQuests");
|
||||
f.activate(actor);
|
||||
var catalog=adventures.snapshot().catalog();
|
||||
completionOperation[0]=UUID.nameUUIDFromBytes(("dungeon:completed:"+dungeon.id()+":"+
|
||||
catalog.revision()).getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(
|
||||
catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.SOLO,
|
||||
actor.core.record.id(),Set.of(actor.core.record.id())).get(5,TimeUnit.SECONDS).status());
|
||||
var leader=new FantasyStickLifecycleNativeTest.Actor(root);
|
||||
var member=new FantasyStickLifecycleNativeTest.Actor(root);
|
||||
absentLeader[0]=leader.core.record.id();presentMember[0]=member.core.record.id();
|
||||
for(var participant:List.of(leader,member)){
|
||||
participant.core.location.setWorld(actor.world);
|
||||
participant.core.location.setY(65);
|
||||
participant.core.location.setZ(.5);
|
||||
f.players.put(participant.core.id,participant.player);
|
||||
f.activate(participant);
|
||||
}
|
||||
leader.core.location.setX(100);member.core.location.setX(1.5);
|
||||
var parties=PartyLifecycleNativeTest.parties(f);
|
||||
var party=parties.create(leader.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(member.core.id,
|
||||
parties.invite(leader.core.id,party.id(),member.core.id).orElseThrow().id()));
|
||||
assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(
|
||||
catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.PARTY,party.id(),
|
||||
Set.of(leader.core.record.id(),member.core.record.id()))
|
||||
.get(5,TimeUnit.SECONDS).status());
|
||||
f.players.remove(leader.core.id);
|
||||
assertNull(f.plugin.getServer().getPlayer(leader.core.id),
|
||||
"the trophy carrier is offline when the boss dies");
|
||||
pulse(f);drain(f);pulse(f);drain(f);
|
||||
var boss=spawned.getLast();defeatedBoss[0]=boss.getUniqueId();
|
||||
var source=mock(org.bukkit.damage.DamageSource.class);
|
||||
when(source.getCausingEntity()).thenReturn(actor.player);
|
||||
when(source.getDirectEntity()).thenReturn(actor.player);
|
||||
f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,boss,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,2));
|
||||
var memberSource=mock(org.bukkit.damage.DamageSource.class);
|
||||
when(memberSource.getCausingEntity()).thenReturn(member.player);
|
||||
when(memberSource.getDirectEntity()).thenReturn(member.player);
|
||||
f.dispatcher.accept(new EntityDamageByEntityEvent(member.player,boss,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK,memberSource,2));
|
||||
var owner=(games.dmg.fantasystick.dungeon.DungeonController)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeonController");
|
||||
var eventField=owner.getClass().getDeclaredField("auditEvents");
|
||||
eventField.setAccessible(true);eventField.set(owner,null);
|
||||
f.dispatcher.accept(new EntityDeathEvent(boss,source,new ArrayList<>(),0));
|
||||
await(f,()->dungeons.runView(dungeon.id(),catalog.revision())
|
||||
.map(DungeonState.Run::defeated).orElse(false),actor.messages);
|
||||
assertEquals(defeatedBoss[0].toString(),dungeons.runView(dungeon.id(),catalog.revision())
|
||||
.orElseThrow().completionAudit().get("boss_id"));
|
||||
assertEquals(0,exportedDungeonEvents(root).stream().filter(event->event.get("type")
|
||||
.getAsString().endsWith("dungeon.completed")).count());
|
||||
}
|
||||
assertTrue(new games.dmg.fantasystick.persistence.YamlDungeonRepository(root).load().runs()
|
||||
.stream().anyMatch(run->run.defeated()&&defeatedBoss[0].toString()
|
||||
.equals(run.completionAudit().get("boss_id"))));
|
||||
try(var restarted=new FantasyStickLifecycleNativeTest.Fixture(root)){
|
||||
restarted.plugin.setEnabled(true);
|
||||
await(restarted,()->{try{return exportedDungeonEvents(root).stream().anyMatch(event->
|
||||
event.get("type").getAsString().endsWith("dungeon.completed"));}
|
||||
catch(Exception transientExport){return false;}},List.of());
|
||||
var runs=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(restarted.plugin,"dungeons");
|
||||
await(restarted,()->runs.snapshot().runs().stream().anyMatch(run->run.defeated()
|
||||
&&defeatedBoss[0].toString().equals(run.completionAudit().get("boss_id"))
|
||||
&&run.completionPublished()),List.of());
|
||||
}
|
||||
var repository=new games.dmg.fantasystick.persistence.YamlDungeonRepository(root);
|
||||
var exportedOnce=repository.load();
|
||||
assertTrue(exportedOnce.runs().stream().anyMatch(run->run.defeated()
|
||||
&&defeatedBoss[0].toString().equals(run.completionAudit().get("boss_id"))
|
||||
&&run.completionPublished()),
|
||||
"the first exported recovery must durably acknowledge this exact boss");
|
||||
repository.save(new DungeonState(exportedOnce.dungeons(),exportedOnce.runs().stream()
|
||||
.map(run->new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),
|
||||
run.clearedRooms(),run.unitClaims(),run.characterClaims(),run.defeated(),
|
||||
run.completionAudit(),false)).toList()));
|
||||
// Simulate a crash after JSONL append but before the separate acknowledgement save.
|
||||
try(var replayAgain=new FantasyStickLifecycleNativeTest.Fixture(root)){
|
||||
replayAgain.plugin.setEnabled(true);
|
||||
var owner=(games.dmg.fantasystick.dungeon.DungeonController)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(replayAgain.plugin,"dungeonController");
|
||||
owner.replayCompletions(); // Force the normal startup replay even on fast test machines.
|
||||
var events=(games.dmg.fantasystick.events.FishingEventBridge)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(replayAgain.plugin,"fishingAuditEvents");
|
||||
replayAgain.plugin.setEnabled(false);
|
||||
events.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
|
||||
}
|
||||
var exportedId=UUID.nameUUIDFromBytes(("fantasystick:fishing:"+
|
||||
completionOperation[0]+":dungeon.completed")
|
||||
.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
assertFalse(Files.exists(root.resolve("fishing-event-intents").resolve(exportedId+".pending")),
|
||||
"a previously exported completion must not queue a fresh intent on a later restart");
|
||||
assertTrue(repository.load().runs().stream().anyMatch(run->run.defeated()
|
||||
&&defeatedBoss[0].toString().equals(run.completionAudit().get("boss_id"))
|
||||
&&run.completionPublished()),
|
||||
"the exported record repairs a lost acknowledgement before any new publication");
|
||||
var completed=exportedDungeonEvents(root).stream().filter(event->event.get("type")
|
||||
.getAsString().endsWith("dungeon.completed")).toList();
|
||||
assertEquals(1,completed.size(),"a later restart must not repeat an acknowledged completion");
|
||||
assertEquals(defeatedBoss[0].toString(),completed.getFirst().getAsJsonObject("data")
|
||||
.get("boss_id").getAsString());
|
||||
var earned=exportedDungeonEvents(root).stream().filter(event->event.get("type")
|
||||
.getAsString().endsWith("adventure.trophy_earned")&&event.getAsJsonObject("data")
|
||||
.get("trophy_type").getAsString().equals("dungeon_trophy")).toList();
|
||||
assertEquals(2,earned.size(),"both represented solo and party units earn a receipt");
|
||||
var partyEarned=earned.stream().filter(event->event.getAsJsonObject("data")
|
||||
.get("unit_kind").getAsString().equals("party")).findFirst().orElseThrow()
|
||||
.getAsJsonObject("data");
|
||||
assertEquals(absentLeader[0].toString(),partyEarned.get("carrier_character_id").getAsString());
|
||||
assertEquals(presentMember[0].toString(),partyEarned.getAsJsonArray("beneficiaries")
|
||||
.get(0).getAsJsonObject().get("character_id").getAsString());
|
||||
assertEquals(1,partyEarned.getAsJsonArray("beneficiaries").size());
|
||||
var bossAwards=exportedDungeonEvents(root).stream().filter(event->event.get("type")
|
||||
.getAsString().endsWith("combat.reward_resolved")&&event.getAsJsonObject("data")
|
||||
.get("target_id").getAsString().equals(defeatedBoss[0].toString())).toList();
|
||||
assertTrue(bossAwards.stream().anyMatch(event->{var award=event.getAsJsonObject("data");
|
||||
return award.get("allocated_experience").getAsBigInteger().signum()>0
|
||||
&&award.get("applied_experience").getAsString().equals("0")
|
||||
&&award.get("loss_reason").getAsString().equals("maximum_level");}),
|
||||
"native boss kill at the configured maximum must distinguish allocated from applied XP");
|
||||
}
|
||||
|
||||
@SuppressWarnings({"unchecked","rawtypes"})
|
||||
@Test void characterBossMaterializesScaledVindicatorAndFixatesOnAcceptedThreat() throws Exception {
|
||||
var root=data.resolve("character-runtime");java.nio.file.Files.createDirectories(root);
|
||||
java.nio.file.Files.writeString(root.resolve("config.yml"),"adventure-daily-site-count: 0\n");
|
||||
var killedBossId=new UUID[1];var savedBossCharacter=new UUID[1];
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(root)){
|
||||
f.plugin.setEnabled(true);
|
||||
var admin=new FantasyStickLifecycleNativeTest.Actor(root);f.players.put(admin.core.id,admin.player);doReturn(true).when(admin.player).hasPermission("fantasystick.admin");var bossStats=new java.util.EnumMap<games.dmg.fantasystick.progression.Stat,BigInteger>(games.dmg.fantasystick.progression.Stat.class);for(var stat:games.dmg.fantasystick.progression.Stat.values())bossStats.put(stat,BigInteger.valueOf(30));var abilities=List.of("fireball","frost_nova","hex","second_wind","purify","stone_skin","guardian_bond");var configuredBoss=new games.dmg.fantasystick.persistence.CharacterRecord(admin.core.record.id(),admin.core.record.name(),BigInteger.ONE,bossStats,admin.core.record.level(),admin.core.record.experience(),admin.core.record.unspentPoints(),java.math.BigDecimal.valueOf(40),Set.copyOf(abilities),abilities,List.of("speed_i","low_light_vision","quicker_regeneration_i"),Map.of("fireball",java.time.Instant.parse("2099-01-01T00:00:00Z")),Map.of(),false,null,null,admin.core.record.coins(),admin.core.record.coinsInitialized(),admin.core.record.rewardReceipts(),admin.core.record.pvpCoinEscrow(),new games.dmg.fantasystick.persistence.CharacterGear(games.dmg.fantasystick.persistence.GearTier.COPPER,games.dmg.fantasystick.persistence.GearTier.GOLD,games.dmg.fantasystick.persistence.GearTier.IRON));new games.dmg.fantasystick.persistence.YamlCharacterRepository(root).replace(configuredBoss,BigInteger.ZERO);
|
||||
var admin=new FantasyStickLifecycleNativeTest.Actor(root);f.players.put(admin.core.id,admin.player);doReturn(true).when(admin.player).hasPermission("fantasystick.admin");var bossStats=new java.util.EnumMap<games.dmg.fantasystick.progression.Stat,BigInteger>(games.dmg.fantasystick.progression.Stat.class);for(var stat:games.dmg.fantasystick.progression.Stat.values())bossStats.put(stat,BigInteger.valueOf(30));var abilities=List.of("fireball","frost_nova","hex","second_wind","purify","stone_skin","guardian_bond");var configuredBoss=new games.dmg.fantasystick.persistence.CharacterRecord(admin.core.record.id(),admin.core.record.name(),BigInteger.ONE,bossStats,admin.core.record.level(),admin.core.record.experience(),admin.core.record.unspentPoints(),java.math.BigDecimal.valueOf(40),Set.copyOf(abilities),abilities,List.of("speed_i","low_light_vision","quicker_regeneration_i"),Map.of("fireball",java.time.Instant.parse("2099-01-01T00:00:00Z")),Map.of(),false,null,null,admin.core.record.coins(),admin.core.record.coinsInitialized(),admin.core.record.rewardReceipts(),admin.core.record.pvpCoinEscrow(),new games.dmg.fantasystick.persistence.CharacterGear(games.dmg.fantasystick.persistence.GearTier.COPPER,games.dmg.fantasystick.persistence.GearTier.GOLD,games.dmg.fantasystick.persistence.GearTier.IRON));new games.dmg.fantasystick.persistence.YamlCharacterRepository(root).replace(configuredBoss,BigInteger.ZERO);savedBossCharacter[0]=configuredBoss.id();
|
||||
UUID worldId=UUID.randomUUID();when(admin.world.getUID()).thenReturn(worldId);when(admin.world.isChunkLoaded(anyInt(),anyInt())).thenReturn(true);
|
||||
var spawned=new ArrayList<Vindicator>();var maximumBases=new IdentityHashMap<Vindicator,double[]>();var attackBases=new IdentityHashMap<Vindicator,double[]>();var bossEffects=new IdentityHashMap<Vindicator,Set<org.bukkit.potion.PotionEffect>>();var bossEquipment=new IdentityHashMap<Vindicator,org.bukkit.inventory.EntityEquipment>();
|
||||
doAnswer(call->{var mob=mock(Vindicator.class);var pdc=new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());var maximum=mock(org.bukkit.attribute.AttributeInstance.class);var damage=mock(org.bukkit.attribute.AttributeInstance.class);var equipment=mock(org.bukkit.inventory.EntityEquipment.class);var maximumBase=new double[]{20};var attackBase=new double[]{1};var health=new double[]{20};var effects=new LinkedHashSet<org.bukkit.potion.PotionEffect>();effects.add(new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.WEAKNESS,200,0));maximumBases.put(mob,maximumBase);attackBases.put(mob,attackBase);bossEffects.put(mob,effects);bossEquipment.put(mob,equipment);when(mob.getPersistentDataContainer()).thenReturn(pdc);when(mob.getUniqueId()).thenReturn(UUID.randomUUID());when(mob.getType()).thenReturn(EntityType.VINDICATOR);when(mob.getWorld()).thenReturn(admin.world);when(mob.getLocation()).thenReturn(((Location)call.getArgument(0)).clone());when(mob.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).thenReturn(maximum);when(mob.getAttribute(org.bukkit.attribute.Attribute.ATTACK_DAMAGE)).thenReturn(damage);when(maximum.getValue()).thenAnswer(ignored->maximumBase[0]);doAnswer(invocation->{maximumBase[0]=(Double)invocation.getArgument(0);return null;}).when(maximum).setBaseValue(anyDouble());doAnswer(invocation->{attackBase[0]=(Double)invocation.getArgument(0);return null;}).when(damage).setBaseValue(anyDouble());when(mob.getHealth()).thenAnswer(ignored->health[0]);doAnswer(invocation->{health[0]=(Double)invocation.getArgument(0);return null;}).when(mob).setHealth(anyDouble());when(mob.getEquipment()).thenReturn(equipment);when(mob.hasLineOfSight(any(Entity.class))).thenReturn(true);when(mob.getActivePotionEffects()).thenAnswer(ignored->Set.copyOf(effects));doAnswer(invocation->{effects.add(invocation.getArgument(0));return true;}).when(mob).addPotionEffect(any(org.bukkit.potion.PotionEffect.class));doAnswer(invocation->{effects.add(invocation.getArgument(0));return true;}).when(mob).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());doAnswer(invocation->{effects.removeIf(effect->effect.getType().equals(invocation.getArgument(0)));return null;}).when(mob).removePotionEffect(any(org.bukkit.potion.PotionEffectType.class));((java.util.function.Consumer<Vindicator>)call.getArgument(2)).accept(mob);spawned.add(mob);return mob;}).when(admin.world).spawn(any(Location.class),eq(Vindicator.class),any(java.util.function.Consumer.class));
|
||||
when(admin.world.getEntities()).thenAnswer(ignored->new ArrayList<Entity>(spawned));
|
||||
var dungeons=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var dungeon=dungeons.create("Mirror",worldId,0,64,0,0,0).get(5,TimeUnit.SECONDS);var room=dungeons.addRadiusRoom(dungeon.id(),"Boss",0,64,0,3,3).get(5,TimeUnit.SECONDS);dungeons.setBoss(dungeon.id(),room.id(),EntityType.HUSK,"Temporary",0,65,0,0,0).get(5,TimeUnit.SECONDS);dungeons.setCompletionReward(dungeon.id(),BigInteger.ZERO,BigInteger.ZERO).get(5,TimeUnit.SECONDS);command(f,admin,"dungeon","edit","start",dungeon.id().toString());var controller=(games.dmg.fantasystick.dungeon.DungeonController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeonController");await(f,()->controller.editing(admin.core.id).isPresent(),admin.messages);admin.core.location.setX(.5);admin.core.location.setY(65);admin.core.location.setZ(.5);var sheet=admin.core.inventory.getItemInOffHand();admin.core.inventory.setItemInOffHand(null);admin.core.inventory.setItemInMainHand(sheet);command(f,admin,"dungeon","room","boss","assign-sheet","2");await(f,()->dungeons.custody(admin.core.record.id()).isPresent(),admin.messages);command(f,admin,"dungeon","edit","done");command(f,admin,"dungeon","enable",dungeon.id().toString());command(f,admin,"dungeon","open",dungeon.id().toString());var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");await(f,()->adventures.snapshot().catalog()!=null&&adventures.snapshot().catalog().sites().contains(dungeon.id()),admin.messages);
|
||||
var fighter=new FantasyStickLifecycleNativeTest.Actor(root);fighter.core.location.setWorld(admin.world);fighter.core.location.setX(.5);fighter.core.location.setY(65);fighter.core.location.setZ(.5);f.players.put(fighter.core.id,fighter.player);doReturn(false).when(fighter.player).isDead();doReturn(true).when(fighter.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());var abilityHits=new ArrayList<Double>();fighter.damage=(amount,source)->abilityHits.add(amount);f.activate(fighter);var catalog=adventures.snapshot().catalog();assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.SOLO,fighter.core.record.id(),Set.of(fighter.core.record.id())).get(5,TimeUnit.SECONDS).status());var challenger=new FantasyStickLifecycleNativeTest.Actor(root);challenger.core.location.setWorld(admin.world);challenger.core.location.setX(1.5);challenger.core.location.setY(65);challenger.core.location.setZ(.5);f.players.put(challenger.core.id,challenger.player);doReturn(false).when(challenger.player).isDead();doReturn(true).when(challenger.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());challenger.damage=(amount,sourceEntity)->{};f.activate(challenger);assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.SOLO,challenger.core.record.id(),Set.of(challenger.core.record.id())).get(5,TimeUnit.SECONDS).status());pulse(f);drain(f);pulse(f);drain(f);assertFalse(spawned.isEmpty());var boss=spawned.getLast();assertEquals(70d,maximumBases.get(boss)[0]);assertEquals(26d,attackBases.get(boss)[0]);var equipment=bossEquipment.get(boss);verify(equipment).setChestplate(argThat(item->item!=null&&item.getType()==Material.COPPER_CHESTPLATE));verify(equipment).setLeggings(argThat(item->item!=null&&item.getType()==Material.GOLDEN_LEGGINGS));verify(equipment).setBoots(argThat(item->item!=null&&item.getType()==Material.IRON_BOOTS));assertTrue(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.SPEED)),bossEffects.get(boss)::toString);assertTrue(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.NIGHT_VISION)),bossEffects.get(boss)::toString);boss.setHealth(50);var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(fighter.player);when(source.getDirectEntity()).thenReturn(fighter.player);f.dispatcher.accept(new EntityDamageByEntityEvent(fighter.player,boss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3));pulse(f);verify(boss,atLeastOnce()).setTarget(fighter.player);assertTrue(abilityHits.size()>=2,"Every ready offensive slot casts in the same pulse");int firstCasts=abilityHits.size();pulse(f);assertEquals(firstCasts,abilityHits.size(),"Boss-only cooldowns suppress immediate recasts");assertFalse(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.WEAKNESS)),"Purify removes a real harmful effect");var challengerSource=mock(org.bukkit.damage.DamageSource.class);when(challengerSource.getCausingEntity()).thenReturn(challenger.player);when(challengerSource.getDirectEntity()).thenReturn(challenger.player);f.dispatcher.accept(new EntityDamageByEntityEvent(challenger.player,boss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,challengerSource,10));pulse(f);var targets=org.mockito.ArgumentCaptor.forClass(LivingEntity.class);verify(boss,atLeastOnce()).setTarget(targets.capture());assertSame(fighter.player,targets.getAllValues().getLast(),"Higher later threat cannot break fixation");fighter.core.location.setX(100);pulse(f);targets=org.mockito.ArgumentCaptor.forClass(LivingEntity.class);verify(boss,atLeastOnce()).setTarget(targets.capture());assertSame(challenger.player,targets.getAllValues().getLast(),"Ineligible fixation retargets highest remaining threat");
|
||||
challenger.core.location.setX(100);pulse(f);verify(boss,atLeastOnce()).remove();challenger.core.location.setX(.5);pulse(f);drain(f);var freshBoss=spawned.getLast();assertNotSame(boss,freshBoss);assertEquals(70d,maximumBases.get(freshBoss)[0]);f.dispatcher.accept(new EntityDamageByEntityEvent(challenger.player,freshBoss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,challengerSource,2));f.dispatcher.accept(new EntityDeathEvent(freshBoss,challengerSource,new ArrayList<>(),0));await(f,()->dungeons.runView(dungeon.id(),catalog.revision()).map(DungeonState.Run::defeated).orElse(false),admin.messages);assertTrue(dungeons.custody(admin.core.record.id()).isPresent(),"Boss defeat keeps character custody for later days");challenger.core.location.setX(100);command(f,admin,"dungeon","disable",dungeon.id().toString(),"confirm");admin.core.location.setX(100);command(f,admin,"dungeon","boss","recover-sheet",dungeon.id().toString());await(f,()->dungeons.custody(admin.core.record.id()).isEmpty(),admin.messages);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(configuredBoss,storage.find(configuredBoss.id()).get(5,TimeUnit.SECONDS).orElseThrow());assertTrue(Arrays.stream(admin.core.inventory.getContents()).filter(Objects::nonNull).anyMatch(item->new games.dmg.fantasystick.items.CharacterSheets().identify(item).filter(configuredBoss.id()::equals).isPresent()));
|
||||
challenger.core.location.setX(100);pulse(f);verify(boss,atLeastOnce()).remove();challenger.core.location.setX(.5);pulse(f);drain(f);var freshBoss=spawned.getLast();killedBossId[0]=freshBoss.getUniqueId();assertNotSame(boss,freshBoss);assertEquals(70d,maximumBases.get(freshBoss)[0]);f.dispatcher.accept(new EntityDamageByEntityEvent(challenger.player,freshBoss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,challengerSource,2));f.dispatcher.accept(new EntityDeathEvent(freshBoss,challengerSource,new ArrayList<>(),0));await(f,()->dungeons.runView(dungeon.id(),catalog.revision()).map(DungeonState.Run::defeated).orElse(false),admin.messages);assertTrue(dungeons.custody(admin.core.record.id()).isPresent(),"Boss defeat keeps character custody for later days");await(f,()->{try{var published=exportedDungeonEvents(root);return published.stream().anyMatch(event->event.get("type").getAsString().endsWith("dungeon.completed"))&&published.stream().anyMatch(event->event.get("type").getAsString().endsWith("adventure.trophy_earned")&&event.getAsJsonObject("data").get("trophy_type").getAsString().equals("dungeon_trophy"));}catch(Exception transientExport){return false;}},admin.messages);challenger.core.location.setX(100);command(f,admin,"dungeon","disable",dungeon.id().toString(),"confirm");admin.core.location.setX(100);command(f,admin,"dungeon","boss","recover-sheet",dungeon.id().toString());await(f,()->dungeons.custody(admin.core.record.id()).isEmpty(),admin.messages);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(configuredBoss,storage.find(configuredBoss.id()).get(5,TimeUnit.SECONDS).orElseThrow());assertTrue(Arrays.stream(admin.core.inventory.getContents()).filter(Objects::nonNull).anyMatch(item->new games.dmg.fantasystick.items.CharacterSheets().identify(item).filter(configuredBoss.id()::equals).isPresent()));
|
||||
await(f,()->{try{var published=exportedDungeonEvents(root);return published.stream().anyMatch(event->event.get("type").getAsString().endsWith("dungeon.completed"))&&published.stream().anyMatch(event->event.get("type").getAsString().endsWith("adventure.trophy_earned")&&event.getAsJsonObject("data").get("trophy_type").getAsString().equals("dungeon_trophy"));}catch(Exception transientExport){return false;}},admin.messages);
|
||||
}
|
||||
var exported=exportedDungeonEvents(root);
|
||||
var bossAwards=exported.stream().filter(event->event.get("type").getAsString()
|
||||
.endsWith("combat.reward_resolved")&&event.getAsJsonObject("data")
|
||||
.get("target_id").getAsString().equals(killedBossId[0].toString())).toList();
|
||||
assertFalse(bossAwards.isEmpty(),"native character-sheet boss keeps its ordinary mob kill reward");
|
||||
assertTrue(bossAwards.stream().allMatch(event->event.getAsJsonObject("data")
|
||||
.get("managed_origin_kind").getAsString().equals("dungeon")
|
||||
&&event.getAsJsonObject("data").get("target_type").getAsString().equals("vindicator")));
|
||||
var earned=exported.stream().filter(event->event.get("type").getAsString()
|
||||
.endsWith("adventure.trophy_earned")&&event.getAsJsonObject("data")
|
||||
.get("trophy_type").getAsString().equals("dungeon_trophy")).toList();
|
||||
assertEquals(1,earned.size(),"only the present represented quest unit receives a head");
|
||||
assertEquals(killedBossId[0].toString(),earned.getFirst().getAsJsonObject("data")
|
||||
.get("boss_id").getAsString());
|
||||
assertFalse(earned.getFirst().toString().contains(savedBossCharacter[0].toString()),
|
||||
"the boss head must not export the character held in dungeon custody");
|
||||
assertEquals(1,exported.stream().filter(event->event.get("type").getAsString()
|
||||
.endsWith("dungeon.completed")).count());
|
||||
}
|
||||
|
||||
@Test void questGiverPaginatesManualDungeonOffersBeyondAutomaticCount() throws Exception {
|
||||
|
||||
@@ -673,7 +673,13 @@ class FantasyStickLifecycleNativeTest {
|
||||
Path directory = data.resolve(route);
|
||||
if (route.equals("disable")) {
|
||||
var intent = directory.resolve("switch-event-intents").resolve(exitedSession + ".pending");
|
||||
assertTrue(java.nio.file.Files.exists(intent), "Shutdown exit intent survives closed event writer");
|
||||
var exported=localRecords(directory).stream().filter(entry->entry.get("type").getAsString()
|
||||
.endsWith("character.switch_delay_started")).filter(entry->{var event=entry.getAsJsonObject("data");
|
||||
return event.get("session_id").getAsString().equals(exitedSession.toString())
|
||||
&&event.get("exit_operation_id").getAsString().equals(exitedSession.toString());
|
||||
}).toList();
|
||||
assertTrue(java.nio.file.Files.exists(intent)||!exported.isEmpty(),
|
||||
"Shutdown exit must retain the exact session's pending intent or exported switch record");
|
||||
}
|
||||
try (var restarted = new Fixture(directory)) {
|
||||
restarted.plugin.setEnabled(true);
|
||||
@@ -695,12 +701,39 @@ class FantasyStickLifecycleNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nativeJsonlReaderSeesExportedUtcSegmentsButNotTransientPendingArea() throws Exception {
|
||||
var root=data.resolve("events");
|
||||
var first=java.nio.file.Files.createDirectories(root.resolve("2026-09-27"));
|
||||
var second=java.nio.file.Files.createDirectories(root.resolve("2026-09-28"));
|
||||
var pending=java.nio.file.Files.createDirectories(root.resolve("pending"));
|
||||
java.nio.file.Files.writeString(first.resolve("events-first-0.jsonl"),"{\"id\":\"first\"}\n");
|
||||
java.nio.file.Files.writeString(second.resolve("events-second-0.jsonl"),"{\"id\":\"second\"}\n");
|
||||
// This is never an exported segment, even if a transient file resembles one.
|
||||
java.nio.file.Files.writeString(pending.resolve("decoy.jsonl"),"{\"id\":\"pending\"}\n");
|
||||
java.nio.file.Files.writeString(pending.resolve("in-flight.pending"),"uncommitted");
|
||||
assertEquals(List.of("first","second"),localRecords(data).stream()
|
||||
.map(record->record.get("id").getAsString()).sorted().toList());
|
||||
java.nio.file.Files.writeString(second.resolve("events-second-0.jsonl"),"{\"broken\":\n");
|
||||
assertThrows(com.google.gson.JsonParseException.class,()->localRecords(data),
|
||||
"a malformed exported record must not be hidden by excluding pending files");
|
||||
}
|
||||
|
||||
private static java.util.List<com.google.gson.JsonObject> localRecords(Path directory) throws IOException {
|
||||
var events = new java.util.ArrayList<com.google.gson.JsonObject>();
|
||||
try (var files = java.nio.file.Files.walk(directory.resolve("events"))) {
|
||||
for (var path : files.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList())
|
||||
for (var line : java.nio.file.Files.readAllLines(path))
|
||||
if (!line.isBlank()) events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
// Exported segments live directly under UTC-date directories. Never traverse
|
||||
// pending/: its files can disappear while the asynchronous writer exports them.
|
||||
try (var dates = java.nio.file.Files.list(directory.resolve("events"))) {
|
||||
for (var date : dates.filter(java.nio.file.Files::isDirectory)
|
||||
.filter(p -> p.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}"))
|
||||
.toList()) {
|
||||
try (var segments = java.nio.file.Files.list(date)) {
|
||||
for (var path : segments.filter(java.nio.file.Files::isRegularFile)
|
||||
.filter(p -> p.getFileName().toString().startsWith("events-")
|
||||
&& p.getFileName().toString().endsWith(".jsonl")).toList())
|
||||
for (var line : java.nio.file.Files.readAllLines(path))
|
||||
if (!line.isBlank()) events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,52 @@ class FishingEventBridgePartsNativeTest {
|
||||
@org.junit.jupiter.api.BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void campStartupReplayDoesNotRepublishAnAlreadyExportedUnacknowledgedIntent() throws Exception {
|
||||
var character=UUID.randomUUID();var receiptId=UUID.randomUUID();
|
||||
var receipt=new games.dmg.fantasystick.adventure.FishingState.Trophy(receiptId,
|
||||
games.dmg.fantasystick.adventure.FishingState.TrophyType.CAMP,character,
|
||||
UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),
|
||||
"Spoils of Restart Camp","Restart Camp",Map.of(character,
|
||||
new games.dmg.fantasystick.adventure.FishingState.Reward(
|
||||
java.math.BigInteger.ONE,java.math.BigInteger.ZERO)),
|
||||
games.dmg.fantasystick.adventure.FishingState.TrophyPhase.EARNED,
|
||||
Map.of("final_enemy_id",UUID.randomUUID().toString(),"unit_kind","solo",
|
||||
"unit_id",character.toString(),"accepted_roster",character.toString(),
|
||||
"arena_shape","circle","arena_x_blocks","10","arena_z_blocks","10"));
|
||||
var log=LocalEventLog.open(data,Clock.systemUTC(),
|
||||
new LocalEventLog.RuntimeInfo("test","test","test"),128,1_000_000,ignored->{});
|
||||
log.ready().toCompletableFuture().get(5,TimeUnit.SECONDS);
|
||||
var allowFirstAcknowledgement=new CompletableFuture<Void>();
|
||||
var firstExported=new CompletableFuture<Void>();
|
||||
var deliveries=new java.util.concurrent.atomic.AtomicInteger();
|
||||
var bridge=new FishingEventBridge(event->{
|
||||
var result=log.emit(event);
|
||||
if(deliveries.incrementAndGet()==1)return result.thenCompose(status->{
|
||||
firstExported.complete(null);
|
||||
return allowFirstAcknowledgement.thenApply(ignored->status);
|
||||
});
|
||||
return result;
|
||||
},Clock.systemUTC(),data,mock(EquipmentSessions.class),ignored->{});
|
||||
bridge.trophyEarned(receipt);
|
||||
firstExported.get(5,TimeUnit.SECONDS);
|
||||
bridge.replay();
|
||||
bridge.close();bridge.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
|
||||
allowFirstAcknowledgement.complete(null);
|
||||
log.close();
|
||||
var earned=new ArrayList<com.google.gson.JsonObject>();
|
||||
try(var files=java.nio.file.Files.walk(data.resolve("events"))){
|
||||
for(var path:files.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("adventure.trophy_earned"))earned.add(event);
|
||||
}
|
||||
}
|
||||
assertEquals(1,earned.size(),()->"camp receipt="+receiptId+" exported IDs="
|
||||
+earned.stream().map(event->event.get("id").getAsString()).toList());
|
||||
assertEquals(receiptId.toString(),earned.getFirst().getAsJsonObject("data")
|
||||
.get("receipt_id").getAsString());
|
||||
}
|
||||
|
||||
@Test void rejectedChampionIntentReplaysFromFrozenReceiptIntoExportedJsonl() throws Exception {
|
||||
var carrier=UUID.randomUUID();var zero=UUID.randomUUID();var receiptId=UUID.randomUUID();
|
||||
var receipt=new games.dmg.fantasystick.adventure.FishingState.Trophy(receiptId,
|
||||
|
||||
@@ -483,6 +483,11 @@ class GreatSwordNativeTest {
|
||||
|
||||
@Test void restartAndDifferentHolderCannotReapplyACompletedChargedStrike() throws Exception {
|
||||
org.bukkit.inventory.ItemStack sheet;
|
||||
java.time.Instant persistedDeadline;
|
||||
java.util.UUID characterId;
|
||||
// This scenario checks an unexpired persisted recovery, not the packaged
|
||||
// 0.75-second wall-clock boundary (covered by the fixed-clock test below).
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),"great-sword-recovery-seconds: 600\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withGreatSword(data, a, 12);
|
||||
@@ -502,6 +507,12 @@ class GreatSwordNativeTest {
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
verify(target, times(1)).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
characterId=a.core.record.id();
|
||||
var stored=(games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");
|
||||
persistedDeadline=stored.find(characterId).join().orElseThrow().cooldowns().get("great_sword");
|
||||
assertTrue(persistedDeadline.isAfter(java.time.Instant.now().plusSeconds(300)),
|
||||
"This restart/transfer check must still be before its acknowledged recovery deadline");
|
||||
sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
|
||||
f.plugin.setEnabled(false);
|
||||
@@ -511,6 +522,12 @@ class GreatSwordNativeTest {
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
b.core.inventory.setItemInOffHand(sheet);
|
||||
f.players.put(b.core.id, b.player); f.activate(b);
|
||||
var restored=(games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");
|
||||
assertEquals(persistedDeadline,restored.find(characterId).join().orElseThrow()
|
||||
.cooldowns().get("great_sword"));
|
||||
assertTrue(persistedDeadline.isAfter(java.time.Instant.now().plusSeconds(300)),
|
||||
"The transferred holder must attack before the same persisted recovery expires");
|
||||
when(b.player.getAttackCooldown()).thenReturn(1.0f);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
|
||||
@@ -40,11 +40,141 @@ class RareShopNativeTest {
|
||||
PartyBookNativeTest.open(f,actor);assertTrue(PartyBookNativeTest.text(actor).contains("Health Potion"),PartyBookNativeTest.text(actor));assertTrue(PartyBookNativeTest.text(actor).contains("[Use]"),PartyBookNativeTest.text(actor));var action=PartyBookNativeTest.button(actor,"[Use]");PartyBookNativeTest.click(f,actor,action);await(f,()->inventory.quantity(actor.core.record.id(),RareProduct.HEALTH_POTION).signum()==0&&actor.player.getHealth()>5&&actor.messages.stream().anyMatch(message->message.contains("Used Health Potion")));assertEquals(10d,actor.player.getHealth());assertEquals(0,BigDecimal.TEN.compareTo(characters.find(record.id()).join().orElseThrow().health()));assertTrue(actor.messages.stream().anyMatch(message->message.contains("Used Health Potion")&&message.contains("restored")));
|
||||
int menusBeforeReskill=actor.inventories.size();menus.open(actor.player,shop.id());await(f,()->actor.inventories.size()>menusBeforeReskill);var reskillMenu=actor.inventories.getLast();var reskill=reskillMenu.getItem(13);assertTrue(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(reskill.getItemMeta().displayName()).contains("Reskill — 20 coins"));var reskillClick=mock(InventoryClickEvent.class);when(reskillClick.getInventory()).thenReturn(reskillMenu);when(reskillClick.getWhoClicked()).thenReturn(actor.player);when(reskillClick.getCurrentItem()).thenReturn(reskill);menus.click(reskillClick);await(f,()->inventory.quantity(actor.core.record.id(),RareProduct.RESKILL).equals(BigInteger.ONE));await(f,()->sessions.active(actor.core.id).flatMap(sessions::currentRecord).map(value->value.coins().equals(BigInteger.valueOf(90))&&!value.stats().get(games.dmg.fantasystick.progression.Stat.DEX).equals(BigInteger.ZERO)).orElse(false));PartyBookNativeTest.open(f,actor);PartyBookNativeTest.click(f,actor,PartyBookNativeTest.button(actor,"[Use]"));await(f,()->PartyBookNativeTest.text(actor).contains("Confirm Reskill"));PartyBookNativeTest.click(f,actor,PartyBookNativeTest.button(actor,"[Confirm Reskill]"));await(f,()->inventory.quantity(actor.core.record.id(),RareProduct.RESKILL).signum()==0&&sessions.active(actor.core.id).flatMap(sessions::currentRecord).map(value->value.stats().values().stream().allMatch(BigInteger.ZERO::equals)&&value.unspentPoints().equals(BigInteger.valueOf(40))).orElse(false));var resetActive=sessions.active(actor.core.id).orElseThrow();var resetRecord=sessions.currentRecord(resetActive).orElseThrow();var repurchased=characters.purchaseStat(resetRecord.id(),resetRecord.revision(),games.dmg.fantasystick.progression.Stat.DEX,BigDecimal.valueOf(actor.player.getHealth()),new BigDecimal("1024")).join().record().orElseThrow();assertTrue(sessions.acknowledgeProgress(resetActive,resetRecord.revision(),repurchased));assertEquals(BigInteger.ONE,repurchased.stats().get(games.dmg.fantasystick.progression.Stat.DEX));assertEquals(BigInteger.valueOf(39),repurchased.unspentPoints());int beforeCoreMenu=actor.inventories.size();menus.open(actor.player,shop.id());await(f,()->actor.inventories.size()>beforeCoreMenu);var coreMenu=actor.inventories.getLast();var coreItem=coreMenu.getItem(14);assertTrue(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(coreItem.getItemMeta().displayName()).contains("Magic Core — 30 coins"));var coreClick=mock(InventoryClickEvent.class);when(coreClick.getInventory()).thenReturn(coreMenu);when(coreClick.getWhoClicked()).thenReturn(actor.player);when(coreClick.getCurrentItem()).thenReturn(coreItem);menus.click(coreClick);awaitEvidence(f,()->inventory.quantity(actor.core.record.id(),RareProduct.MAGIC_CORE).equals(BigInteger.ONE)&&sessions.active(actor.core.id).flatMap(sessions::currentRecord).map(value->value.coins().equals(BigInteger.valueOf(60))).orElse(false),()->"core purchase inventory="+inventory.inventory(actor.core.record.id())+" record="+sessions.active(actor.core.id).flatMap(sessions::currentRecord)+" messages="+actor.messages);PartyBookNativeTest.open(f,actor);PartyBookNativeTest.click(f,actor,PartyBookNativeTest.button(actor,"[Use]"));awaitEvidence(f,()->inventory.quantity(actor.core.record.id(),RareProduct.MAGIC_CORE).signum()==0&&sessions.active(actor.core.id).flatMap(sessions::currentRecord).map(value->value.unspentPoints().equals(BigInteger.valueOf(40))).orElse(false),()->"core use inventory="+inventory.inventory(actor.core.record.id())+" record="+sessions.active(actor.core.id).flatMap(sessions::currentRecord)+" messages="+actor.messages);int beforeSecondReskill=actor.inventories.size();menus.open(actor.player,shop.id());await(f,()->actor.inventories.size()>beforeSecondReskill);var secondReskillMenu=actor.inventories.getLast();var secondReskill=secondReskillMenu.getItem(13);var secondReskillClick=mock(InventoryClickEvent.class);when(secondReskillClick.getInventory()).thenReturn(secondReskillMenu);when(secondReskillClick.getWhoClicked()).thenReturn(actor.player);when(secondReskillClick.getCurrentItem()).thenReturn(secondReskill);menus.click(secondReskillClick);awaitEvidence(f,()->inventory.quantity(actor.core.record.id(),RareProduct.RESKILL).equals(BigInteger.ONE)&&sessions.active(actor.core.id).flatMap(sessions::currentRecord).map(value->value.coins().equals(BigInteger.valueOf(40))).orElse(false),()->"second reskill inventory="+inventory.inventory(actor.core.record.id())+" messages="+actor.messages);PartyBookNativeTest.open(f,actor);PartyBookNativeTest.click(f,actor,PartyBookNativeTest.button(actor,"[Use]"));await(f,()->PartyBookNativeTest.text(actor).contains("Confirm Reskill"));PartyBookNativeTest.click(f,actor,PartyBookNativeTest.button(actor,"[Confirm Reskill]"));awaitEvidence(f,()->inventory.quantity(actor.core.record.id(),RareProduct.RESKILL).signum()==0&&sessions.active(aLine truncated
|
||||
int beforePhoenixMenu=actor.inventories.size();menus.open(actor.player,shop.id());await(f,()->actor.inventories.size()>beforePhoenixMenu);var phoenixMenu=actor.inventories.getLast();var phoenix=phoenixMenu.getItem(15);assertTrue(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(phoenix.getItemMeta().displayName()).contains("Phoenix Down — 10 coins"));var phoenixClick=mock(InventoryClickEvent.class);when(phoenixClick.getInventory()).thenReturn(phoenixMenu);when(phoenixClick.getWhoClicked()).thenReturn(actor.player);when(phoenixClick.getCurrentItem()).thenReturn(phoenix);menus.click(phoenixClick);await(f,()->inventory.quantity(actor.core.record.id(),RareProduct.PHOENIX_DOWN).equals(BigInteger.ONE));int beforeRestockMessage=actor.messages.size();f.command.execute(actor.player,"fantasystick",new String[]{"rare-shop","restock"});await(f,()->actor.messages.size()>beforeRestockMessage&&actor.messages.getLast().contains("Restocked Rare Items catalog"));
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();var eventTypes=new java.util.ArrayList<String>();try(var eventFiles=java.nio.file.Files.walk(data.resolve("events"))){for(var eventFile:eventFiles.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(eventFile))if(!line.isBlank()){var event=com.google.gson.JsonParser.parseString(line).getAsJsonObject();if(event.getAsJsonObject("data").has("product_id")&&event.getAsJsonObject("data").get("product_id").getAsString().equals("phoenix-down"))eventTypes.add(event.get("type").getAsString());}}assertTrue(eventTypes.stream().anyMatch(type->type.endsWith("rare_item.purchased")),eventTypes.toString());assertTrue(eventTypes.stream().anyMatch(type->type.endsWith("rare_catalog.refreshed")),eventTypes.toString());f.plugin.setEnabled(true);var reloaded=(RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");assertEquals(BigInteger.ZERO,reloaded.quantity(actor.core.record.id(),RareProduct.HEALTH_POTION));assertEquals(BigInteger.ZERO,reloaded.quantity(actor.core.record.id(),RareProduct.RESKILL));assertEquals(BigInteger.ZERO,reloaded.quantity(actor.core.record.id(),RareProduct.MAGIC_CORE));assertEquals(BigInteger.ONE,reloaded.quantity(actor.core.record.id(),RareProduct.PHOENIX_DOWN));var persisted=new YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();assertTrue(persisted.stats().values().stream().allMatch(BigInteger.ZERO::equals));assertEquals(BigInteger.valueOf(41),persisted.unspentPoints());assertTrue(((RareMerchantDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchants")).find(shop.id()).isPresent());
|
||||
await(f,()->{
|
||||
var events=exportedRareEvents(data);
|
||||
return events.stream().filter(event->event.get("type").getAsString()
|
||||
.endsWith("rare_catalog.refreshed")).count()==2
|
||||
&&hasRareEvent(events,"rare_shop.placed","shop_id",shop.id().toString())
|
||||
&&hasRareEvent(events,"rare_item.purchased","product_id","health-potion")
|
||||
&&hasRareEvent(events,"rare_item.purchased","product_id","phoenix-down")
|
||||
&&hasRareEvent(events,"rare_item.used","product_id","health-potion");
|
||||
});
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();
|
||||
var exported=new java.util.ArrayList<com.google.gson.JsonObject>();
|
||||
try(var eventFiles=java.nio.file.Files.walk(data.resolve("events"))){
|
||||
for(var path:eventFiles.filter(file->file.getFileName().toString().endsWith(".jsonl")).toList())
|
||||
for(var line:java.nio.file.Files.readAllLines(path))if(!line.isBlank())
|
||||
exported.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
var placements=exported.stream().filter(event->event.get("type").getAsString()
|
||||
.endsWith("rare_shop.placed")&&event.getAsJsonObject("data")
|
||||
.get("shop_id").getAsString().equals(shop.id().toString())).toList();
|
||||
assertEquals(1,placements.size(),"Registered placement must reach exported JSONL");
|
||||
assertEquals("Curios",placements.getFirst().getAsJsonObject("data").get("shop_name").getAsString());
|
||||
var catalogs=exported.stream().filter(event->event.get("type").getAsString()
|
||||
.endsWith("rare_catalog.refreshed")).toList();
|
||||
assertEquals(2,catalogs.size(),"Startup and manual commits each publish one global catalog record");
|
||||
assertEquals(2,catalogs.stream().map(event->event.getAsJsonObject("data")
|
||||
.get("catalog_revision").getAsString()).distinct().count());
|
||||
for(var catalog:catalogs){
|
||||
var products=catalog.getAsJsonObject("data").getAsJsonArray("products");
|
||||
assertTrue(products.asList().stream().anyMatch(product->product.getAsJsonObject()
|
||||
.get("product_id").getAsString().equals("phoenix-down")));
|
||||
var phoenixOffer=products.asList().stream().map(com.google.gson.JsonElement::getAsJsonObject)
|
||||
.filter(product->product.get("product_id").getAsString().equals("phoenix-down"))
|
||||
.findFirst().orElseThrow();
|
||||
assertEquals("10",phoenixOffer.get("price").getAsString());
|
||||
assertEquals("1",phoenixOffer.get("stock").getAsString());
|
||||
var healing=products.asList().stream().map(com.google.gson.JsonElement::getAsJsonObject)
|
||||
.filter(product->product.get("product_id").getAsString().equals("health-potion"))
|
||||
.findFirst().orElseThrow();
|
||||
assertEquals("40",healing.get("price").getAsString());
|
||||
assertEquals("1",healing.get("stock").getAsString());
|
||||
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")&&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();
|
||||
assertEquals(1,potionPurchases.size(),"Acknowledged potion purchase must reach JSONL");
|
||||
assertEquals("40",potionPurchases.getFirst().getAsJsonObject("data").get("price").getAsString());
|
||||
assertEquals("1",potionPurchases.getFirst().getAsJsonObject("data").get("stock_before").getAsString());
|
||||
assertEquals("0",potionPurchases.getFirst().getAsJsonObject("data").get("stock_after").getAsString());
|
||||
var potionUses=exported.stream().filter(event->event.get("type").getAsString()
|
||||
.endsWith("rare_item.used")&&event.getAsJsonObject("data")
|
||||
.get("product_id").getAsString().equals("health-potion")).toList();
|
||||
assertEquals(1,potionUses.size(),"Acknowledged native potion healing must reach JSONL");
|
||||
assertEquals("5",potionUses.getFirst().getAsJsonObject("data").get("health_before").getAsString());
|
||||
assertEquals("10",potionUses.getFirst().getAsJsonObject("data").get("health_after").getAsString());
|
||||
f.plugin.setEnabled(true);var reloaded=(RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");assertEquals(BigInteger.ZERO,reloaded.quantity(actor.core.record.id(),RareProduct.HEALTH_POTION));assertEquals(BigInteger.ZERO,reloaded.quantity(actor.core.record.id(),RareProduct.RESKILL));assertEquals(BigInteger.ZERO,reloaded.quantity(actor.core.record.id(),RareProduct.MAGIC_CORE));assertEquals(BigInteger.ONE,reloaded.quantity(actor.core.record.id(),RareProduct.PHOENIX_DOWN));var persisted=new YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();assertTrue(persisted.stats().values().stream().allMatch(BigInteger.ZERO::equals));assertEquals(BigInteger.valueOf(41),persisted.unspentPoints());assertTrue(((RareMerchantDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchants")).find(shop.id()).isPresent());
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();
|
||||
assertEquals(2,exportedRareEvents(data).stream().filter(event->event.get("type").getAsString()
|
||||
.endsWith("rare_catalog.refreshed")).count(),"Restarting a current catalog is not another refresh");
|
||||
}
|
||||
}
|
||||
@Test void registeredAdministratorRemovalExportsExactDurableMerchant()throws Exception{
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);
|
||||
var merchants=(RareMerchantDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchants");
|
||||
var shop=merchants.place("Retired Curios",UUID.randomUUID(),12,64,20,90,0).join();
|
||||
var sender=new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.command.execute(sender.port,"fantasystick",new String[]{"rare-shop","remove",shop.id().toString()});
|
||||
await(f,()->merchants.find(shop.id()).isEmpty());
|
||||
await(f,()->hasRareEvent(exportedRareEvents(data),"rare_shop.removed","shop_id",shop.id().toString()));
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();
|
||||
var removals=new java.util.ArrayList<com.google.gson.JsonObject>();
|
||||
try(var files=java.nio.file.Files.walk(data.resolve("events"))){
|
||||
for(var path:files.filter(file->file.toString().endsWith(".jsonl")).toList())
|
||||
for(var line:java.nio.file.Files.readAllLines(path)){
|
||||
var event=com.google.gson.JsonParser.parseString(line).getAsJsonObject();
|
||||
if(event.get("type").getAsString().endsWith("rare_shop.removed"))removals.add(event);
|
||||
}
|
||||
}
|
||||
assertEquals(1,removals.size());
|
||||
assertEquals(shop.id().toString(),removals.getFirst().getAsJsonObject("data")
|
||||
.get("shop_id").getAsString());
|
||||
assertEquals("Retired Curios",removals.getFirst().getAsJsonObject("data")
|
||||
.get("shop_name").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void administratorReadsAndChangesFuturePhoenixProtectionWithEvents()throws Exception{
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();var phoenixKeys=f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","rare-"});assertTrue(phoenixKeys.containsAll(List.of("rare-price-phoenix-down","rare-stock-phoenix-down-min","rare-stock-phoenix-down-max","rare-phoenix-down-invulnerability-seconds")),phoenixKeys.toString());assertEquals(List.of("1000"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","rare-price-phoenix-down",""}));assertEquals(List.of("0"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","rare-stock-phoenix-down-min",""}));assertEquals(List.of("2"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","rare-stock-phoenix-down-max",""}));assertEquals(List.of("5"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","rare-phoenix-down-invulnerability-seconds",""}));f.command.execute(sender.port,"fantasystick",new String[]{"config","rare-phoenix-down-invulnerability-seconds"});assertTrue(sender.messages.getLast().contains("=5"));int before=sender.messages.size();f.command.execute(sender.port,"fantasystick",new String[]{"config","rare-phoenix-down-invulnerability-seconds","0"});await(f,()->sender.messages.size()>before);assertTrue(sender.messages.getLast().contains("=0"));f.command.execute(sender.port,"fantasystick",new String[]{"config","rare-phoenix-down-invulnerability-seconds","31536001"});assertTrue(sender.messages.getLast().contains("invalid"));assertEquals(java.time.Duration.ZERO,((RareSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareSettings")).phoenixProtection());f.plugin.setEnabled(false);var types=new java.util.ArrayList<String>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank()){var event=com.google.gson.JsonParser.parseString(line).getAsJsonObject();if(event.getAsJsonObject("data").has("key")&&event.getAsJsonObject("data").get("key").getAsString().equals("rare-phoenix-down-invulnerability-seconds"))types.add(event.get("type").getAsString());}}assertTrue(types.stream().anyMatch(type->type.endsWith("config.read")),types.toString());assertTrue(types.stream().anyMatch(type->type.endsWith("config.changed")),types.toString());}
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();var phoenixKeys=f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","rare-"});assertTrue(phoenixKeys.containsAll(List.of("rare-price-phoenix-down","rare-stock-phoenix-down-min","rare-stock-phoenix-down-max","rare-phoenix-down-invulnerability-seconds")),phoenixKeys.toString());assertEquals(List.of("1000"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","rare-price-phoenix-down",""}));assertEquals(List.of("0"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","rare-stock-phoenix-down-min",""}));assertEquals(List.of("2"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","rare-stock-phoenix-down-max",""}));assertEquals(List.of("5"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","rare-phoenix-down-invulnerability-seconds",""}));f.command.execute(sender.port,"fantasystick",new String[]{"config","rare-phoenix-down-invulnerability-seconds"});assertTrue(sender.messages.getLast().contains("=5"));int before=sender.messages.size();f.command.execute(sender.port,"fantasystick",new String[]{"config","rare-phoenix-down-invulnerability-seconds","0"});await(f,()->sender.messages.size()>before);assertTrue(sender.messages.getLast().contains("=0"));f.command.execute(sender.port,"fantasystick",new String[]{"config","rare-phoenix-down-invulnerability-seconds","31536001"});assertTrue(sender.messages.getLast().contains("invalid"));assertEquals(java.time.Duration.ZERO,((RareSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareSettings")).phoenixProtection());
|
||||
f.command.execute(sender.port,"fantasystick",new String[]{"config","rare-health-potion-healing-percent"});
|
||||
assertTrue(sender.messages.getLast().contains("=50"));
|
||||
int potionMessages=sender.messages.size();
|
||||
f.command.execute(sender.port,"fantasystick",new String[]{"config","rare-health-potion-healing-percent","75"});
|
||||
await(f,()->sender.messages.size()>potionMessages);
|
||||
assertEquals("75",((RareSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareSettings")).value("rare-health-potion-healing-percent"));
|
||||
await(f,()->hasRareEvent(exportedRareEvents(data),"config.changed","key","rare-health-potion-healing-percent"));
|
||||
f.plugin.setEnabled(false);var types=new java.util.ArrayList<String>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank()){var event=com.google.gson.JsonParser.parseString(line).getAsJsonObject();if(event.getAsJsonObject("data").has("key")&&event.getAsJsonObject("data").get("key").getAsString().equals("rare-phoenix-down-invulnerability-seconds"))types.add(event.get("type").getAsString());}}assertTrue(types.stream().anyMatch(type->type.endsWith("config.read")),types.toString());assertTrue(types.stream().anyMatch(type->type.endsWith("config.changed")),types.toString());
|
||||
var potionTypes=new java.util.ArrayList<String>();
|
||||
try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank()){var event=com.google.gson.JsonParser.parseString(line).getAsJsonObject();if(event.getAsJsonObject("data").has("key")&&event.getAsJsonObject("data").get("key").getAsString().equals("rare-health-potion-healing-percent"))potionTypes.add(event.get("type").getAsString());}}
|
||||
assertTrue(potionTypes.stream().anyMatch(type->type.endsWith("config.read")),potionTypes.toString());
|
||||
assertTrue(potionTypes.stream().anyMatch(type->type.endsWith("config.changed")),potionTypes.toString());}
|
||||
}
|
||||
|
||||
@Test void reskillUseRequiresFreshConfirmationThenResetsTheExactActiveCharacter()throws Exception{
|
||||
@@ -54,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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,9 +238,57 @@ class RareShopNativeTest {
|
||||
|
||||
@Test void everyMerchantSharesOneAllZeroCatalogAndShowsAnEmptyShop()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"rare-stock-lesser-health-potion-min: 0\nrare-stock-lesser-health-potion-max: 0\nrare-stock-health-potion-min: 0\nrare-stock-health-potion-max: 0\nrare-stock-greater-health-potion-min: 0\nrare-stock-greater-health-potion-max: 0\nrare-stock-reskill-min: 0\nrare-stock-reskill-max: 0\nrare-stock-magic-core-min: 0\nrare-stock-magic-core-max: 0\nrare-stock-phoenix-down-min: 0\nrare-stock-phoenix-down-max: 0\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);UUID world=UUID.randomUUID();when(actor.world.getUID()).thenReturn(world);f.activate(actor);var directory=(RareMerchantDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareMerchants");var first=directory.place("One",world,actor.core.location.getX(),actor.core.location.getY(),actor.core.location.getZ(),0,0).join();var second=directory.place("Two",world,actor.core.location.getX(),actor.core.location.getY(),actor.core.location.getZ(),0,0).join();var menus=(RareShopMenus)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareShopMenus");menus.open(actor.player,first.id());await(f,()->!actor.inventories.isEmpty());var one=actor.inventories.getLast();assertTrue(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(one.getItem(13).getItemMeta().displayName()).contains("Reskill"));assertTrue(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(one.getItem(14).getItemMeta().displayName()).contains("Magic Core"));assertEquals(Material.BARRIER,one.getItem(16).getType());menus.open(actor.player,second.id());await(f,()->actor.inventories.size()>=2);var two=actor.inventories.getLast();assertEquals(one.getItem(10).getItemMeta().displayName(),two.getItem(10).getItemMeta().displayName());assertTrue(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(one.getItem(10).getItemMeta().displayName()).startsWith("Sold out"));}}
|
||||
|
||||
@Test void transferredStoredPotionUseExportsNewHolderAndOriginalCharacter()throws Exception{
|
||||
UUID originalCharacter,newHolder;
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);
|
||||
var a=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(a.core.id,a.player);f.activate(a);
|
||||
originalCharacter=a.core.record.id();
|
||||
var inventory=(RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");
|
||||
inventory.add(originalCharacter,RareProduct.HEALTH_POTION,UUID.randomUUID()).join();
|
||||
var sessions=(games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");
|
||||
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.RESTORED,
|
||||
sessions.exit(a.player));f.awaitCharacterAndReceipt(a);
|
||||
var b=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(b.core.id,b.player);
|
||||
newHolder=b.core.id;
|
||||
b.core.inventory.setItemInOffHand(a.core.inventory.getItemInOffHand().clone());
|
||||
f.activate(b);
|
||||
assertEquals(originalCharacter,sessions.active(b.core.id).orElseThrow().character());
|
||||
var characters=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");
|
||||
injure(b,characters,sessions,new BigDecimal("5"));
|
||||
useFirst(f,b);
|
||||
await(f,()->inventory.quantity(originalCharacter,RareProduct.HEALTH_POTION).signum()==0
|
||||
&&b.player.getHealth()>5);
|
||||
await(f,()->hasRareEvent(exportedRareEvents(data),"rare_item.used","product_id","health-potion"));
|
||||
f.plugin.setEnabled(false);f.awaitWorkers();
|
||||
}
|
||||
var uses=exportedRareEvents(data).stream().filter(event->event.get("type").getAsString()
|
||||
.endsWith("rare_item.used")).toList();
|
||||
assertEquals(1,uses.size());
|
||||
assertEquals(originalCharacter.toString(),uses.getFirst().getAsJsonObject("data")
|
||||
.get("character_id").getAsString());
|
||||
assertEquals(newHolder.toString(),uses.getFirst().getAsJsonObject("data")
|
||||
.get("player_id").getAsString());
|
||||
}
|
||||
|
||||
@Test void allPotionSizesPreserveFractionalHealingAndFullHealthStock()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);f.activate(actor);var inventory=(RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");var characters=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");inventory.add(actor.core.record.id(),RareProduct.LESSER_HEALTH_POTION,UUID.randomUUID()).join();injure(actor,characters,sessions,new BigDecimal("4"));useFirst(f,actor);awaitEvidence(f,()->actor.player.getHealth()==6.5&&inventory.quantity(actor.core.record.id(),RareProduct.LESSER_HEALTH_POTION).signum()==0,()->"lesser health="+actor.player.getHealth()+" quantity="+inventory.quantity(actor.core.record.id(),RareProduct.LESSER_HEALTH_POTION)+" messages="+actor.messages);inventory.add(actor.core.record.id(),RareProduct.LESSER_HEALTH_POTION,UUID.randomUUID()).join();injure(actor,characters,sessions,BigDecimal.TEN);var potionService=(RarePotionService)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rarePotions");var refusal=new java.util.concurrent.atomic.AtomicReference<RarePotionService.Outcome>();potionService.use(actor.player,RareProduct.LESSER_HEALTH_POTION,refusal::set);assertEquals(RarePotionService.Status.FULL_HEALTH,refusal.get().status());assertEquals(BigInteger.ONE,inventory.quantity(actor.core.record.id(),RareProduct.LESSER_HEALTH_POTION));var settings=(RareSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareSettings");var zero=settings.set("rare-lesser-health-potion-healing-percent","0").toCompletableFuture().join();assertTrue(settings.publish(zero));injure(actor,characters,sessions,BigDecimal.ONE);var noEffect=new java.util.concurrent.atomic.AtomicReference<RarePotionService.Outcome>();potionService.use(actor.player,RareProduct.LESSER_HEALTH_POTION,noEffect::set);assertEquals(RarePotionService.Status.NO_EFFECT,noEffect.get().status());assertEquals(BigInteger.ONE,inventory.quantity(actor.core.record.id(),RareProduct.LESSER_HEALTH_POTION));inventory.add(actor.core.record.id(),RareProduct.GREATER_HEALTH_POTION,UUID.randomUUID()).join();injure(actor,characters,sessions,BigDecimal.ONE);var greater=new java.util.concurrent.atomic.AtomicReference<RarePotionService.Outcome>();potionService.use(actor.player,RareProduct.GREATER_HEALTH_POTION,greater::set);awaitEvidence(f,()->greater.get()!=null&&greater.get().status()==RarePotionService.Status.APPLIED&&actor.player.getHealth()==10&&inventory.quantity(actor.core.record.id(),RareProduct.GREATER_HEALTH_POTION).signum()==0,()->"greater outcome="+greater.get()+" health="+actor.player.getHealth()+" quantities="+inventory.inventory(actor.core.record.id())+" messages="+actor.messages);assertEquals(BigInteger.ONE,inventory.quantity(actor.core.record.id(),RareProduct.LESSER_HEALTH_POTION));}}
|
||||
private static void injure(FantasyStickLifecycleNativeTest.Actor actor,CharacterStorage characters,games.dmg.fantasystick.session.EquipmentSessions sessions,BigDecimal health){var active=sessions.active(actor.core.id).orElseThrow();var record=sessions.currentRecord(active).orElseThrow();var saved=characters.saveHealth(record.id(),record.revision(),health,BigDecimal.valueOf(100)).join().orElseThrow();if(!saved.revision().equals(record.revision()))assertTrue(sessions.acknowledgeProgress(active,record.revision(),saved));actor.player.setHealth(health.doubleValue());}
|
||||
private static void useFirst(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor)throws Exception{PartyBookNativeTest.open(f,actor);PartyBookNativeTest.click(f,actor,PartyBookNativeTest.button(actor,"[Use]"));}
|
||||
private static boolean hasRareEvent(List<com.google.gson.JsonObject> events,String type,String field,String value){
|
||||
return events.stream().anyMatch(event->event.get("type").getAsString().endsWith(type)
|
||||
&&event.getAsJsonObject("data").has(field)
|
||||
&&event.getAsJsonObject("data").get(field).getAsString().equals(value));
|
||||
}
|
||||
private static List<com.google.gson.JsonObject> exportedRareEvents(Path root){
|
||||
try(var files=java.nio.file.Files.walk(root.resolve("events"))){
|
||||
var records=new ArrayList<com.google.gson.JsonObject>();
|
||||
for(var path:files.filter(file->file.toString().endsWith(".jsonl")).toList())
|
||||
for(var line:java.nio.file.Files.readAllLines(path))if(!line.isBlank())
|
||||
records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
return records;
|
||||
}catch(java.io.IOException|RuntimeException transientExport){return List.of();}
|
||||
}
|
||||
private static void awaitEvidence(FantasyStickLifecycleNativeTest.Fixture f,java.util.function.BooleanSupplier condition,java.util.function.Supplier<String> evidence)throws Exception{for(int attempt=0;attempt<80&&!condition.getAsBoolean();attempt++){var task=f.tasks.poll(50,TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(condition.getAsBoolean(),evidence);}
|
||||
private static void await(FantasyStickLifecycleNativeTest.Fixture f,java.util.function.BooleanSupplier condition)throws Exception{for(int attempt=0;attempt<80&&!condition.getAsBoolean();attempt++){var task=f.tasks.poll(50,TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(condition.getAsBoolean());}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,40 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
final class DungeonDirectoryTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void committedBossCompletionProofSurvivesRunMutationAndRepositoryReload() throws Exception {
|
||||
var repository=new YamlDungeonRepository(data);
|
||||
var generation=UUID.randomUUID();var boss=UUID.randomUUID();
|
||||
UUID dungeonId,roomId;
|
||||
try(var directory=new DungeonDirectory(repository,()->1)){
|
||||
var dungeon=directory.create("Proof Vault",UUID.randomUUID(),0,64,0,0,0)
|
||||
.get(5,TimeUnit.SECONDS);
|
||||
dungeonId=dungeon.id();
|
||||
var room=directory.addRadiusRoom(dungeonId,"Boss",0,64,0,3,3).get(5,TimeUnit.SECONDS);
|
||||
roomId=room.id();
|
||||
directory.run(dungeonId,generation).get(5,TimeUnit.SECONDS);
|
||||
var proof=java.util.Map.of("boss_id",boss.toString(),"room_id",roomId.toString(),
|
||||
"defeated_at",java.time.Instant.now().truncatedTo(
|
||||
java.time.temporal.ChronoUnit.SECONDS).toString());
|
||||
var defeated=directory.defeat(dungeonId,generation,proof).get(5,TimeUnit.SECONDS);
|
||||
assertTrue(defeated.defeated());
|
||||
assertEquals(proof,defeated.completionAudit());
|
||||
assertFalse(defeated.completionPublished());
|
||||
assertFalse(directory.acknowledgeCompletion(dungeonId,generation,UUID.randomUUID())
|
||||
.get(5,TimeUnit.SECONDS),"another boss cannot acknowledge the committed defeat");
|
||||
assertTrue(directory.acknowledgeCompletion(dungeonId,generation,boss)
|
||||
.get(5,TimeUnit.SECONDS));
|
||||
assertTrue(directory.acknowledgeCompletion(dungeonId,generation,boss)
|
||||
.get(5,TimeUnit.SECONDS),"acknowledgement remains idempotent");
|
||||
assertEquals(proof,directory.markCleared(dungeonId,generation,roomId)
|
||||
.get(5,TimeUnit.SECONDS).completionAudit());
|
||||
}
|
||||
var loaded=repository.load().runs().stream().filter(run->run.dungeon().equals(dungeonId))
|
||||
.findFirst().orElseThrow();
|
||||
assertEquals(boss.toString(),loaded.completionAudit().get("boss_id"));
|
||||
assertEquals(roomId.toString(),loaded.completionAudit().get("room_id"));
|
||||
assertTrue(loaded.completionPublished(),"acknowledged export survives room updates and reload");
|
||||
}
|
||||
|
||||
@Test void persistsConnectedDefinitionAndRejectsOverlapOrDisconnectedRemoval() throws Exception {
|
||||
var repository=new YamlDungeonRepository(data);
|
||||
UUID world=UUID.randomUUID();
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.dungeon.DungeonState;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
final class DungeonCompletionExportEvidenceTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void onlyMatchingExportedJsonlCanAcknowledgeTheExactCommittedBoss() throws Exception {
|
||||
var dungeon=UUID.randomUUID();var generation=UUID.randomUUID();var boss=UUID.randomUUID();
|
||||
var run=new DungeonState.Run(dungeon,generation,Set.of(),Set.of(),Set.of(),Set.of(),
|
||||
true,Map.of("boss_id",boss.toString()));
|
||||
var id=DungeonEventBridge.completionEventId(run);
|
||||
var pending=data.resolve("events/pending");Files.createDirectories(pending);
|
||||
Files.writeString(pending.resolve(id+".pending"),record(id,dungeon,generation,boss));
|
||||
assertFalse(DungeonEventBridge.exported(data,id,dungeon,generation,boss),
|
||||
"a transient pending intent is not an exported acknowledgement");
|
||||
|
||||
var day=data.resolve("events/2026-09-27");Files.createDirectories(day);
|
||||
var segment=day.resolve("segment.jsonl");
|
||||
Files.writeString(segment,record(id,dungeon,generation,boss)+"\n");
|
||||
assertTrue(DungeonEventBridge.exported(data,id,dungeon,generation,boss));
|
||||
assertFalse(DungeonEventBridge.exported(data,UUID.randomUUID(),dungeon,generation,boss));
|
||||
var externalId=UUID.randomUUID();
|
||||
var outside=data.resolve("outside.jsonl");
|
||||
Files.writeString(outside,record(externalId,dungeon,generation,boss)+"\n");
|
||||
Files.createSymbolicLink(day.resolve("linked.jsonl"),outside);
|
||||
assertFalse(DungeonEventBridge.exported(data,externalId,dungeon,generation,boss),
|
||||
"a linked external file is not an exported event segment");
|
||||
assertThrows(IOException.class,()->DungeonEventBridge.exported(data,id,dungeon,
|
||||
generation,UUID.randomUUID()),"an identity collision cannot acknowledge another boss");
|
||||
Files.writeString(segment,record(id,dungeon,generation,boss)+"\n{" );
|
||||
assertThrows(IOException.class,()->DungeonEventBridge.exported(data,UUID.randomUUID(),
|
||||
dungeon,generation,boss),"corrupt exported segments cannot attest absence safely");
|
||||
}
|
||||
|
||||
private static String record(UUID id,UUID dungeon,UUID generation,UUID boss){
|
||||
return "{\"id\":\""+id+"\",\"type\":\"dungeon.completed\",\"data\":{"
|
||||
+"\"dungeon_id\":\""+dungeon+"\",\"generation_id\":\""+generation
|
||||
+"\",\"boss_id\":\""+boss+"\"}}";
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,56 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
class FeatureEventOutboxCollectionsTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void rejectedInFlightIntentCanReplayWithItsOriginalIdAndDifferentIdsStillDeliver() throws Exception {
|
||||
var first=new EventInput(UUID.randomUUID(),UUID.randomUUID(),Instant.parse("2026-09-26T00:00:00Z"),
|
||||
"adventure.trophy_earned",null,Map.of("receipt_id",UUID.randomUUID().toString()));
|
||||
var other=new EventInput(UUID.randomUUID(),UUID.randomUUID(),Instant.parse("2026-09-26T00:00:01Z"),
|
||||
"adventure.trophy_earned",null,Map.of("receipt_id",UUID.randomUUID().toString()));
|
||||
var refused=new CompletableFuture<LocalEventLog.Delivery>();
|
||||
var initial=new java.util.concurrent.CountDownLatch(2);
|
||||
var deliveries=new java.util.concurrent.CopyOnWriteArrayList<EventInput>();
|
||||
var failures=new ArrayList<Throwable>();
|
||||
var writer=new FeatureEventOutbox(data,"fishing-event-intents",
|
||||
Set.of("adventure.trophy_earned"),event->{
|
||||
deliveries.add(event);
|
||||
if(deliveries.size()<=2)initial.countDown();
|
||||
return event.id().equals(first.id())&&deliveries.stream()
|
||||
.filter(value->value.id().equals(first.id())).count()==1
|
||||
?refused:CompletableFuture.completedFuture(LocalEventLog.Delivery.EXPORTED);
|
||||
},failures::add);
|
||||
writer.accepted(first);
|
||||
writer.accepted(other);
|
||||
assertTrue(initial.await(5,TimeUnit.SECONDS));
|
||||
assertEquals(List.of(first,other),deliveries);
|
||||
refused.complete(LocalEventLog.Delivery.REJECTED);
|
||||
assertTrue(java.nio.file.Files.exists(data.resolve("fishing-event-intents")
|
||||
.resolve(first.id()+".pending")));
|
||||
writer.replay();writer.close();writer.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
|
||||
assertEquals(List.of(first,other,first),deliveries);
|
||||
assertTrue(failures.isEmpty(),failures.toString());
|
||||
}
|
||||
|
||||
@Test void failedInFlightIntentRemainsRetryableWithItsOriginalId() throws Exception {
|
||||
var original=new EventInput(UUID.randomUUID(),UUID.randomUUID(),
|
||||
Instant.parse("2026-09-26T00:00:00Z"),"adventure.trophy_earned",null,
|
||||
Map.of("receipt_id",UUID.randomUUID().toString()));
|
||||
var deliveries=new java.util.concurrent.CopyOnWriteArrayList<EventInput>();
|
||||
var failures=new java.util.concurrent.CopyOnWriteArrayList<Throwable>();
|
||||
var writer=new FeatureEventOutbox(data,"fishing-event-intents",
|
||||
Set.of("adventure.trophy_earned"),event->{
|
||||
deliveries.add(event);
|
||||
return deliveries.size()==1
|
||||
?CompletableFuture.failedFuture(new java.io.IOException("sink unavailable"))
|
||||
:CompletableFuture.completedFuture(LocalEventLog.Delivery.EXPORTED);
|
||||
},failures::add);
|
||||
writer.accepted(original);
|
||||
writer.replay();writer.close();writer.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
|
||||
assertEquals(List.of(original,original),deliveries);
|
||||
assertEquals(1,failures.size());
|
||||
assertFalse(java.nio.file.Files.exists(data.resolve("fishing-event-intents")
|
||||
.resolve(original.id()+".pending")));
|
||||
}
|
||||
|
||||
@Test void durableFishingRosterAndAllocationsReplayAsStructuredJsonValues() throws Exception {
|
||||
var original = new EventInput(UUID.randomUUID(), UUID.randomUUID(), Instant.parse("2026-09-26T00:00:00Z"),
|
||||
"adventure.items_submitted", null, Map.of("roster", List.of("first", "second"),
|
||||
|
||||
@@ -1,15 +1,171 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import games.dmg.fantasystick.shop.*;
|
||||
|
||||
import games.dmg.fantasystick.shop.RareCatalog;
|
||||
import games.dmg.fantasystick.shop.RareProduct;
|
||||
import java.math.BigInteger;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class RareEventBridgeTest {
|
||||
@Test void phoenixCatalogAndPurchaseFactsUseStableExactIdentifiersAndDecimalStrings(){
|
||||
var captured=new ArrayList<EventInput>();var now=Instant.parse("2026-09-25T01:00:00Z");var bridge=new RareEventBridge(captured::add,Clock.fixed(now,ZoneOffset.UTC));var offers=new EnumMap<RareProduct,RareCatalog.Offer>(RareProduct.class);offers.put(RareProduct.PHOENIX_DOWN,new RareCatalog.Offer(BigInteger.valueOf(1007),2));var catalog=new RareCatalog(LocalDate.of(2026,9,25),UUID.randomUUID(),now,RareCatalog.RefreshReason.ADMINISTRATIVE,offers);bridge.catalogRefreshed(catalog);bridge.catalogRefreshed(catalog);assertEquals(2,captured.size());assertEquals(captured.get(0).id(),captured.get(1).id());assertEquals("rare_catalog.refreshed",captured.getFirst().type());assertEquals("phoenix-down",captured.getFirst().data().get("product_id"));assertEquals("1007",captured.getFirst().data().get("price"));assertEquals("2",captured.getFirst().data().get("stock"));captured.clear();UUID operation=UUID.randomUUID(),player=UUID.randomUUID(),character=UUID.randomUUID(),session=UUID.randomUUID();bridge.purchased(operation,catalog.revision(),player,"Account",character,"Wren",session,BigInteger.valueOf(1007),2,1,BigInteger.valueOf(12),BigInteger.valueOf(11));bridge.purchased(operation,catalog.revision(),player,"Account",character,"Wren",session,BigInteger.valueOf(1007),2,1,BigInteger.valueOf(12),BigInteger.valueOf(11));assertEquals(captured.get(0).id(),captured.get(1).id());var event=captured.getFirst();assertEquals("rare_item.purchased",event.type());assertEquals(operation,event.actionId());assertEquals(character,event.context().characterId());assertEquals("2",event.data().get("stock_before"));assertEquals("1",event.data().get("stock_after"));assertEquals("12",event.data().get("quantity_before"));assertEquals("11",event.data().get("quantity_after"));
|
||||
}
|
||||
@Test void eventSinkFailureCannotAlterMarketCallers(){var bridge=new RareEventBridge(value->{throw new IllegalStateException("disk");},Clock.systemUTC());var offers=new EnumMap<RareProduct,RareCatalog.Offer>(RareProduct.class);offers.put(RareProduct.PHOENIX_DOWN,new RareCatalog.Offer(BigInteger.ONE,0));assertDoesNotThrow(()->bridge.catalogRefreshed(new RareCatalog(LocalDate.now(),UUID.randomUUID(),Instant.now(),RareCatalog.RefreshReason.SCHEDULED,offers)));}
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void rejectedCatalogIntentReplaysOneFullProductRevisionIntoJsonl() throws Exception {
|
||||
var now=Instant.parse("2026-09-25T01:00:00Z");
|
||||
var offers=new EnumMap<RareProduct,RareCatalog.Offer>(RareProduct.class);
|
||||
offers.put(RareProduct.PHOENIX_DOWN,new RareCatalog.Offer(BigInteger.valueOf(1007),2));
|
||||
offers.put(RareProduct.HEALTH_POTION,new RareCatalog.Offer(BigInteger.valueOf(151),0));
|
||||
var catalog=new RareCatalog(LocalDate.of(2026,9,25),UUID.randomUUID(),now,
|
||||
RareCatalog.RefreshReason.ADMINISTRATIVE,offers);
|
||||
var refused=new RareEventBridge(event->CompletableFuture.completedFuture(LocalEventLog.Delivery.REJECTED),
|
||||
Clock.fixed(now,ZoneOffset.UTC),data,ignored->{});
|
||||
refused.catalogRefreshed(catalog);refused.close();
|
||||
refused.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
|
||||
var pending=data.resolve("rare-event-intents");
|
||||
try(var paths=java.nio.file.Files.list(pending)){
|
||||
assertEquals(1,paths.filter(path->path.toString().endsWith(".pending")).count());
|
||||
}
|
||||
var log=LocalEventLog.open(data,Clock.fixed(now,ZoneOffset.UTC),
|
||||
new LocalEventLog.RuntimeInfo("test","test","test"),128,1_000_000,ignored->{});
|
||||
log.ready().toCompletableFuture().get(5,TimeUnit.SECONDS);
|
||||
var recovered=new RareEventBridge(log::emit,Clock.fixed(now,ZoneOffset.UTC),data,ignored->{});
|
||||
recovered.replay();recovered.close();recovered.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
|
||||
log.close();
|
||||
var rows=new ArrayList<com.google.gson.JsonObject>();
|
||||
try(var paths=java.nio.file.Files.walk(data.resolve("events"))){
|
||||
for(var path:paths.filter(value->value.toString().endsWith(".jsonl")).toList())
|
||||
for(var line:java.nio.file.Files.readAllLines(path)){
|
||||
var event=com.google.gson.JsonParser.parseString(line).getAsJsonObject();
|
||||
if(event.get("type").getAsString().endsWith("rare_catalog.refreshed"))rows.add(event);
|
||||
}
|
||||
}
|
||||
assertEquals(1,rows.size());
|
||||
assertEquals(catalog.revision().toString(),rows.getFirst().getAsJsonObject("data")
|
||||
.get("catalog_revision").getAsString());
|
||||
assertEquals(2,rows.getFirst().getAsJsonObject("data").getAsJsonArray("products").size());
|
||||
}
|
||||
|
||||
@Test void oneCommittedCatalogRevisionIncludesPotionAndPhoenixOffersExactlyOnce() {
|
||||
var captured = new ArrayList<EventInput>();
|
||||
var now = Instant.parse("2026-09-25T01:00:00Z");
|
||||
var bridge = new RareEventBridge(captured::add, Clock.fixed(now, ZoneOffset.UTC));
|
||||
var offers = new EnumMap<RareProduct, RareCatalog.Offer>(RareProduct.class);
|
||||
offers.put(RareProduct.PHOENIX_DOWN, new RareCatalog.Offer(BigInteger.valueOf(1007), 2));
|
||||
offers.put(RareProduct.LESSER_HEALTH_POTION, new RareCatalog.Offer(BigInteger.valueOf(51), 0));
|
||||
offers.put(RareProduct.HEALTH_POTION, new RareCatalog.Offer(BigInteger.valueOf(149), 3));
|
||||
offers.put(RareProduct.GREATER_HEALTH_POTION, new RareCatalog.Offer(BigInteger.valueOf(499), 1));
|
||||
var catalog = new RareCatalog(LocalDate.of(2026, 9, 25), UUID.randomUUID(), now,
|
||||
RareCatalog.RefreshReason.ADMINISTRATIVE, offers);
|
||||
|
||||
bridge.catalogRefreshed(catalog);
|
||||
bridge.catalogRefreshed(catalog);
|
||||
assertEquals(1, captured.size(), "One revision is one catalog success");
|
||||
var event = captured.getFirst();
|
||||
assertEquals("rare_catalog.refreshed", event.type());
|
||||
assertEquals(catalog.revision(), event.actionId());
|
||||
assertEquals(catalog.refreshedAt(), event.time());
|
||||
assertEquals(catalog.revision().toString(), event.data().get("catalog_revision"));
|
||||
assertEquals("2026-09-25", event.data().get("catalog_day"));
|
||||
assertEquals("administrative", event.data().get("refresh_reason"));
|
||||
assertEquals("2026-09-26T00:00:00Z", event.data().get("next_midnight"));
|
||||
assertFalse(event.data().containsKey("product_id"), "No Phoenix-only second success");
|
||||
@SuppressWarnings("unchecked") var products = (List<Map<String, String>>) event.data().get("products");
|
||||
assertEquals(List.of("lesser-health-potion", "health-potion", "greater-health-potion", "phoenix-down"),
|
||||
products.stream().map(product -> product.get("product_id")).toList());
|
||||
assertEquals("0", products.getFirst().get("stock"));
|
||||
assertEquals("51", products.getFirst().get("price"));
|
||||
assertEquals("1007", products.getLast().get("price"));
|
||||
assertEquals("2", products.getLast().get("stock"));
|
||||
}
|
||||
|
||||
@Test void phoenixPurchaseRetainsStableExactIdentifiersAndDecimalStrings() {
|
||||
var captured = new ArrayList<EventInput>();
|
||||
var bridge = new RareEventBridge(captured::add, Clock.systemUTC());
|
||||
UUID operation = UUID.randomUUID(), catalog = UUID.randomUUID(), player = UUID.randomUUID(),
|
||||
character = UUID.randomUUID(), session = UUID.randomUUID();
|
||||
bridge.purchased(operation, catalog, player, "Account", character, "Wren", session,
|
||||
BigInteger.valueOf(1007), 2, 1, BigInteger.valueOf(12), BigInteger.valueOf(11));
|
||||
bridge.purchased(operation, catalog, player, "Account", character, "Wren", session,
|
||||
BigInteger.valueOf(1007), 2, 1, BigInteger.valueOf(12), BigInteger.valueOf(11));
|
||||
assertEquals(captured.get(0).id(), captured.get(1).id());
|
||||
var event = captured.getFirst();
|
||||
assertEquals("rare_item.purchased", event.type());
|
||||
assertEquals(operation, event.actionId());
|
||||
assertEquals(character, event.context().characterId());
|
||||
assertEquals("phoenix-down", event.data().get("product_id"));
|
||||
assertEquals("2", event.data().get("stock_before"));
|
||||
assertEquals("1", event.data().get("stock_after"));
|
||||
assertEquals("12", event.data().get("quantity_before"));
|
||||
assertEquals("11", event.data().get("quantity_after"));
|
||||
}
|
||||
|
||||
@Test void 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);
|
||||
offers.put(RareProduct.PHOENIX_DOWN, new RareCatalog.Offer(BigInteger.ONE, 0));
|
||||
assertDoesNotThrow(() -> bridge.catalogRefreshed(new RareCatalog(LocalDate.now(), UUID.randomUUID(),
|
||||
Instant.now(), RareCatalog.RefreshReason.SCHEDULED, offers)));
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,63 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
class RareMarketTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void committedStartupAndManualRevisionsNotifyOnceButCurrentRestartDoesNot() throws Exception {
|
||||
var clock=Clock.fixed(Instant.parse("2026-09-23T12:00:00Z"),ZoneOffset.UTC);
|
||||
try(var settings=new RareSettings(data,Map.of("rare-price-variance-percent","0"));
|
||||
var characters=new CharacterStorage(new YamlCharacterRepository(data),clock);
|
||||
var inventory=new RareInventoryDirectory(new YamlRareInventoryRepository(data))){
|
||||
var notifications=new java.util.ArrayList<RareCatalog>();
|
||||
try(var market=new RareMarket(new YamlRareCatalogRepository(data),
|
||||
new RarePurchaseOperationStore(data),characters,inventory,settings::rules,clock,
|
||||
(minimum,maximum)->minimum)){
|
||||
market.ready().toCompletableFuture().join();
|
||||
market.attachListener(notifications::add);
|
||||
var startup=market.current().toCompletableFuture().join();
|
||||
assertEquals(List.of(startup.revision()),notifications.stream()
|
||||
.map(RareCatalog::revision).toList());
|
||||
var manual=market.restock().toCompletableFuture().join();
|
||||
assertEquals(List.of(startup.revision(),manual.revision()),notifications.stream()
|
||||
.map(RareCatalog::revision).toList());
|
||||
market.current().toCompletableFuture().join();
|
||||
assertEquals(2,notifications.size());
|
||||
}
|
||||
try(var restarted=new RareMarket(new YamlRareCatalogRepository(data),
|
||||
new RarePurchaseOperationStore(data),characters,inventory,settings::rules,clock,
|
||||
(minimum,maximum)->minimum)){
|
||||
restarted.ready().toCompletableFuture().join();
|
||||
var onRestart=new java.util.ArrayList<RareCatalog>();
|
||||
restarted.attachListener(onRestart::add);
|
||||
restarted.current().toCompletableFuture().join();
|
||||
assertTrue(onRestart.isEmpty(),"Retaining a current revision is not a refresh success");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void midnightRefreshNotifiesOnlyTheNewCommittedUtcRevision() throws Exception {
|
||||
var instant=new java.util.concurrent.atomic.AtomicReference<>(Instant.parse("2026-09-23T23:59:59Z"));
|
||||
Clock clock=new Clock(){
|
||||
@Override public ZoneId getZone(){return ZoneOffset.UTC;}
|
||||
@Override public Clock withZone(ZoneId zone){return zone.equals(ZoneOffset.UTC)?this:Clock.fixed(instant.get(),zone);}
|
||||
@Override public Instant instant(){return instant.get();}
|
||||
};
|
||||
try(var settings=new RareSettings(data,Map.of("rare-price-variance-percent","0"));
|
||||
var characters=new CharacterStorage(new YamlCharacterRepository(data),clock);
|
||||
var inventory=new RareInventoryDirectory(new YamlRareInventoryRepository(data));
|
||||
var market=new RareMarket(new YamlRareCatalogRepository(data),new RarePurchaseOperationStore(data),
|
||||
characters,inventory,settings::rules,clock,(minimum,maximum)->minimum)){
|
||||
market.ready().toCompletableFuture().join();
|
||||
var observed=new java.util.ArrayList<RareCatalog>();market.attachListener(observed::add);
|
||||
var before=market.current().toCompletableFuture().join();
|
||||
instant.set(Instant.parse("2026-09-24T00:00:00Z"));
|
||||
var after=market.current().toCompletableFuture().join();
|
||||
market.current().toCompletableFuture().join();
|
||||
assertNotEquals(before.revision(),after.revision());
|
||||
assertEquals(List.of(before.revision(),after.revision()),observed.stream()
|
||||
.map(RareCatalog::revision).toList());
|
||||
assertEquals(RareCatalog.RefreshReason.SCHEDULED,after.reason());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void purchaseRecoverablyDebitsOnePriceStockAndAddsOneTypedItemAcrossRestart() throws Exception {
|
||||
var clock=Clock.fixed(Instant.parse("2026-09-23T12:00:00Z"),ZoneOffset.UTC);var settings=new RareSettings(data,Map.of("rare-price-variance-percent","0","rare-price-health-potion","40","rare-stock-health-potion-min","1","rare-stock-health-potion-max","1"));var repository=new YamlCharacterRepository(data);var base=new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());var character=new CharacterRecord(base.id(),base.name(),base.revision(),base.stats(),base.level(),base.experience(),base.unspentPoints(),base.health(),base.learned(),base.loadout(),base.passives(),base.cooldowns(),base.restrictedKills(),false,null,null,BigInteger.valueOf(100),true,java.util.Set.of(),BigInteger.ZERO,CharacterGear.none());
|
||||
try(var characters=new CharacterStorage(repository,clock);var inventory=new RareInventoryDirectory(new YamlRareInventoryRepository(data));var market=new RareMarket(new YamlRareCatalogRepository(data),new RarePurchaseOperationStore(data),characters,inventory,settings::rules,clock,(minimum,maximum)->minimum)){
|
||||
|
||||
Reference in new issue
Block a user