feat(champion): export feature-owned quest and native events
Release / release (push) Failing after 12m35s

This commit is contained in:
dmg committed 2026-10-03 01:21:42 -04:00
1 parent 6552a90a93
commit 12cdce1845
20 files changed
+938 -15

No files matched your search

@@ -191,6 +191,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
private java.util.concurrent.CompletionStage<Void> stickKnockbackAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.CampEventBridge campAuditEvents;
private java.util.concurrent.CompletionStage<Void> campAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.ChampionEventBridge championAuditEvents;
private java.util.concurrent.CompletionStage<Void> championAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.HardMobXpEventBridge hardMobXpAuditEvents;
private java.util.concurrent.CompletionStage<Void> hardMobXpAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.TrainingEventBridge trainingAuditEvents;
@@ -924,7 +926,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
adventureQuestCommands.attachCampEvents(campAuditEvents);
adventureQuestController.attachCampEvents(campAuditEvents);
adventureQuests.attachCatalogEvents(campAuditEvents::catalogPublished);
championAuditEvents = new games.dmg.fantasystick.events.ChampionEventBridge(eventLog::emit, getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING, "Champion event intent failed", failure));
admin.attachChampionEvents(championAuditEvents);
adventureQuestCommands.attachChampionEvents(championAuditEvents);
adventureQuestController.attachChampionEvents(championAuditEvents);
adventureQuests.attachQuestExpiryEvents(campAuditEvents::questExpired);
adventureQuests.attachChampionQuestExpiryEvents(championAuditEvents::questExpired);
trainingAuditEvents = new games.dmg.fantasystick.events.TrainingEventBridge(eventLog::emit, getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING, "Training event intent failed", failure));
admin.attachTrainingEvents(trainingAuditEvents);
@@ -1125,6 +1133,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (failure != null) getLogger().log(Level.WARNING, "Previous camp event drain failed", failure);
if (replayCampEvents != null) replayCampEvents.replay();
});
var replayChampionEvents = championAuditEvents;
championAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous champion event drain failed", failure);
if (replayChampionEvents != null) replayChampionEvents.replay();
});
var replayTrainingEvents = trainingAuditEvents;
trainingAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous training event drain failed", failure);
@@ -1241,6 +1254,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingKnockbackEvents = stickKnockbackEvents; stickKnockbackEvents = null;
var closingHardMobXpEvents = hardMobXpAuditEvents; hardMobXpAuditEvents = null;
var closingCampEvents = campAuditEvents; campAuditEvents = null;
var closingChampionEvents = championAuditEvents; championAuditEvents = null;
var closingTrainingEvents = trainingAuditEvents; trainingAuditEvents = null;
var closingArmorEvents = armorAuditEvents; armorAuditEvents = null;
var closingCoinEvents = coinEvents;
@@ -1629,6 +1643,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
closingCampEvents.close(); return null;
}).thenCompose(ignored -> closingCampEvents.drained());
});
if (closingChampionEvents != null) cleanup("champion event intent drain", () -> {
var authorityDrained = closingAdventureQuests == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingAdventureQuests.drained();
championAuditDrain = java.util.concurrent.CompletableFuture.allOf(authorityDrained.toCompletableFuture(), configurationDrain.toCompletableFuture())
.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Champion authority drain failed", failure);
closingChampionEvents.close(); return null;
}).thenCompose(ignored -> closingChampionEvents.drained());
});
if (closingTrainingEvents != null) cleanup("training event intent drain", () -> {
var registrationsDrained = closingTrainingDummies == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingTrainingDummies.drained();
@@ -16,8 +16,10 @@ public final class AdventureQuestCommands {
public void attachFishingEvents(games.dmg.fantasystick.events.FishingEventBridge value){fishingEvents=Objects.requireNonNull(value);}
private games.dmg.fantasystick.events.CampEventBridge campEvents;
public void attachCampEvents(games.dmg.fantasystick.events.CampEventBridge value){campEvents=Objects.requireNonNull(value);}
private games.dmg.fantasystick.events.ChampionEventBridge championEvents;
public void attachChampionEvents(games.dmg.fantasystick.events.ChampionEventBridge value){championEvents=Objects.requireNonNull(value);}
public void adventure(CommandSender sender,String[] args){if(args.length==1){if(sender instanceof Player player)controller.inspect(player);else sender.sendMessage("Usage: /fstick adventure");return;}if(args.length==2&&args[1].equals("tracking")){if(sender instanceof Player player)controller.tracking(player,null);else sender.sendMessage("Quest tracking requires a player.");return;}if(args.length==3&&args[1].equals("tracking")&&(args[2].equals("on")||args[2].equals("off"))){if(sender instanceof Player player)controller.tracking(player,args[2].equals("on"));else sender.sendMessage("Quest tracking requires a player.");return;}if(args.length==2&&args[1].equals("reroll")){if(!authorized(sender))return;sender.sendMessage("Reroll expires every current adventure quest, encounter and reward claim. Run /fstickadmin adventure reroll confirm to reset the day.");return;}if(args.length!=3||!args[1].equals("reroll")||!args[2].equals("confirm")){sender.sendMessage("Usage: /fstick adventure [tracking [on|off]] or /fstickadmin adventure reroll [confirm]");return;}if(!authorized(sender))return;var current=directory.snapshot().catalog();UUID expected=current==null?null:current.revision();var campAudit=campEvents;var actor=campAudit==null?null:campAudit.actor(sender,null);directory.rerollToday(expected,actor).whenComplete((result,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Adventure reset failed; the current generation is unchanged.");return;}switch(result.status()){case STALE->sender.sendMessage("Adventure reset refused: the generation changed or is still being published; inspect it and try again.");case APPLIED->{var catalog=result.catalog().orElseThrow();controller.publishPendingReset().whenComplete((published,publishFailure)->respond(sender,()->{if(publishFailure!=null||!published){sender.sendMessage("Adventure reset is saved but pending recovery; the new generation is not yet available.");return;}sender.sendMessage("Reset daily adventures: generation="+catalog.revision()+", offers="+catalog.sites().size()+", expires="+catalog.expiresAt()+".");}));}}}));}
public void site(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usageSite(sender);return;}switch(args[1]){case"list"->{if(args.length!=2){usageSite(sender);return;}if(directory.snapshot().sites().stream().noneMatch(v->v.type()!=AdventureQuestState.SiteType.DUNGEON))sender.sendMessage("No adventure sites registered.");else directory.snapshot().sites().stream().filter(v->v.type()!=AdventureQuestState.SiteType.DUNGEON).sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.type().name().toLowerCase(Locale.ROOT)+" "+v.displayName()+" at "+locations.format(v.world(),v.x(),v.y(),v.z())+"; "+controller.arena(v).display()+"; mode="+v.participationMode().value()+siteDetails(v)));}case"place"->{if(args.length<3||!(sender instanceof Player player)){usageSite(sender);return;}if(args[2].equals("fishing")){placeFishing(sender,player,args);return;}if(!(args[2].equals("camp")||args[2].equals("champion"))){usageSite(sender);return;}int start=3;var mode=AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;if(args.length>3&&args[3].equals("--mode")){if(args.length<5){usageSite(sender);return;}try{mode=AdventureQuestState.ParticipationMode.parse(args[4]);}catch(IllegalArgumentException invalid){usageSite(sender);return;}start=5;}var placement=placement(args,start);if(placement==null){usageSite(sender);return;}var location=player.getLocation();var type=args[2].equals("camp")?AdventureQuestState.SiteType.CAMP:AdventureQuestState.SiteType.CHAMPION;var campAudit=campEvents;var campActor=campAudit==null?null:campAudit.actor(sender,location);UUID operation=UUID.randomUUID();directory.placeSite(type,placement.name(),Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),placement.arena(),mode).whenComplete((value,failure)->{try{if(failure==null&&campAudit!=null)campAudit.siteRegistered(operation,campActor,value,controller.arena(value));}catch(RuntimeException ignored){}respond(sender,()->sender.sendMessage(failure==null?"Placed "+value.type().name().toLowerCase(Locale.ROOT)+" "+value.id()+" ("+value.displayName()+") at "+locations.format(value.world(),value.x(),value.y(),value.z())+"; "+controller.arena(value).display()+"; mode="+value.participationMode().value()+".":"Adventure site save failed; desired state is unchanged."));});}case"fishing"->{fishing(sender,args);}case"mode"->{if(args.length<3||args.length>4){usageSite(sender);return;}UUID id=parse(args[2]);var current=id==null?null:site(id).orElse(null);if(current==null){sender.sendMessage("Adventure site not found.");return;}if(args.length==3){sender.sendMessage("Adventure site "+id+" mode="+current.participationMode().value()+".");return;}AdventureQuestState.ParticipationMode requested;try{requested=AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){usageSite(sender);return;}var audit=fishingEvents;var auditActor=audit==null?null:audit.actor(sender);var campAudit=campEvents;var campActor=campAudit==null?null:campAudit.actor(sender,null);UUID operation=UUID.randomUUID();directory.setSiteMode(id,current.participationMode(),requested).whenComplete((result,failure)->{try{if(campAudit!=null&&failure==null&&result.status()==AdventureQuestDirectory.ModeStatus.APPLIED)campAudit.siteModeChanged(operation,campActor,current,result.site().orElseThrow(),controller.arena(result.site().orElseThrow()));}catch(RuntimeException ignored){}if(audit!=null&&current.type()==AdventureQuestState.SiteType.FISHING&&failure==null&&result.status()==AdventureQuestDirectory.ModeStatus.APPLIED)audit.site("adventure.site.mode_changed",UUID.randomUUID(),auditActor,result.site().orElseThrow(),Map.of("mode_before",current.participationMode().value(),"mode_after",requested.value()));respond(sender,()->{if(failure!=null){sender.sendMessage("Adventure site mode save failed; the acknowledged mode is unchanged.");return;}sender.sendMessage(switch(result.status()){case APPLIED->"Updated adventure site "+id+" mode="+result.site().orElseThrow().participationMode().value()+" for future acceptances.";case UNCHANGED->"Adventure site "+id+" mode is already "+requested.value()+".";case STALE->"Adventure site mode update refused: the acknowledged mode changed; inspect and retry.";case NOT_FOUND->"Adventure site not found.";});});});}case"remove"->{if(args.length!=3){usageSite(sender);return;}UUID id=parse(args[2]);if(id==null){usageSite(sender);return;}var campAudit=campEvents;var campActor=campAudit==null?null:campAudit.actor(sender,null);UUID operation=UUID.randomUUID();directory.removeSite(id).whenComplete((removed,failure)->{try{if(campAudit!=null&&failure==null&&removed.isPresent())campAudit.siteRemoved(operation,campActor,removed.orElseThrow(),controller.arena(removed.orElseThrow()),directory.snapshot().catalog());}catch(RuntimeException ignored){}respond(sender,()->{if(failure!=null)sender.sendMessage("Adventure site removal failed; desired state is unLine truncated
public void site(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usageSite(sender);return;}switch(args[1]){case"list"->{if(args.length!=2){usageSite(sender);return;}if(directory.snapshot().sites().stream().noneMatch(v->v.type()!=AdventureQuestState.SiteType.DUNGEON))sender.sendMessage("No adventure sites registered.");else directory.snapshot().sites().stream().filter(v->v.type()!=AdventureQuestState.SiteType.DUNGEON).sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.type().name().toLowerCase(Locale.ROOT)+" "+v.displayName()+" at "+locations.format(v.world(),v.x(),v.y(),v.z())+"; "+controller.arena(v).display()+"; mode="+v.participationMode().value()+siteDetails(v)));}case"place"->{if(args.length<3||!(sender instanceof Player player)){usageSite(sender);return;}if(args[2].equals("fishing")){placeFishing(sender,player,args);return;}if(!(args[2].equals("camp")||args[2].equals("champion"))){usageSite(sender);return;}int start=3;var mode=AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;if(args.length>3&&args[3].equals("--mode")){if(args.length<5){usageSite(sender);return;}try{mode=AdventureQuestState.ParticipationMode.parse(args[4]);}catch(IllegalArgumentException invalid){usageSite(sender);return;}start=5;}var placement=placement(args,start);if(placement==null){usageSite(sender);return;}var location=player.getLocation();var type=args[2].equals("camp")?AdventureQuestState.SiteType.CAMP:AdventureQuestState.SiteType.CHAMPION;var campAudit=campEvents;var campActor=campAudit==null?null:campAudit.actor(sender,location);UUID operation=UUID.randomUUID();directory.placeSite(type,placement.name(),Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),placement.arena(),mode).whenComplete((value,failure)->{try{if(failure==null){if(campAudit!=null)campAudit.siteRegistered(operation,campActor,value,controller.arena(value));if(championEvents!=null)championEvents.siteRegistered(operation,campActor,value,controller.arena(value));}}catch(RuntimeException ignored){}respond(sender,()->sender.sendMessage(failure==null?"Placed "+value.type().name().toLowerCase(Locale.ROOT)+" "+value.id()+" ("+value.displayName()+") at "+locations.format(value.world(),value.x(),value.y(),value.z())+"; "+controller.arena(value).display()+"; mode="+value.participationMode().value()+".":"Adventure site save failed; desired state is unchanged."));});}case"fishing"->{fishing(sender,args);}case"mode"->{if(args.length<3||args.length>4){usageSite(sender);return;}UUID id=parse(args[2]);var current=id==null?null:site(id).orElse(null);if(current==null){sender.sendMessage("Adventure site not found.");return;}if(args.length==3){sender.sendMessage("Adventure site "+id+" mode="+current.participationMode().value()+".");return;}AdventureQuestState.ParticipationMode requested;try{requested=AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){usageSite(sender);return;}var audit=fishingEvents;var auditActor=audit==null?null:audit.actor(sender);var campAudit=campEvents;var campActor=campAudit==null?null:campAudit.actor(sender,null);UUID operation=UUID.randomUUID();directory.setSiteMode(id,current.participationMode(),requested).whenComplete((result,failure)->{try{if(failure==null&&result.status()==AdventureQuestDirectory.ModeStatus.APPLIED){if(campAudit!=null)campAudit.siteModeChanged(operation,campActor,current,result.site().orElseThrow(),controller.arena(result.site().orElseThrow()));if(championEvents!=null)championEvents.siteModeChanged(operation,campActor,current,result.site().orElseThrow(),controller.arena(result.site().orElseThrow()));}}catch(RuntimeException ignored){}if(audit!=null&&current.type()==AdventureQuestState.SiteType.FISHING&&failure==null&&result.status()==AdventureQuestDirectory.ModeStatus.APPLIED)audit.site("adventure.site.mode_changed",UUID.randomUUID(),auditActor,result.site().orElseThrow(),Map.of("mode_before",current.participationMode().value(),"mode_after",requested.value()));respond(sender,()->{if(failure!=null){sender.sendMessage("Adventure site mode save failed; the acknowledged mode is unchanged.");return;}sender.sendMessage(switch(result.status()){case APPLIED->"Updated adventure site "+id+" mode="+result.site().orElseThrow().participationMode().value()+" for future acceptances.";case UNCHANGED->"Adventure site "+id+" mode is already "+requested.value()+".";case STALE->"Adventure site mode update refused: the acknowledged mode changed; inspect and retry.";case NOT_FOUND->"Adventure site not found.";});});});}case"remove"->{if(args.length!=3){usageSite(sender);return;}UUID id=parse(args[2]);if(id==null){usageSite(sender);return;}var campAudit=campEvents;var campActor=campAudit==null?null:campAudit.actor(sender,null);UUID operation=UUID.randomUUID();directory.removeSite(id).whenComplete((removed,failure)->{try{if(failure==null&&removed.isPresent()){if(campAudit!=null)camLine truncated
public void giver(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usageGiver(sender);return;}switch(args[1]){case"list"->{if(args.length!=2){usageGiver(sender);return;}if(directory.snapshot().givers().isEmpty())sender.sendMessage("No Quest Givers registered.");else directory.snapshot().givers().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.displayName()+" at "+locations.format(v.world(),v.x(),v.y(),v.z())));}case"place"->{if(!(sender instanceof Player player)){usageGiver(sender);return;}String name=join(args,2);var location=player.getLocation();var campAudit=campEvents;var campActor=campAudit==null?null:campAudit.actor(sender,location);UUID operation=UUID.randomUUID();directory.placeGiver(name,Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Quest Giver save failed; desired state is unchanged.");return;}var observed=controller.observedGiver(value);boolean nativeReturned=false;try{var world=plugin.getServer().getWorld(value.world());if(world!=null&&world.isChunkLoaded(section(value.x()),section(value.z())))controller.reconcile(world.getChunkAt(section(value.x()),section(value.z())));nativeReturned=true;}finally{auditGiver(campAudit,"adventure.quest_giver.placed",operation,campActor,value,observed,nativeReturned);}sender.sendMessage("Placed Quest Giver "+value.id()+" ("+value.displayName()+") at "+locations.format(value.world(),value.x(),value.y(),value.z())+".");}));}case"remove"->{if(args.length!=3){usageGiver(sender);return;}UUID id=parse(args[2]);if(id==null){usageGiver(sender);return;}var campAudit=campEvents;var campActor=campAudit==null?null:campAudit.actor(sender,null);UUID operation=UUID.randomUUID();directory.removeGiver(id).whenComplete((removed,failure)->respond(sender,()->{if(failure!=null)sender.sendMessage("Quest Giver removal failed; desired state is unchanged.");else if(removed.isEmpty())sender.sendMessage("Quest Giver not found.");else{var value=removed.orElseThrow();var observed=controller.observedGiver(value);boolean nativeReturned=false;try{controller.removeGiverLoaded(id);nativeReturned=true;}finally{auditGiver(campAudit,"adventure.quest_giver.removed",operation,campActor,value,observed,nativeReturned);}sender.sendMessage("Removed Quest Giver "+id+".");}}));}default->usageGiver(sender);}}
public List<String> complete(CommandSender sender,String[] args){if(args.length==1){var values=new ArrayList<String>();values.add("adventure");if(sender.hasPermission(PERMISSION)){values.add("adventure-site");values.add("quest-giver");}return matching(values,args[0]);}if(args.length==2&&args[0].equals("adventure")){var values=new ArrayList<String>();if(sender instanceof Player player&&controller.trackingAvailable(player))values.add("tracking");if(sender.hasPermission(PERMISSION))values.add("reroll");return matching(values,args[1]);}if(args.length==3&&args[0].equals("adventure")&&args[1].equals("tracking")&&sender instanceof Player player&&controller.trackingAvailable(player))return matching(List.of("on","off"),args[2]);if(!sender.hasPermission(PERMISSION))return List.of();if(args.length==3&&args[0].equals("adventure")&&args[1].equals("reroll"))return matching(List.of("confirm"),args[2]);if(args.length==2&&args[0].equals("adventure-site"))return matching(sender instanceof Player?List.of("fishing","list","mode","place","remove","teleport"):List.of("fishing","list","mode","remove"),args[1]);if(args.length==3&&args[0].equals("adventure-site")&&args[1].equals("place"))return matching(List.of("camp","champion","fishing"),args[2]);if(args.length>=4&&args[0].equals("adventure-site")&&args[1].equals("place")&&args[2].equals("fishing"))return fishingOptionCompletion(args);if(args.length==4&&args[0].equals("adventure-site")&&args[1].equals("place")&&(args[2].equals("camp")||args[2].equals("champion"))){var values=new ArrayList<>(List.of("--mode","4","16","64"));return matching(values,args[3]);}if(args.length==5&&args[0].equals("adventure-site")&&args[1].equals("place")&&args[3].equals("--mode"))return matching(modeValues(),args[4]);if(args.length==6&&args[0].equals("adventure-site")&&args[1].equals("place")&&args[3].equals("--mode"))return matching(List.of("4","16","64"),args[5]);if(args.length==5&&args[0].equals("adventure-site")&&args[1].equals("place")&&whole(args[3])!=null)return matching(List.of("8","16","128"),args[4]);if(args.length==7&&args[0].equals("adventure-site")&&args[1].equals("place")&&args[3].equals("--mode")&&whole(args[5])!=null)return matching(List.of("8","16","128"),args[6]);if(args.length==3&&args[0].equals("adventure-site")&&Set.of("fishing","mode","remove","teleport").contains(args[1]))return matching(directory.snapshot().sites().stream().filter(v->v.type()!=AdventureQuestState.SiteType.DUNGEON).map(v->v.id().toString()).toList(),args[2]);if(args.length==4&&args[0].equals("adventure-site")&&args[1].equals("mode"))return matching(modeValues(),args[3]);if(args.length>=4&&args[0].equals("adventure-site")&&args[1].equals("fishing"))return fishingOptionCompletion(args);if(args.length==2&&args[0].equals("quest-giver"))return matching(sender instanceof Player?List.of("list","place","remove"):List.of("list","remove"),args[1]);if(args.length==3&&args[0].equals("quest-giver")&&args[1].equals("remove"))return matching(directory.snapshot().givers().stream().map(v->v.id().toString()).toList(),args[2]);return List.of();}
private void placeFishing(CommandSender sender,Player player,String[] args){var options=options(args,3,Set.of("name","item","required","chance","xp","coins","radius","mode"));if(options==null||!options.keySet().containsAll(Set.of("name","item","required","chance","xp","coins"))){usageSite(sender);return;}try{int radius=options.containsKey("radius")?bounded(options.get("radius"),4,64):directory.defaultRadius();var mode=options.containsKey("mode")?AdventureQuestState.ParticipationMode.parse(options.get("mode")):AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;var location=player.getLocation();var audit=fishingEvents;var actor=audit==null?null:audit.actor(sender);directory.placeFishingSite(options.get("name"),options.get("item"),Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),radius,percent(options.get("chance")),positive(options.get("required")),nonnegative(options.get("xp")),nonnegative(options.get("coins")),mode).whenComplete((value,failure)->{if(failure==null&&audit!=null)audit.site("adventure.site.registered",value.id(),actor,value,Map.of("definition","created"));respond(sender,()->sender.sendMessage(failure==null?"Placed fishing site "+value.id()+" ("+value.displayName()+"); "+fishingDetails(value)+".":"Fishing site save failed; desired state is unchanged."));});}catch(IllegalArgumentException invalid){usageSite(sender);}}
@@ -44,7 +44,7 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
private java.util.function.BiPredicate<UUID,Location> dungeonRoomPresence=(ignored,location)->false;
private org.bukkit.scheduler.BukkitTask pulseTask;private boolean closed;
private static final class MenuHolder implements InventoryHolder {final UUID catalog;final int page;final AdventureQuestState.Giver giver;MenuHolder(UUID catalog,int page,AdventureQuestState.Giver giver){this.catalog=catalog;this.page=page;this.giver=giver;}@Override public Inventory getInventory(){return null;}}
private static final class Attempt {AdventureQuestState.Site auditSite;List<org.bukkit.entity.EntityType> auditRoster=List.of();final Map<UUID,String> auditEntities=new LinkedHashMap<>();final UUID id=UUID.randomUUID(),site,catalog;final AdventureQuestState.SiteType type;final Set<UUID> entities=new HashSet<>(),represented=new HashSet<>();final AdventureQuestState.Arena arena;final int resetSeconds;BossBar bossBar;LivingEntity champion;Instant absentSince;
private static final class Attempt {AdventureQuestState.Site auditSite;Map<String,Object> auditConfiguration=Map.of();List<org.bukkit.entity.EntityType> auditRoster=List.of();final Map<UUID,String> auditEntities=new LinkedHashMap<>();final UUID id=UUID.randomUUID(),site,catalog;final AdventureQuestState.SiteType type;final Set<UUID> entities=new HashSet<>(),represented=new HashSet<>();final AdventureQuestState.Arena arena;final int resetSeconds;BossBar bossBar;LivingEntity champion;Instant absentSince;
UUID defeatedEntity;Map<String,Object> defeatLocation=Map.of("status","unavailable");Instant defeatedAt;
Attempt(UUID site,UUID catalog,AdventureQuestState.SiteType type,AdventureQuestState.Arena arena,int resetSeconds){this.site=site;this.catalog=catalog;this.type=type;this.arena=arena;this.resetSeconds=resetSeconds;}}
public AdventureQuestController(Plugin plugin,AdventureQuestDirectory directory,AdventureQuestSettings settings,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,Clock clock,java.util.random.RandomGenerator random){this(plugin,directory,settings,sessions,parties,characters,progression,nextTick,clock,random,games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());}
@@ -103,6 +103,8 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
public void attachFishingEvents(games.dmg.fantasystick.events.FishingEventBridge value){fishingEvents=Objects.requireNonNull(value);}
private games.dmg.fantasystick.events.CampEventBridge campEvents;
public void attachCampEvents(games.dmg.fantasystick.events.CampEventBridge value){campEvents=Objects.requireNonNull(value);}
private games.dmg.fantasystick.events.ChampionEventBridge championEvents;
public void attachChampionEvents(games.dmg.fantasystick.events.ChampionEventBridge value){championEvents=Objects.requireNonNull(value);}
public void registerResetParticipant(AdventureResetParticipant participant){if(closed)throw new IllegalStateException("Adventure controller closed");resetParticipants.add(Objects.requireNonNull(participant));}
public void registerDungeonRoomPresence(java.util.function.BiPredicate<UUID,Location> presence){if(closed)throw new IllegalStateException("Adventure controller closed");dungeonRoomPresence=Objects.requireNonNull(presence);}
public CompletionStage<Boolean> publishPendingReset(){var reset=directory.snapshot().reset();if(reset==null)return CompletableFuture.completedFuture(true);if(resetWork!=null)return resetWork;for(var attempt:new ArrayList<>(attempts.values()))cleanup(attempt,"catalog_reset");attempts.clear();starting.clear();readyAt.clear();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(managed(entity))entity.remove();refreshTrackingPlayers();var work=resetParticipants.stream().map(participant->{try{return participant.reset(reset).toCompletableFuture();}catch(RuntimeException failure){return CompletableFuture.<Void>failedFuture(failure);}}).toArray(CompletableFuture[]::new);resetWork=CompletableFuture.allOf(work).thenCompose(ignored->directory.completeReset(reset.operation())).whenComplete((ignored,failure)->resetWork=null);return resetWork;}
@@ -112,11 +114,17 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
private void open(Player player,int requestedPage){open(player,requestedPage,null);}
private void open(Player player,int requestedPage,AdventureQuestState.Giver giver){var context=context(player);if(context==null){player.sendMessage("Quest Giver refused: actively play a living fantasy character first.");return;}var pending=directory.pendingClaim(context.character);if(pending.isPresent()){var record=sessions.currentRecord(context.active).orElse(null);if(record==null){player.sendMessage("Reward claim recovery requires the exact active character.");return;}attemptClaim(player,context.active,pending.orElseThrow(),record);return;}directory.ensureToday().whenComplete((catalog,failure)->schedule(()->{var currentContext=context(player);if(failure!=null||currentContext==null||!currentContext.active.equals(context.active)){player.sendMessage("Daily quests are unavailable.");return;}if(directory.snapshot().reset()!=null){publishPendingReset();player.sendMessage("Daily adventure reset is completing; try again.");return;}int pages=Math.max(1,(catalog.sites().size()+44)/45),page=Math.max(0,Math.min(requestedPage,pages-1));int count=Math.min(45,Math.max(0,catalog.sites().size()-page*45));int size=pages>1?54:Math.min(54,Math.max(18,((count+8)/9)*9+9));var inventory=plugin.getServer().createInventory(new MenuHolder(catalog.revision(),page,giver),size,net.kyori.adventure.text.Component.text("Daily Adventure Quests"));int slot=0;for(UUID siteId:catalog.sites().stream().skip(page*45L).limit(45).toList()){var site=directory.findSite(siteId).orElse(null);var item=new ItemStack(Material.PAPER);var meta=item.getItemMeta();String name=site==null?"Unavailable site":site.displayName();meta.displayName(net.kyori.adventure.text.Component.text(name));var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog.revision(),siteId,context.character);boolean rewardsReady=unclaimed.xp().signum()>0||unclaimed.coins().signum()>0;String state=rewardsReady?"Rewards ready — click to claim":active!=null&&active.site().equals(siteId)?"Active — right-click to abandon":active!=null?"Another quest is active":directory.characterClaimed(catalog.revision(),siteId,context.character)?directory.rewardClaimed(catalog.revision(),siteId,context.character)?"Already claimed":"Completed with nothing owed":site==null||!directory.offerOpen(siteId)||!directory.available(site)?"Unavailable / cleared":"Available — click to accept";String kind=site==null?"Unavailable":switch(site.type()){case CAMP->"Monster camp";case CHAMPION->"Champion";case DUNGEON->"Dungeon";case FISHING->"Fishing";};var reward=site==null?new AdventureQuestDirectory.Reward(BigInteger.ZERO,BigInteger.ZERO):directory.reward(site);var rewardXp=rewardsReady?unclaimed.xp():active!=null&&active.site().equals(siteId)?active.rewardXp():reward.xp();var rewardCoins=rewardsReady?unclaimed.coins():active!=null&&active.site().equals(siteId)?active.rewardCoins():reward.coins();String rewardLabel=rewardsReady?"Banked reward: ":site!=null&&site.type()==AdventureQuestState.SiteType.DUNGEON?"Completion trophy pool: ":"Reward: ";var lore=new ArrayList<net.kyori.adventure.text.Component>();lore.add(net.kyori.adventure.text.Component.text(kind));var fishingTerms=active!=null&&active.site().equals(siteId)&&active.fishing()!=null?active.fishing():site==null||site.fishing()==null?null:site.fishing().terms();if(fishingTerms!=null){lore.add(net.kyori.adventure.text.Component.text("Item: "+fishingTerms.itemName()+" × "+fishingTerms.required()));lore.add(net.kyori.adventure.text.Component.text("Catch chance: "+fishingTerms.chancePercent().toPlainString()+"%"));if(active!=null&&active.site().equals(siteId)&&fishing!=null)lore.add(net.kyori.adventure.text.Component.text("Held: "+fishing.quantity(context.character,fishingTerms.itemId())+" / "+fishingTerms.required()));}lore.add(net.kyori.adventure.text.Component.text(rewardLabel+rewardXp+" XP, "+rewardCoins+" coins"));if(site!=null&&site.type()==AdventureQuestState.SiteType.DUNGEON)lore.add(net.kyori.adventure.text.Component.text("Mob kills: additional variable XP, coins and native loot"));if(site!=null){lore.add(net.kyori.adventure.text.Component.text("Mode: "+site.participationMode().value()));if(active==null&&!rewardsReady&&!directory.characterClaimed(catalog.revision(),siteId,context.character)&&directory.offerOpen(siteId)&&directory.available(site))acceptanceLore(lore,site.participationMode(),context);}lore.add(net.kyori.adventure.text.Component.text("Expires: "+catalog.expiresAt()));lore.add(net.kyori.adventure.text.Component.text(state));meta.lore(lore);meta.getPersistentDataContainer().set(menuSiteKey,PersistentDataType.STRING,siteId.toString());meta.getPersistentDataContainer().set(menuCatalogKey,PersistentDataType.STRING,catalog.revision().toString());item.setItemMeta(meta);inventory.setItem(slot++,item);}if(pages>1){if(page>0)inventory.setItem(45,pageItem(page-1,"Previous page"));if(page+1<pages)inventory.setItem(53,pageItem(page+1,"Next page"));}if(fishing!=null){var turnIn=new ILine truncated
private ItemStack pageItem(int page,String name){var item=new ItemStack(Material.ARROW);var meta=item.getItemMeta();meta.displayName(net.kyori.adventure.text.Component.text(name));meta.getPersistentDataContainer().set(menuPageKey,PersistentDataType.INTEGER,page);item.setItemMeta(meta);return item;}
private static games.dmg.fantasystick.events.CampEventBridge.Actor championQuestActor(games.dmg.fantasystick.events.CampEventBridge.Actor actor,AdventureQuestState.Giver giver){
var facts=new LinkedHashMap<String,Object>(actor==null?Map.of("actor_kind","unavailable"):actor.captured());
if(giver!=null){facts.put("giver_id",giver.id().toString());facts.put("giver_location",Map.of("world_id",giver.world().toString(),"x",giver.x(),"y",giver.y(),"z",giver.z()));facts.put("giver_capture_quality","original_menu_target");}
else facts.put("giver_capture_quality","unavailable");
return new games.dmg.fantasystick.events.CampEventBridge.Actor(actor==null?null:actor.context(),facts);
}
private static void acceptanceLore(List<net.kyori.adventure.text.Component> lore,AdventureQuestState.ParticipationMode mode,Context context){if(mode!=AdventureQuestState.ParticipationMode.PARTY_REQUIRED)lore.add(net.kyori.adventure.text.Component.text("Left click: Accept individually"));if(mode!=AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY){if(context.partyId!=null&&context.leader)lore.add(net.kyori.adventure.text.Component.text("Right click: Accept for party"));else lore.add(net.kyori.adventure.text.Component.text("Party acceptance requires the active leader"));}}
private record Context(EquipmentSessions.Active active,UUID character,UUID partyId,boolean leader,Set<UUID> members){}
private Context context(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return null;var record=sessions.currentRecord(active).orElse(null);if(record==null||record.dead()||player.isDead())return null;var party=parties.partyOf(player.getUniqueId()).orElse(null);if(party==null)return new Context(active,record.id(),null,true,Set.of(record.id()));return new Context(active,record.id(),party.id(),party.leaderCharacter().equals(record.id()),Set.copyOf(party.characters()));}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void interact(PlayerInteractEntityEvent event){if(!giver(event.getRightClicked()))return;event.setCancelled(true);if(event.getHand()!=EquipmentSlot.HAND)return;UUID id=id(event.getRightClicked(),idKey);if(id!=null)directory.findGiver(id).ifPresent(giver->open(event.getPlayer(),0,giver));}
@EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof MenuHolder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null)return;var data=event.getCurrentItem().getPersistentDataContainer();Integer page=data.get(menuPageKey,PersistentDataType.INTEGER);if(page!=null){open(player,page,holder.giver);return;}if(data.has(menuFishingKey,PersistentDataType.INTEGER)&&fishing!=null){var now=directory.snapshot();if(now.reset()!=null||now.catalog()==null||!holder.catalog.equals(now.catalog().revision())){player.sendMessage("Quest item entry refused: the Quest Giver menu expired.");return;}fishing.open(player,holder.giver);return;}UUID site=parse(data.get(menuSiteKey,PersistentDataType.STRING)),catalog=parse(data.get(menuCatalogKey,PersistentDataType.STRING));if(site==null||catalog==null||!holder.catalog.equals(catalog))return;var context=context(player);if(context==null){player.sendMessage("Quest action refused: stale fantasy session.");return;}var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog,site,context.character);if(unclaimed.xp().signum()>0||unclaimed.coins().signum()>0){claim(player,context,catalog,site);return;}if(active!=null){if(!active.site().equals(site)){player.sendMessage("Quest action refused: another quest is active.");return;}if(!event.isRightClick()){player.sendMessage("This quest is active; right-click its Paper to abandon it.");return;}var audit=campEvents;var actor=audit==null?null:audit.actor(player,null);UUID operation=UUID.randomUUID();var capturedSite=directory.findSite(active.site()).orElse(null);directory.abandon(active.id(),active.unit()).whenComplete((changed,failure)->{if(audit!=null&&failure==null&&changed)audit.questTransition("adventure.quest.abandoned",operation,actor,active,capturedSite,null,"player_abandoned");schedule(()->{refreshTracking(player);player.sendMessage(failure==null&&changed?"Adventure quest abandoned.":"Quest abandonment failed; retained state is unchanged.");open(player);});});return;}if(directory.characterClaimed(catalog,site,context.character)){player.sendMessage(directory.rewardClaimed(catalog,site,context.character)?"Adventure rewards were already claimed.":"Adventure complete with no reward owed.");return;}if(!directory.offerOpen(site)||directory.findSite(site).filter(directory::available).isEmpty()){player.sendMessage("Quest action refused: this offer is unavailable.");return;}var offered=directory.findSite(site).orElseThrow();var type=event.isRightClick()?AdventureQuestState.UnitType.PARTY:AdventureQuestState.UnitType.SOLO;if(!offered.participationMode().permits(type)){player.sendMessage("Quest acceptance refused: this site is "+offered.participationMode().value()+".");return;}if(type==AdventureQuestState.UnitType.PARTY&&(context.partyId==null||!context.leader)){player.sendMessage("Only the active party leader may accept a party quest.");return;}var unit=type==AdventureQuestState.UnitType.SOLO?context.character:context.partyId;var members=type==AdventureQuestState.UnitType.SOLO?Set.of(context.character):context.members;var campAudit=campEvents;var campActor=campAudit==null?null:campAudit.actor(player,null);UUID operation=UUID.randomUUID();directory.accept(catalog,site,type,unit,members,offered.participationMode()).whenComplete((result,failure)->schedule(()->{if(failure!=null){player.sendMessage("Quest acceptance failed; retained state is unchanged.");return;}if(result.status()!=AdventureQuestDirectory.Acceptance.APPLIED){player.sendMessage("Quest acceptance refused: "+result.status().name().toLowerCase(Locale.ROOT)+".");return;}var quest=result.quest().orElseThrow();var current=context(player);boolean same=current!=null&&current.active.equals(context.active)&&current.character.equals(context.character)&&(type==AdventureQuestState.UnitType.SOLO||current.leader&&Objects.equals(current.partyId,context.partyId)&&current.members.equals(context.members));if(!same){directory.abandon(quest.id(),quest.unit()).whenComplete((ignored,rollbackFailure)->schedule(()->player.sendMessage(rollbackFailure==null?"Quest acceptance refused: the character or party changed.":"Quest acceptance is pending safe cancellation; retry after recovery.")));return;}var location=directory.findSite(site).orElseThrow();if(campAudit!=null)campAudit.questTransition("adventure.quest.accepted",operation,campActor,quest,result.capturedSite().orElse(null),type==AdventureQuestState.UnitType.PARTY?context.character:null,"accepted");if(quest.fishing()!=null&&fishingEvents!=null)fishingEvents.accepted(quest.id(),fishingEvents.actor(player),quest);refreshTracking(player);player.sendMessage("Accepted "+location.displayName()+"; expires "+quest.expiresAt()+". Follow the Quest Compass.");}));}
@EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof MenuHolder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null)return;var data=event.getCurrentItem().getPersistentDataContainer();Integer page=data.get(menuPageKey,PersistentDataType.INTEGER);if(page!=null){open(player,page,holder.giver);return;}if(data.has(menuFishingKey,PersistentDataType.INTEGER)&&fishing!=null){var now=directory.snapshot();if(now.reset()!=null||now.catalog()==null||!holder.catalog.equals(now.catalog().revision())){player.sendMessage("Quest item entry refused: the Quest Giver menu expired.");return;}fishing.open(player,holder.giver);return;}UUID site=parse(data.get(menuSiteKey,PersistentDataType.STRING)),catalog=parse(data.get(menuCatalogKey,PersistentDataType.STRING));if(site==null||catalog==null||!holder.catalog.equals(catalog))return;var context=context(player);if(context==null){player.sendMessage("Quest action refused: stale fantasy session.");return;}var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog,site,context.character);if(unclaimed.xp().signum()>0||unclaimed.coins().signum()>0){claim(player,context,catalog,site);return;}if(active!=null){if(!active.site().equals(site)){player.sendMessage("Quest action refused: another quest is active.");return;}if(!event.isRightClick()){player.sendMessage("This quest is active; right-click its Paper to abandon it.");return;}var audit=campEvents;var championAudit=championEvents;var actor=audit==null?null:audit.actor(player,null);var championActor=championQuestActor(actor,holder.giver);UUID operation=UUID.randomUUID();var capturedSite=directory.findSite(active.site()).orElse(null);directory.abandon(active.id(),active.unit()).whenComplete((changed,failure)->{if(failure==null&&changed){if(audit!=null)audit.questTransition("adventure.quest.abandoned",operation,actor,active,capturedSite,null,"player_abandoned");if(championAudit!=null)championAudit.questTransition("adventure.quest.abandoned",operation,championActor,active,capturedSite,null,"player_abandoned");}schedule(()->{refreshTracking(player);player.sendMessage(failure==null&&changed?"Adventure quest abandoned.":"Quest abandonment failed; retained state is unchanged.");open(player);});});return;}if(directory.characterClaimed(catalog,site,context.character)){player.sendMessage(directory.rewardClaimed(catalog,site,context.character)?"Adventure rewards were already claimed.":"Adventure complete with no reward owed.");return;}if(!directory.offerOpen(site)||directory.findSite(site).filter(directory::available).isEmpty()){player.sendMessage("Quest action refused: this offer is unavailable.");return;}var offered=directory.findSite(site).orElseThrow();var type=event.isRightClick()?AdventureQuestState.UnitType.PARTY:AdventureQuestState.UnitType.SOLO;if(!offered.participationMode().permits(type)){player.sendMessage("Quest acceptance refused: this site is "+offered.participationMode().value()+".");return;}if(type==AdventureQuestState.UnitType.PARTY&&(context.partyId==null||!context.leader)){player.sendMessage("Only the active party leader may accept a party quest.");return;}var unit=type==AdventureQuestState.UnitType.SOLO?context.character:context.partyId;var members=type==AdventureQuestState.UnitType.SOLO?Set.of(context.character):context.members;var campAudit=campEvents;var championAudit=championEvents;var campActor=campAudit==null?null:campAudit.actor(player,null);var championActor=championQuestActor(campActor,holder.giver);UUID operation=UUID.randomUUID();directory.accept(catalog,site,type,unit,members,offered.participationMode()).whenComplete((result,failure)->schedule(()->{if(failure!=null){player.sendMessage("Quest acceptance failed; retained state is unchanged.");return;}if(result.status()!=AdventureQuestDirectory.Acceptance.APPLIED){player.sendMessage("Quest acceptance refused: "+result.status().name().toLowerCase(Locale.ROOT)+".");return;}var quest=result.quest().orElseThrow();var current=context(player);boolean same=current!=null&&current.active.equals(context.active)&&current.character.equals(context.character)&&(type==AdventureQuestState.UnitType.SOLO||current.leader&&Objects.equals(current.partyId,context.partyId)&&current.members.equals(context.members));if(!same){directory.abandon(quest.id(),quest.unit()).whenComplete((ignored,rollbackFailure)->schedule(()->player.sendMessage(rollbackFailure==null?"Quest acceptance refused: the character or party changed.":"Quest acceptance is pending safe cancellation; retry after recovery.")));return;}var location=directory.findSite(site).orElseThrow();if(campAudit!=null)campAudit.questTransition("adventure.quest.accepted",operation,campActor,quest,result.capturedSite().orElse(null),type==AdventureQuestState.UnitType.PARTY?context.character:null,"accepted");if(championAudit!=nLine truncated
private void claim(Player player,Context expected,UUID catalog,UUID site){directory.beginClaim(catalog,site,expected.character).whenComplete((operation,failure)->schedule(()->{if(failure!=null||operation.isEmpty()){player.sendMessage("Reward claim failed; retained rewards are unchanged.");return;}var current=context(player);if(current==null||!current.active.equals(expected.active)||!current.character.equals(operation.orElseThrow().character())){player.sendMessage("Reward claim paused: actively play the exact living character and try again.");return;}var record=sessions.currentRecord(current.active).orElse(null);if(record==null){player.sendMessage("Reward claim paused: the current character changed.");return;}attemptClaim(player,current.active,operation.orElseThrow(),record);}));}
private void attemptClaim(Player player,EquipmentSessions.Active active,AdventureQuestState.RewardClaim operation,games.dmg.fantasystick.persistence.CharacterRecord record){characters.awardCombatReward(record.id(),record.revision(),operation.id(),operation.xp(),operation.coins(),progression,games.dmg.fantasystick.progression.CombatRewardRules.defaults(),null).whenComplete((award,failure)->{if(failure!=null){schedule(()->player.sendMessage("Reward claim failed; retained rewards are unchanged."));return;}if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){attemptClaim(player,active,operation,award.record().orElseThrow());return;}if(award.record().isEmpty()){schedule(()->player.sendMessage("Reward claim failed; retained rewards are unchanged."));return;}var saved=award.record().orElseThrow();directory.finishClaim(operation.id()).whenComplete((finished,finishFailure)->schedule(()->{if(finishFailure!=null||!finished){player.sendMessage("Reward claim is pending recovery; retry at a Quest Giver.");return;}var holder=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE&&holder!=null)sessions.acknowledgeProgress(holder,saved.revision().subtract(BigInteger.ONE),saved);var currentPlayer=holder==null?null:plugin.getServer().getPlayer(holder.player());var current=currentPlayer==null?null:context(currentPlayer);if(current!=null&&current.character.equals(operation.character())){currentPlayer.sendMessage("Claimed "+operation.xp()+" fantasy XP and "+operation.coins()+" character coins.");open(currentPlayer);}}));});}
public boolean trackingAvailable(Player player){var context=context(player);return context!=null&&directory.activeFor(context.character,context.partyId).filter(quest->quest.snapshot().contains(context.character)).isPresent();}
@@ -131,7 +139,7 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
private java.util.stream.Stream<Player> eligibleInside(UUID site,AdventureQuestState.Arena arena){var value=directory.findSite(site).orElse(null);if(value==null)return java.util.stream.Stream.empty();return plugin.getServer().getOnlinePlayers().stream().map(Player.class::cast).filter(player->eligible(player,site)!=null).filter(player->inside(player.getLocation(),value,arena));}
private AdventureQuestState.Quest eligible(Player player,UUID site){var context=context(player);if(context==null)return null;var quest=directory.activeFor(context.character,context.partyId).orElse(null);return quest!=null&&quest.site().equals(site)&&quest.snapshot().contains(context.character)?quest:null;}
private void start(UUID site,UUID catalog){var value=directory.findSite(site).orElse(null);if(value==null||value.type()==AdventureQuestState.SiteType.DUNGEON||value.type()==AdventureQuestState.SiteType.FISHING)return;if(value.type()==AdventureQuestState.SiteType.CHAMPION){startChampion(value,catalog);return;}starting.add(site);directory.roster(site).whenComplete((roster,failure)->schedule(()->{starting.remove(site);if(failure!=null||closed||attempts.containsKey(site))return;var current=directory.findSite(site).orElse(null);if(current==null)return;var world=plugin.getServer().getWorld(current.world());if(world==null||!world.isChunkLoaded(section(current.x()),section(current.z())))return;var rules=settings.rules();var attempt=new Attempt(site,catalog,current.type(),arena(current),rules.resetSeconds());attempt.auditSite=current;attempt.auditRoster=List.copyOf(roster);attempts.put(site,attempt);boolean spawned=false;try{for(int index=0;index<roster.size();index++)spawnEnemy(world,current,roster.get(index),attempt,index);spawned=true;}finally{auditAttempt("adventure.camp.started",attempt,"eligible_accepted_unit_entered",spawned);}}));}
private void startChampion(AdventureQuestState.Site site,UUID catalog){if(closed||attempts.containsKey(site.id())||starting.contains(site.id()))return;var world=plugin.getServer().getWorld(site.world());if(world==null||!world.isChunkLoaded(section(site.x()),section(site.z())))return;starting.add(site.id());try{var rules=settings.rules();var attempt=new Attempt(site.id(),catalog,site.type(),arena(site),rules.resetSeconds());attempt.bossBar=plugin.getServer().createBossBar("Daybreak Champion",BarColor.RED,BarStyle.SOLID);attempts.put(site.id(),attempt);world.spawn(new Location(world,site.x(),site.y(),site.z()),Vindicator.class,champion->{configureEnemy(champion,attempt);var maximum=Objects.requireNonNull(champion.getAttribute(Attribute.MAX_HEALTH));maximum.setBaseValue(rules.championHealth());champion.setHealth(rules.championHealth());Objects.requireNonNull(champion.getAttribute(Attribute.ATTACK_DAMAGE)).setBaseValue(rules.championDamage());champion.customName(net.kyori.adventure.text.Component.text("Daybreak Champion"));champion.setCustomNameVisible(true);attempt.champion=champion;});syncBossBar(attempt);}catch(RuntimeException failure){var attempt=attempts.remove(site.id());if(attempt!=null)cleanup(attempt);plugin.getLogger().log(java.util.logging.Level.WARNING,"Champion attempt could not start at site "+site.id(),failure);}finally{starting.remove(site.id());}}
private void startChampion(AdventureQuestState.Site site,UUID catalog){if(closed||attempts.containsKey(site.id())||starting.contains(site.id()))return;var world=plugin.getServer().getWorld(site.world());if(world==null||!world.isChunkLoaded(section(site.x()),section(site.z())))return;starting.add(site.id());Attempt observed=null;boolean spawned=false;try{var rules=settings.rules();var attempt=new Attempt(site.id(),catalog,site.type(),arena(site),rules.resetSeconds());observed=attempt;attempt.auditSite=site;attempt.auditRoster=List.of(org.bukkit.entity.EntityType.VINDICATOR);attempt.auditConfiguration=Map.of("requested_health",rules.championHealth(),"requested_damage",rules.championDamage());attempt.bossBar=plugin.getServer().createBossBar("Daybreak Champion",BarColor.RED,BarStyle.SOLID);attempts.put(site.id(),attempt);world.spawn(new Location(world,site.x(),site.y(),site.z()),Vindicator.class,champion->{configureEnemy(champion,attempt);var maximum=Objects.requireNonNull(champion.getAttribute(Attribute.MAX_HEALTH));maximum.setBaseValue(rules.championHealth());champion.setHealth(rules.championHealth());Objects.requireNonNull(champion.getAttribute(Attribute.ATTACK_DAMAGE)).setBaseValue(rules.championDamage());champion.customName(net.kyori.adventure.text.Component.text("Daybreak Champion"));champion.setCustomNameVisible(true);attempt.champion=champion;});syncBossBar(attempt);spawned=true;}catch(RuntimeException failure){var attempt=attempts.remove(site.id());if(attempt!=null)cleanup(attempt);plugin.getLogger().log(java.util.logging.Level.WARNING,"Champion attempt could not start at site "+site.id(),failure);}finally{try{if(observed!=null){captureChampionConfiguration(observed);auditAttempt("adventure.camp.started",observed,spawned?"eligible_accepted_unit_entered":"native_spawn_or_configuration_failed",spawned);}}catch(RuntimeException ignored){/* Audit cannot interrupt native lifecycle. */}starting.remove(site.id());}}
private void spawnEnemy(World world,AdventureQuestState.Site site,org.bukkit.entity.EntityType type,Attempt attempt,int index){var location=new Location(world,site.x()+(index%3)-1,site.y(),site.z()+(index/3));switch(type){case HUSK->world.spawn(location,Husk.class,mob->configureEnemy(mob,attempt));case PILLAGER->world.spawn(location,Pillager.class,mob->configureEnemy(mob,attempt));case VINDICATOR->world.spawn(location,Vindicator.class,mob->configureEnemy(mob,attempt));case WITCH->world.spawn(location,Witch.class,mob->configureEnemy(mob,attempt));default->throw new IllegalArgumentException("Unsupported camp entity");}}
private void configureEnemy(Mob mob,Attempt attempt){mob.getPersistentDataContainer().set(typeKey,PersistentDataType.STRING,ENEMY);mob.getPersistentDataContainer().set(siteKey,PersistentDataType.STRING,attempt.site.toString());mob.getPersistentDataContainer().set(catalogKey,PersistentDataType.STRING,attempt.catalog.toString());mob.getPersistentDataContainer().set(attemptKey,PersistentDataType.STRING,attempt.id.toString());var kind=attempt.type==AdventureQuestState.SiteType.CHAMPION?games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CHAMPION:games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CAMP;mob.getPersistentDataContainer().set(encounterKindKey,PersistentDataType.STRING,kind.name());mob.setCanPickupItems(false);mob.setRemoveWhenFarAway(false);mob.setPersistent(true);mob.setFireTicks(0);if(mob instanceof Husk husk){husk.setShouldBurnInDay(false);husk.setCanBreakDoors(false);}if(mob instanceof Raider raider){raider.setPatrolLeader(false);raider.setCanJoinRaid(false);}UUID entityId=mob.getUniqueId();attempt.entities.add(entityId);auditNativeEnemy(attempt,entityId,mob);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void damage(EntityDamageEvent event){if(enemy(event.getEntity())&&encounterOrigin(event.getEntity()).isPresent()){if(!(event instanceof EntityDamageByEntityEvent by)||source(by)==null||eligible(source(by),site(event.getEntity()))==null)event.setCancelled(true);return;}if(event instanceof EntityDamageByEntityEvent by&&managed(by.getDamager())){UUID site=site(by.getDamager());if(!(event.getEntity() instanceof Player player)||site==null||eligible(player,site)==null)event.setCancelled(true);}}
@@ -170,6 +178,7 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
}
if(attempt.entities.isEmpty()){attempt.defeatedEntity=event.getEntity().getUniqueId();
attempt.defeatLocation=trophyLocation(event.getEntity().getLocation());attempt.defeatedAt=clock.instant();
if(attempt.type==AdventureQuestState.SiteType.CHAMPION)captureChampionDefeat(attempt,event);
clear(attempt);}}
private static Map<String,Object> trophyLocation(Location location){
if(location==null||location.getWorld()==null)return Map.of("status","unavailable");
@@ -187,7 +196,8 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
}
private void auditAttempt(String type,Attempt attempt,String reason,boolean succeeded){
try{
if(campEvents==null||attempt.type!=AdventureQuestState.SiteType.CAMP||attempt.auditSite==null)return;
boolean champion=attempt.type==AdventureQuestState.SiteType.CHAMPION;
if(attempt.auditSite==null||(champion?championEvents==null:campEvents==null||attempt.type!=AdventureQuestState.SiteType.CAMP))return;
var entities=attempt.auditEntities.entrySet().stream().map(entry->Map.<String,Object>of("entity_id",entry.getKey().toString(),"entity_type",entry.getValue())).toList();
var units=new ArrayList<Map<String,Object>>();
for(UUID id:attempt.represented.stream().sorted().toList()){
@@ -196,12 +206,53 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
else unit.put("unit_capture_quality","unavailable");units.add(Map.copyOf(unit));
}
var catalog=directory.snapshot().catalog();boolean current=catalog!=null&&catalog.revision().equals(attempt.catalog)&&directory.offerOpen(attempt.site);
campEvents.attemptTransition(type,new games.dmg.fantasystick.events.CampEventBridge.CampAttempt(attempt.id,attempt.catalog,
var facts=new games.dmg.fantasystick.events.CampEventBridge.CampAttempt(attempt.id,attempt.catalog,
attempt.auditSite,attempt.arena,attempt.resetSeconds,attempt.auditRoster.stream().map(Enum::name).toList(),entities,units,
attempt.entities.size(),attempt.defeatedEntity,attempt.defeatLocation,attempt.defeatedAt,current,
type.equals("adventure.camp.reset")||type.equals("adventure.camp.started")?auditNativePresence(attempt):Map.of("status","not_applicable")),reason,succeeded);
type.equals("adventure.camp.reset")||type.equals("adventure.camp.started")?auditNativePresence(attempt):Map.of("status","not_applicable"));
if(champion)championEvents.attemptTransition(switch(type){case "adventure.camp.started"->"adventure.champion.spawned";case "adventure.camp.cleared"->"adventure.champion.defeated";case "adventure.camp.reset"->"adventure.champion.reset";default->throw new IllegalArgumentException("Unknown champion transition");},facts,attempt.auditConfiguration,reason,succeeded);
else campEvents.attemptTransition(type,facts,reason,succeeded);
}catch(RuntimeException ignored){/* Metadata cannot spawn, reset, represent, clear or award gameplay. */}
}
private static void captureChampionConfiguration(Attempt attempt){
var facts=new LinkedHashMap<String,Object>(attempt.auditConfiguration);var champion=attempt.champion;
for(var field:Map.of("health",Attribute.MAX_HEALTH,"damage",Attribute.ATTACK_DAMAGE).entrySet()){
try{var attribute=champion==null?null:champion.getAttribute(field.getValue());double value=attribute==null?Double.NaN:attribute.getBaseValue();
if(Double.isFinite(value)&&value>0){facts.put("configured_"+field.getKey(),value);facts.put(field.getKey()+"_capture_quality","native_base_value_observed");}
else facts.put(field.getKey()+"_capture_quality","unavailable");
}catch(RuntimeException unavailable){facts.put(field.getKey()+"_capture_quality","unavailable");}
}
try{var name=champion==null?null:champion.customName();
if(name!=null){facts.put("champion_name",net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(name));facts.put("champion_name_capture_quality","native_name_observed");}
else facts.put("champion_name_capture_quality","unavailable");
}catch(RuntimeException unavailable){facts.put("champion_name_capture_quality","unavailable");}
attempt.auditConfiguration=Map.copyOf(facts);
}
private void captureChampionDefeat(Attempt attempt,EntityDeathEvent event){
try{
var source=new LinkedHashMap<String,Object>();source.put("capture_quality","captured_native_death");
source.put("damage_action_capture_quality","unavailable_not_in_native_death");
org.bukkit.entity.Entity causing=null,direct=null;
try{var damage=event.getDamageSource();if(damage!=null)causing=damage.getCausingEntity();}catch(RuntimeException unavailable){}
try{var damage=event.getDamageSource();if(damage!=null)direct=damage.getDirectEntity();}catch(RuntimeException unavailable){}
captureSourceEntity(source,"causing",causing);captureSourceEntity(source,"direct",direct);
if(causing instanceof Player player&&campEvents!=null){
var actor=campEvents.actor(player,null);
for(var field:Map.of("request_player_id","killer_player_id","request_character_id","killer_character_id","request_session_id","killer_session_id").entrySet()){
var value=actor.captured().get(field.getKey());if(value!=null)source.put(field.getValue(),value);
}
source.put("killer_context_capture_quality",actor.captured().get("actor_capture_quality"));
var location=actor.captured().get("actor_location");if(location instanceof Map<?,?> coordinates)
for(var field:coordinates.entrySet())source.put("killer_"+field.getKey(),field.getValue());
}else source.put("killer_context_capture_quality","unavailable");
var facts=new LinkedHashMap<String,Object>(attempt.auditConfiguration);facts.put("defeat_source",Map.copyOf(source));attempt.auditConfiguration=Map.copyOf(facts);
}catch(RuntimeException ignored){/* Missing original source metadata remains unavailable, never gameplay authority. */}
}
private static void captureSourceEntity(Map<String,Object> facts,String prefix,org.bukkit.entity.Entity entity){
try{if(entity!=null&&entity.getUniqueId()!=null)facts.put(prefix+"_entity_id",entity.getUniqueId().toString());}catch(RuntimeException unavailable){}
try{if(entity!=null&&entity.getType()!=null)facts.put(prefix+"_entity_type",entity.getType().name());}catch(RuntimeException unavailable){}
facts.put(prefix+"_capture_quality",facts.containsKey(prefix+"_entity_id")?"native_entity_observed":"unavailable");
}
private Map<String,Object> auditNativePresence(Attempt attempt){
try{
var world=plugin.getServer().getWorld(attempt.auditSite.world());if(world==null)return Map.of("status","world_unavailable");
@@ -29,6 +29,8 @@ public final class AdventureQuestDirectory implements AutoCloseable {
games.dmg.fantasystick.events.CampEventBridge.Actor actor){}
private volatile java.util.function.Consumer<QuestExpiry> expiryObserver=ignored->{};
public void attachQuestExpiryEvents(java.util.function.Consumer<QuestExpiry> observer){expiryObserver=Objects.requireNonNull(observer);}
private volatile java.util.function.Consumer<QuestExpiry> championExpiryObserver=ignored->{};
public void attachChampionQuestExpiryEvents(java.util.function.Consumer<QuestExpiry> observer){championExpiryObserver=Objects.requireNonNull(observer);}
public record AcceptResult(Acceptance status,Optional<AdventureQuestState.Quest> quest,Optional<AdventureQuestState.Site> capturedSite) {
public AcceptResult(Acceptance status,Optional<AdventureQuestState.Quest> quest){this(status,quest,Optional.empty());}
}
@@ -55,11 +57,11 @@ public final class AdventureQuestDirectory implements AutoCloseable {
public Optional<AdventureQuestState.Quest> findQuest(UUID id){return snapshot.quests().stream().filter(v->v.id().equals(id)).findFirst();}
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z){return placeSite(type,name,world,x,y,z,null,AdventureQuestState.ParticipationMode.PARTY_AVAILABLE);}
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z,AdventureQuestState.Arena arena){return placeSite(type,name,world,x,y,z,arena,AdventureQuestState.ParticipationMode.PARTY_AVAILABLE);}
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z,AdventureQuestState.Arena arena,AdventureQuestState.ParticipationMode mode){if(type==AdventureQuestState.SiteType.FISHING)throw new IllegalArgumentException("Use complete fishing definition");var value=new AdventureQuestState.Site(UUID.randomUUID(),type,name,world,x,y,z,arena,mode,null,type==AdventureQuestState.SiteType.CAMP?UUID.randomUUID():null);return mutate(state->{var sites=new ArrayList<>(state.sites());sites.add(value);return result(copy(state,List.copyOf(sites),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z,AdventureQuestState.Arena arena,AdventureQuestState.ParticipationMode mode){if(type==AdventureQuestState.SiteType.FISHING)throw new IllegalArgumentException("Use complete fishing definition");var value=new AdventureQuestState.Site(UUID.randomUUID(),type,name,world,x,y,z,arena,mode,null,(type==AdventureQuestState.SiteType.CAMP||type==AdventureQuestState.SiteType.CHAMPION)?UUID.randomUUID():null);return mutate(state->{var sites=new ArrayList<>(state.sites());sites.add(value);return result(copy(state,List.copyOf(sites),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
public CompletableFuture<AdventureQuestState.Site> placeFishingSite(String name,String itemName,UUID world,double x,double y,double z,int radius,java.math.BigDecimal chance,BigInteger required,BigInteger rewardXp,BigInteger rewardCoins,AdventureQuestState.ParticipationMode mode){var fishing=new AdventureQuestState.FishingDefinition(UUID.randomUUID(),itemName,required,chance,rewardXp,rewardCoins,radius);var value=new AdventureQuestState.Site(UUID.randomUUID(),AdventureQuestState.SiteType.FISHING,name,world,x,y,z,AdventureQuestState.Arena.circle(radius),mode,fishing);return mutate(state->{var sites=new ArrayList<>(state.sites());sites.add(value);return result(copy(state,List.copyOf(sites),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
public CompletableFuture<FishingUpdateResult> updateFishing(UUID id,AdventureQuestState.FishingDefinition expected,AdventureQuestState.FishingDefinition requested){var current=findSite(id).orElse(null);if(current==null||current.type()!=AdventureQuestState.SiteType.FISHING)return CompletableFuture.completedFuture(new FishingUpdateResult(FishingUpdateStatus.NOT_FOUND,Optional.empty()));if(!current.fishing().equals(expected))return CompletableFuture.completedFuture(new FishingUpdateResult(FishingUpdateStatus.STALE,Optional.of(current)));return updateFishingSite(id,current,current.name(),requested,current.participationMode());}
public CompletableFuture<FishingUpdateResult> updateFishingSite(UUID id,AdventureQuestState.Site expected,String name,AdventureQuestState.FishingDefinition requested,AdventureQuestState.ParticipationMode mode){Objects.requireNonNull(expected);Objects.requireNonNull(requested);Objects.requireNonNull(mode);return mutate(state->{var found=state.sites().stream().filter(value->value.id().equals(id)&&value.type()==AdventureQuestState.SiteType.FISHING).findFirst();if(found.isEmpty())return result(state,new FishingUpdateResult(FishingUpdateStatus.NOT_FOUND,Optional.empty()));var current=found.orElseThrow();if(!current.equals(expected))return result(state,new FishingUpdateResult(FishingUpdateStatus.STALE,Optional.of(current)));if(!current.fishing().itemId().equals(requested.itemId()))throw new IllegalArgumentException("Fishing item identity cannot change");var changed=new AdventureQuestState.Site(current.id(),current.type(),name,current.world(),current.x(),current.y(),current.z(),AdventureQuestState.Arena.circle(requested.radius()),mode,requested,current.registrationRevision());if(changed.equals(current))return result(state,new FishingUpdateResult(FishingUpdateStatus.UNCHANGED,Optional.of(current)));var sites=state.sites().stream().map(value->value.id().equals(id)?changed:value).toList();return result(copy(state,sites,state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),new FishingUpdateResult(FishingUpdateStatus.APPLIED,Optional.of(changed)));});}
public CompletableFuture<ModeResult> setSiteMode(UUID id,AdventureQuestState.ParticipationMode expected,AdventureQuestState.ParticipationMode requested){Objects.requireNonNull(expected);Objects.requireNonNull(requested);return mutate(state->{var found=state.sites().stream().filter(value->value.id().equals(id)&&value.type()!=AdventureQuestState.SiteType.DUNGEON).findFirst();if(found.isEmpty())return result(state,new ModeResult(ModeStatus.NOT_FOUND,Optional.empty()));var current=found.orElseThrow();if(current.participationMode()!=expected)return result(state,new ModeResult(ModeStatus.STALE,Optional.of(current)));if(current.participationMode()==requested)return result(state,new ModeResult(ModeStatus.UNCHANGED,Optional.of(current)));var changed=new AdventureQuestState.Site(current.id(),current.type(),current.name(),current.world(),current.x(),current.y(),current.z(),current.arena(),requested,current.fishing(),current.type()==AdventureQuestState.SiteType.CAMP?UUID.randomUUID():current.registrationRevision());var sites=state.sites().stream().map(value->value.id().equals(id)?changed:value).toList();return result(copy(state,sites,state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),new ModeResult(ModeStatus.APPLIED,Optional.of(changed)));});}
public CompletableFuture<ModeResult> setSiteMode(UUID id,AdventureQuestState.ParticipationMode expected,AdventureQuestState.ParticipationMode requested){Objects.requireNonNull(expected);Objects.requireNonNull(requested);return mutate(state->{var found=state.sites().stream().filter(value->value.id().equals(id)&&value.type()!=AdventureQuestState.SiteType.DUNGEON).findFirst();if(found.isEmpty())return result(state,new ModeResult(ModeStatus.NOT_FOUND,Optional.empty()));var current=found.orElseThrow();if(current.participationMode()!=expected)return result(state,new ModeResult(ModeStatus.STALE,Optional.of(current)));if(current.participationMode()==requested)return result(state,new ModeResult(ModeStatus.UNCHANGED,Optional.of(current)));var changed=new AdventureQuestState.Site(current.id(),current.type(),current.name(),current.world(),current.x(),current.y(),current.z(),current.arena(),requested,current.fishing(),(current.type()==AdventureQuestState.SiteType.CAMP||current.type()==AdventureQuestState.SiteType.CHAMPION)?UUID.randomUUID():current.registrationRevision());var sites=state.sites().stream().map(value->value.id().equals(id)?changed:value).toList();return result(copy(state,sites,state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),new ModeResult(ModeStatus.APPLIED,Optional.of(changed)));});}
public CompletableFuture<Optional<AdventureQuestState.Site>> removeSite(UUID id){return mutate(state->{var found=state.sites().stream().filter(v->v.id().equals(id)&&v.type()!=AdventureQuestState.SiteType.DUNGEON).findFirst();if(found.isEmpty())return result(state,Optional.empty());var sites=state.sites().stream().filter(v->!v.id().equals(id)).toList();var quests=state.quests().stream().filter(v->!v.site().equals(id)).toList();var rosters=new HashMap<>(state.rosters());rosters.remove(id);return result(copy(state,sites,state.givers(),state.catalog(),quests,state.unitClaims(),state.characterClaims(),rosters),found);});}
public CompletableFuture<Optional<AdventureQuestState.Site>> removeDungeonSite(UUID id){return mutate(state->{var found=state.sites().stream().filter(v->v.id().equals(id)&&v.type()==AdventureQuestState.SiteType.DUNGEON).findFirst();if(found.isEmpty())return result(state,Optional.empty());var sites=state.sites().stream().filter(v->!v.id().equals(id)).toList();return result(copy(state,sites,state.givers(),state.catalog(),state.quests().stream().filter(v->!v.site().equals(id)).toList(),state.unitClaims(),state.characterClaims(),state.rosters()),found);});}
public CompletableFuture<AdventureQuestState.Giver> placeGiver(String name,UUID world,double x,double y,double z,float yaw,float pitch){var value=new AdventureQuestState.Giver(UUID.randomUUID(),name,world,x,y,z,yaw,pitch,UUID.randomUUID());return mutate(state->{var values=new ArrayList<>(state.givers());values.add(value);return result(copy(state,state.sites(),List.copyOf(values),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
@@ -132,14 +134,15 @@ public final class AdventureQuestDirectory implements AutoCloseable {
for(var quest:before.quests()){
if(remaining.contains(quest.id()))continue;
var definition=find(before.sites(),quest.site()).orElse(null);
if(definition==null||definition.type()!=AdventureQuestState.SiteType.CAMP)continue;
if(definition==null||(definition.type()!=AdventureQuestState.SiteType.CAMP&&definition.type()!=AdventureQuestState.SiteType.CHAMPION))continue;
var claim=new AdventureQuestState.UnitClaim(quest.catalog(),quest.site(),quest.unitType(),quest.unit());
if(after.unitClaims().contains(claim)&&!before.unitClaims().contains(claim))continue;
String observed=!clock.instant().isBefore(quest.expiresAt())?"utc_deadline":
find(after.sites(),quest.site()).isEmpty()?"site_removed":result.publication()!=null?
result.publication().reason().equals("admin_full_reroll")?"full_day_reroll":"utc_day_rollover":reason;
var publication=result.publication();
expiryObserver.accept(new QuestExpiry(publication==null?operation:publication.operation(),observed,quest,definition,
var observer=definition.type()==AdventureQuestState.SiteType.CHAMPION?championExpiryObserver:expiryObserver;
observer.accept(new QuestExpiry(publication==null?operation:publication.operation(),observed,quest,definition,
publication==null?null:publication.actor()));
}
}catch(RuntimeException ignored){/* Removed authority is not reversible by audit metadata. */}
@@ -72,9 +72,9 @@ public final class CampEventBridge implements AutoCloseable {
}catch(RuntimeException ignored){/* Cannot undo acknowledged registration. */}
}
private static LinkedHashMap<String,Object> siteData(AdventureQuestState.Site site,AdventureQuestState.Arena arena) {
static LinkedHashMap<String,Object> siteData(AdventureQuestState.Site site,AdventureQuestState.Arena arena) {
var data=new LinkedHashMap<String,Object>();
data.put("site_kind","camp");data.put("site_id",site.id().toString());
data.put("site_kind",site.type().name().toLowerCase(java.util.Locale.ROOT));data.put("site_id",site.id().toString());
data.put("site_name",site.name());data.put("site_display_name",site.displayName());
data.put("revision_capture_quality",site.registrationRevision()==null?"unavailable":"persisted");
if(site.registrationRevision()!=null)data.put("site_revision",site.registrationRevision().toString());
@@ -0,0 +1,160 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.adventure.AdventureQuestState;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Function;
/** Independent champion observations; only immutable audit facts enter the worker/outbox. */
public final class ChampionEventBridge implements AutoCloseable {
private final FeatureEventOutbox outbox;
private final Clock clock=Clock.systemUTC();
public ChampionEventBridge(Function<EventInput,CompletionStage<LocalEventLog.Delivery>> sink,
Path data,Consumer<Throwable> failures){
outbox=new FeatureEventOutbox(data,"champion-event-intents",Set.of("adventure.site.registered","adventure.site.mode_changed","adventure.site.removed",
"adventure.quest.accepted","adventure.quest.abandoned","adventure.quest.expired",
"adventure.champion.spawned","adventure.champion.defeated","adventure.champion.reset","config.read","config.changed"),sink,failures);
}
public void siteRegistered(UUID operation,CampEventBridge.Actor actor,AdventureQuestState.Site site,AdventureQuestState.Arena arena){
try{
if(site.type()!=AdventureQuestState.SiteType.CHAMPION)return;
var data=CampEventBridge.siteData(site,arena);
data.put("mode_before","not_applicable");data.put("mode_after",site.participationMode().value());
data.put("catalog_impact","unchanged_by_registration");data.put("active_quest_impact","unchanged");
accepted("adventure.site.registered",operation,actor,data);
}catch(RuntimeException ignored){/* Acknowledged authority is independent of audit availability. */}
}
public void siteModeChanged(UUID operation,CampEventBridge.Actor actor,AdventureQuestState.Site requestedBefore,
AdventureQuestState.Site acknowledged,AdventureQuestState.Arena arena){
try{
if(acknowledged.type()!=AdventureQuestState.SiteType.CHAMPION)return;
var data=CampEventBridge.siteData(acknowledged,arena);
data.put("mode_before",requestedBefore.participationMode().value());data.put("mode_after",acknowledged.participationMode().value());
if(requestedBefore.registrationRevision()!=null)data.put("requested_site_revision",requestedBefore.registrationRevision().toString());
data.put("application_scope","future_acceptances");data.put("active_quest_impact","captured_authority_unchanged");
data.put("catalog_impact","same_offer_future_acceptances_use_new_mode");
accepted("adventure.site.mode_changed",operation,actor,data);
}catch(RuntimeException ignored){/* Mode publication never depends on audit. */}
}
public void siteRemoved(UUID operation,CampEventBridge.Actor actor,AdventureQuestState.Site removed,
AdventureQuestState.Arena arena,AdventureQuestState.Catalog catalog){
try{
if(removed.type()!=AdventureQuestState.SiteType.CHAMPION)return;
var data=CampEventBridge.siteData(removed,arena);
data.put("mode_before",removed.participationMode().value());data.put("mode_after","not_applicable");
boolean offered=catalog!=null&&catalog.sites().contains(removed.id());data.put("offered_at_acknowledgement",offered);
if(catalog!=null)data.put("catalog_id",catalog.revision().toString());
data.put("catalog_impact",offered?"slot_unavailable_until_reset":"unoffered_registration_removed");
data.put("active_quest_impact","unfinished_site_quests_expired");data.put("earned_reward_impact","retained");
accepted("adventure.site.removed",operation,actor,data);
}catch(RuntimeException ignored){/* Durable removal cannot be undone or duplicated by audit. */}
}
public static boolean ownsSetting(String key){return Set.of(
games.dmg.fantasystick.persistence.AdventureQuestSettings.CHAMPION_HEALTH,games.dmg.fantasystick.persistence.AdventureQuestSettings.CHAMPION_DAMAGE,
games.dmg.fantasystick.persistence.AdventureQuestSettings.CHAMPION_REWARD_XP,games.dmg.fantasystick.persistence.AdventureQuestSettings.CHAMPION_REWARD_COINS).contains(key);}
private static LinkedHashMap<String,Object> settingData(String key,String defaults){
var data=new LinkedHashMap<String,Object>();data.put("key",key);data.put("default",defaults);data.put("feature","champion");
data.put("application_scope",key.equals(games.dmg.fantasystick.persistence.AdventureQuestSettings.CHAMPION_HEALTH)
||key.equals(games.dmg.fantasystick.persistence.AdventureQuestSettings.CHAMPION_DAMAGE)?"future_attempts":"future_acceptances");return data;
}
public void configRead(CampEventBridge.Actor actor,String key,String effective,String defaults){
try{if(!ownsSetting(key))return;var data=settingData(key,defaults);data.put("effective",effective);data.put("outcome","acknowledged_read");
accepted("config.read",UUID.randomUUID(),actor,data);}catch(RuntimeException ignored){/* Reporting cannot depend on audit. */}
}
public void configChanged(CampEventBridge.Actor actor,UUID operation,String key,String before,String after,String defaults){
try{if(!ownsSetting(key)||before.equals(after))return;var data=settingData(key,defaults);data.put("before",before);data.put("after",after);
data.put("effective",after);data.put("outcome","acknowledged_future_publication");accepted("config.changed",operation,actor,data);
}catch(RuntimeException ignored){/* Effective publication cannot be rolled back or duplicated by audit. */}
}
public void attemptTransition(String type,CampEventBridge.CampAttempt attempt,Map<String,Object> configuration,String reason,boolean succeeded){
try{
var site=attempt.site();if(site.type()!=AdventureQuestState.SiteType.CHAMPION)return;
var data=new LinkedHashMap<String,Object>();data.put("actor_kind","system");data.put("site_id",site.id().toString());data.put("site_kind","champion");
data.put("site_name",site.name());data.put("catalog_id",attempt.catalog().toString());data.put("generation_id",attempt.catalog().toString());
data.put("attempt_id",attempt.id().toString());data.put("attempt_capture_quality","native_transient_not_persisted");
data.put("roster_capture_quality","registered_single_vindicator_definition");data.put("site_mode_at_start",site.participationMode().value());
data.put("site_revision_capture_quality",site.registrationRevision()==null?"unavailable":"persisted_definition_at_start");
if(site.registrationRevision()!=null)data.put("site_revision",site.registrationRevision().toString());
data.put("encounter_location",Map.of("world_id",site.world().toString(),"x",site.x(),"y",site.y(),"z",site.z()));
data.put("arena_shape",attempt.arena().shape().name().toLowerCase(java.util.Locale.ROOT));data.put("arena_x_blocks",attempt.arena().xBlocks());data.put("arena_z_blocks",attempt.arena().zBlocks());
data.put("reset_seconds",attempt.resetSeconds());data.put("reason",reason);data.putAll(configuration);
String nativeOutcome=succeeded?"succeeded":"failed";String outcome=succeeded?"native_observed":"native_failed";
if(type.equals("adventure.champion.spawned")){
data.put("native_materialization",attempt.nativePresence());
if(succeeded){var status=attempt.nativePresence().get("status");
nativeOutcome="loaded_observed".equals(status)?
((Number)attempt.nativePresence().get("remaining_native_entities")).intValue()==1&&attempt.entities().size()==1?"succeeded":"not_materialized":
"not_loaded".equals(status)?"not_loaded":"unavailable";
if(!"succeeded".equals(nativeOutcome))outcome="native_"+nativeOutcome;
}
}
if(type.equals("adventure.champion.reset")){
data.put("native_cleanup",attempt.nativePresence());
if(succeeded){var status=attempt.nativePresence().get("status");
nativeOutcome="loaded_observed".equals(status)?((Number)attempt.nativePresence().get("remaining_native_entities")).intValue()==0?"succeeded":"not_confirmed_removed":
"not_loaded".equals(status)?"not_loaded":"unavailable";}
}
data.put("native_outcome",nativeOutcome);data.put("generation_status",attempt.currentGeneration()?"current":"stale");data.put("outcome",outcome);
data.put("roster_total",attempt.roster().size());data.put("native_entity_total",attempt.entities().size());data.put("remaining_entity_count",attempt.liveEntities());
data.put("represented_unit_total",attempt.representedUnits().size());data.put("reward_owner","existing_trophy_receipt_and_beneficiary_claim");
if(attempt.finalEnemy()!=null)data.put("final_enemy_id",attempt.finalEnemy().toString());
if(!attempt.finalLocation().isEmpty())data.put("final_enemy_location",attempt.finalLocation());if(attempt.defeatedAt()!=null)data.put("defeated_at",attempt.defeatedAt().toString());
int parts=Math.max(1,(Math.max(attempt.entities().size(),attempt.representedUnits().size())+19)/20);
for(int part=0;part<parts;part++){
var values=new LinkedHashMap<String,Object>(data);int from=part*20;
values.put("native_entities",slice(attempt.entities(),from));var units=slice(attempt.representedUnits(),from);values.put("represented_units",units);
values.put("represented_quest_ids",units.stream().map(unit->unit.get("quest_id")).toList());
if(parts>1){values.put("part_index",part);values.put("part_count",parts);}
accepted(type,attempt.id(),null,values,parts==1?-1:part);
}
}catch(RuntimeException ignored){/* Audit facts cannot materialize, clear, reset or reward an encounter. */}
}
private static <T>java.util.List<T> slice(java.util.List<T> values,int from){return from>=values.size()?java.util.List.of():values.subList(from,Math.min(from+20,values.size()));}
public void questExpired(games.dmg.fantasystick.adventure.AdventureQuestDirectory.QuestExpiry expiry){
questTransition("adventure.quest.expired",expiry.operation(),expiry.actor(),expiry.quest(),expiry.site(),null,expiry.reason());
}
public void questTransition(String type,UUID operation,CampEventBridge.Actor actor,AdventureQuestState.Quest quest,
AdventureQuestState.Site site,UUID acceptingLeader,String reason){
try{
if(site==null||site.type()!=AdventureQuestState.SiteType.CHAMPION)return;
var data=new LinkedHashMap<String,Object>();data.put("site_kind","champion");data.put("quest_id",quest.id().toString());
data.put("catalog_id",quest.catalog().toString());data.put("site_id",quest.site().toString());data.put("site_name",site.name());
data.put("unit_kind",quest.unitType().name().toLowerCase(java.util.Locale.ROOT));data.put("unit_id",quest.unit().toString());
data.put("captured_mode",quest.participationMode().value());data.put("deadline",quest.expiresAt().toString());
data.put("reason",reason);data.put("reward_xp_pool",quest.rewardXp().toString());data.put("reward_coin_pool",quest.rewardCoins().toString());
data.put("site_location",Map.of("world_id",site.world().toString(),"x",site.x(),"y",site.y(),"z",site.z()));
data.put("site_revision_capture_quality",site.registrationRevision()==null?"unavailable":"persisted_definition_at_transition");
if(site.registrationRevision()!=null)data.put("site_revision",site.registrationRevision().toString());
data.put("accepting_actor_capture_quality",type.equals("adventure.quest.accepted")?"captured_request":"unavailable_not_in_saved_quest");
if(acceptingLeader!=null)data.put("accepting_leader_character_id",acceptingLeader.toString());
if(quest.unitType()==AdventureQuestState.UnitType.PARTY)data.put("party_id",quest.unit().toString());else data.put("solo_character_id",quest.unit().toString());
var roster=quest.snapshot().stream().sorted().map(UUID::toString).toList();int parts=Math.max(1,(roster.size()+19)/20);
for(int part=0;part<parts;part++){
var values=new LinkedHashMap<String,Object>(data);int from=part*20,to=Math.min(from+20,roster.size());values.put("accepted_roster",roster.subList(from,to));
if(parts>1){values.put("roster_total",roster.size());values.put("part_index",part);values.put("part_count",parts);}
accepted(type,operation,actor,values,parts==1?-1:part);
}
}catch(RuntimeException ignored){/* Quest publication never authorizes reward or native replay. */}
}
private void accepted(String type,UUID operation,CampEventBridge.Actor actor,Map<String,Object> input){accepted(type,operation,actor,input,-1);}
private void accepted(String type,UUID operation,CampEventBridge.Actor actor,Map<String,Object> input,int part){
try{
var data=new LinkedHashMap<String,Object>(input);
if(actor!=null)data.putAll(actor.captured());else data.putIfAbsent("actor_kind","unavailable");
data.put("operation_id",operation.toString());data.putIfAbsent("outcome","acknowledged");
String discriminator=type.startsWith("adventure.quest.")?":"+data.get("quest_id"):"";
UUID id=UUID.nameUUIDFromBytes(("fantasystick:champion:"+operation+":"+type+discriminator+(part<0?"":":"+part)).getBytes(StandardCharsets.UTF_8));
outbox.accepted(new EventInput(id,operation,clock.instant(),type,actor==null?null:actor.context(),data));
}catch(RuntimeException ignored){/* Never interrupt or replay gameplay. */}
}
public void replay(){outbox.replay();}
public CompletionStage<Void> drained(){return outbox.drained();}
@Override public void close(){outbox.close();}
}
@@ -391,6 +391,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
}
public void attachFishingEvents(games.dmg.fantasystick.events.FishingEventBridge value){fishingEvents=Objects.requireNonNull(value);}
public void attachCampEvents(games.dmg.fantasystick.events.CampEventBridge value){campEvents=Objects.requireNonNull(value);}
private games.dmg.fantasystick.events.ChampionEventBridge championEvents;
public void attachChampionEvents(games.dmg.fantasystick.events.ChampionEventBridge value){championEvents=Objects.requireNonNull(value);}
public void attachDungeons(games.dmg.fantasystick.dungeon.DungeonCommands commands){if(closed||dungeonCommands!=null)throw new IllegalStateException("Dungeons already attached or command closed");dungeonCommands=Objects.requireNonNull(commands);}
public void attachSheetResourcePack(games.dmg.fantasystick.persistence.SheetResourcePackSettings settings,games.dmg.fantasystick.presentation.SheetResourcePack delivery){if(closed||sheetResourcePackSettings!=null)throw new IllegalStateException("Sheet resource pack already attached or command closed");sheetResourcePackSettings=Objects.requireNonNull(settings);sheetResourcePack=Objects.requireNonNull(delivery);}
public void attachSheetResourcePackEvents(games.dmg.fantasystick.events.SheetResourcePackEventBridge events){
@@ -2111,10 +2113,13 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
var actor=audit==null?null:audit.actor(sender);
var campAudit=games.dmg.fantasystick.events.CampEventBridge.ownsSetting(key)?campEvents:null;
var campActor=campAudit==null?null:campAudit.actor(sender,null);
var championAudit=games.dmg.fantasystick.events.ChampionEventBridge.ownsSetting(key)?championEvents:null;
var championActor=championAudit==null||campEvents==null?null:campEvents.actor(sender,null);
if(args.length==2){
var effective=adventureQuestSettings.value(key);
if(audit!=null)audit.configRead(actor,key,effective,games.dmg.fantasystick.persistence.AdventureQuestSettings.defaultValue(key));
if(campAudit!=null)campAudit.configRead(campActor,key,effective,games.dmg.fantasystick.persistence.AdventureQuestSettings.defaultValue(key));
if(championAudit!=null)championAudit.configRead(championActor,key,effective,games.dmg.fantasystick.persistence.AdventureQuestSettings.defaultValue(key));
sender.sendMessage(key+"="+effective+" (applies to the owning later catalog, acceptance or encounter attempt)");return;
}
var operationId=UUID.randomUUID();
@@ -2129,6 +2134,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
games.dmg.fantasystick.persistence.AdventureQuestSettings.defaultValue(key));
if(applied&&campAudit!=null&&!before.equals(effective))campAudit.configChanged(campActor,operationId,key,before,effective,
games.dmg.fantasystick.persistence.AdventureQuestSettings.defaultValue(key));
if(applied&&championAudit!=null&&!before.equals(effective))championAudit.configChanged(championActor,operationId,key,before,effective,
games.dmg.fantasystick.persistence.AdventureQuestSettings.defaultValue(key));
if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;
if(failure!=null)sender.sendMessage("Adventure quest configuration save failed; effective values are unchanged.");
else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+effective+".");
@@ -25,6 +25,7 @@ final class CampAuditNativeFixture implements AutoCloseable {
final AdventureQuestDirectory directory;
final AdventureQuestController controller;
final ArrayList<LivingEntity> spawned=new ArrayList<>();
Consumer<LivingEntity> nativeSpawnSetup=ignored->{};
Consumer<LivingEntity> nativeSpawnOutcome=ignored->{};
CampAuditNativeFixture(Path data)throws Exception{
f=new FantasyStickLifecycleNativeTest.Fixture(data);f.plugin.setEnabled(true);
@@ -46,7 +47,7 @@ final class CampAuditNativeFixture implements AutoCloseable {
entity instanceof org.bukkit.entity.Pillager?org.bukkit.entity.EntityType.PILLAGER:
entity instanceof org.bukkit.entity.Vindicator?org.bukkit.entity.EntityType.VINDICATOR:org.bukkit.entity.EntityType.WITCH);
doAnswer(removal->{when(entity.isValid()).thenReturn(false);return null;}).when(entity).remove();
Consumer<? super LivingEntity> configure=call.getArgument(2);configure.accept(entity);nativeSpawnOutcome.accept(entity);spawned.add(entity);return entity;
Consumer<? super LivingEntity> configure=call.getArgument(2);nativeSpawnSetup.accept(entity);configure.accept(entity);nativeSpawnOutcome.accept(entity);spawned.add(entity);return entity;
}).when(actor.world).spawn(any(Location.class),org.mockito.ArgumentMatchers.<Class<LivingEntity>>any(),org.mockito.ArgumentMatchers.<Consumer<? super LivingEntity>>any());
directory=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
controller=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");
@@ -0,0 +1,97 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static games.dmg.fantasystick.WitchAuditTestSupport.*;
import games.dmg.fantasystick.adventure.FishingDirectory;
import games.dmg.fantasystick.adventure.FishingState;
import java.nio.file.Path;
import java.util.ArrayList;
import org.bukkit.entity.Vindicator;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ChampionAttemptEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void realSoloChampionNativeConfigurationCancelledOutsiderGenuineRepresentationAndDuplicateDeathUseSoleHeadRedemptionAndClaim()throws Exception{
try(var championFixture=new ChampionAuditNativeFixture(data)){
var fixture=championFixture.base;var f=fixture.f;var actor=fixture.actor;championFixture.champion("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);
var before=CombatExperienceNativeTest.record(f,actor);fixture.click(actor,fixture.open(actor),false);
ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();var site=fixture.directory.findSite(quest.site()).orElseThrow();fixture.pulse();
ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().anyMatch(Vindicator.class::isInstance));var champion=fixture.spawned.stream().filter(Vindicator.class::isInstance).findFirst().orElseThrow();
assertEquals(100,champion.getHealth());assertEquals(100,champion.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH).getBaseValue());assertEquals(8,champion.getAttribute(org.bukkit.attribute.Attribute.ATTACK_DAMAGE).getBaseValue());
assertEquals(net.kyori.adventure.text.Component.text("Daybreak Champion"),champion.customName());assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CHAMPION,fixture.controller.encounterOrigin(champion).orElseThrow().kind());
String attempt=champion.getPersistentDataContainer().get(new org.bukkit.NamespacedKey(f.plugin,"adventure_attempt_id"),org.bukkit.persistence.PersistentDataType.STRING);assertNotNull(attempt);
var outsider=new FantasyStickLifecycleNativeTest.Actor(data);outsider.core.location.setWorld(actor.world);f.players.put(outsider.core.id,outsider.player);
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);
var rejected=new EntityDamageByEntityEvent(outsider.player,champion,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);f.dispatcher.accept(rejected);assertTrue(rejected.isCancelled());
var cancelled=new EntityDamageByEntityEvent(actor.player,champion,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);cancelled.setCancelled(true);f.dispatcher.accept(cancelled);assertTrue(cancelled.isCancelled());
var hit=new EntityDamageByEntityEvent(actor.player,champion,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);when(champion.getLastDamageCause()).thenReturn(hit);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());
var death=new org.bukkit.event.entity.EntityDeathEvent(champion,source,new ArrayList<>(),7);f.dispatcher.accept(death);f.dispatcher.accept(death);assertEquals(0,death.getDroppedExp());assertTrue(death.getDrops().isEmpty());
var trophies=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");ReskillEventsNativeTest.await(f,()->fixture.directory.active().isEmpty()&&trophies.trophies(quest.unit()).size()==1);
assertEquals(before,CombatExperienceNativeTest.record(f,actor));var trophy=trophies.trophies(quest.unit()).getFirst();assertEquals(FishingState.TrophyType.CHAMPION,trophy.type());assertEquals(quest.id(),trophy.quest());assertEquals(java.util.Set.of(quest.unit()),trophy.rewards().keySet());
var menu=fixture.open(actor);fixture.click(actor,menu,CampAttemptEventsNativeTest.slot(menu,"Turn in quest items"),false);menu=actor.inventories.getLast();fixture.click(actor,menu,CampAttemptEventsNativeTest.slot(menu,"Redeem Daybreak Champion Head"),false);
ReskillEventsNativeTest.await(f,()->trophies.trophies(quest.unit()).isEmpty()&&!trophies.unclaimed(quest.unit()).equals(FishingState.Reward.empty()));assertEquals(before,CombatExperienceNativeTest.record(f,actor));
menu=fixture.open(actor);fixture.click(actor,menu,CampAttemptEventsNativeTest.slot(menu,"Turn in quest items"),false);menu=actor.inventories.getLast();fixture.click(actor,menu,CampAttemptEventsNativeTest.slot(menu,"Claim earned quest rewards"),false);
ReskillEventsNativeTest.await(f,()->trophies.unclaimed(quest.unit()).equals(FishingState.Reward.empty()));var paid=CombatExperienceNativeTest.record(f,actor);assertEquals(before.experience().add(quest.rewardXp()),paid.experience());assertEquals(before.coins().add(quest.rewardCoins()),paid.coins());
ReskillEventsNativeTest.await(f,()->{
try{return rows("adventure.trophy_earned").size()==1&&rows("adventure.trophy_redeemed").size()==1&&rows("adventure.reward_claimed").size()==1
&&rows("adventure.champion.spawned").size()==1&&rows("adventure.champion.defeated").size()==1;}catch(Exception failure){throw new AssertionError(failure);}
});
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");var starts=rows("adventure.champion.spawned");var clears=rows("adventure.champion.defeated");
assertEquals(1,starts.size(),"Actual configured loaded-valid champion must export exactly one independent transient start");assertEquals(1,clears.size());
var start=starts.getFirst().getAsJsonObject("data");var clear=clears.getFirst().getAsJsonObject("data");assertEquals(attempt,start.get("attempt_id").getAsString());assertEquals(attempt,clear.get("attempt_id").getAsString());
assertEquals(site.registrationRevision().toString(),start.get("site_revision").getAsString());assertEquals("native_transient_not_persisted",start.get("attempt_capture_quality").getAsString());assertEquals("succeeded",start.get("native_outcome").getAsString());
assertEquals(100,start.get("configured_health").getAsDouble());assertEquals(8,start.get("configured_damage").getAsDouble());assertEquals(champion.getUniqueId().toString(),clear.get("final_enemy_id").getAsString());
assertEquals("Daybreak Champion",start.get("champion_name").getAsString());var nativeSource=clear.getAsJsonObject("defeat_source");
assertEquals(actor.core.id.toString(),nativeSource.get("causing_entity_id").getAsString());assertEquals(actor.core.id.toString(),nativeSource.get("direct_entity_id").getAsString());
assertEquals(actor.core.id.toString(),nativeSource.get("killer_player_id").getAsString());assertEquals(before.id().toString(),nativeSource.get("killer_character_id").getAsString());
assertEquals("captured_native_death",nativeSource.get("capture_quality").getAsString());
assertTrue(rows("adventure.champion.started").isEmpty());assertTrue(rows("adventure.champion.cleared").isEmpty());
assertEquals(1,clear.get("represented_unit_total").getAsInt());assertEquals(quest.id().toString(),clear.getAsJsonArray("represented_quest_ids").get(0).getAsString());assertEquals(0,clear.get("remaining_entity_count").getAsInt());
assertEquals("existing_trophy_receipt_and_beneficiary_claim",clear.get("reward_owner").getAsString());assertTrue(rows("adventure.champion.reset").isEmpty());
}
}
@Test void actualChunkUnloadAndDisableResetDistinctTransientChampionsThenReplayAuditWithoutRestoringEncounterAuthority()throws Exception{
try(var championFixture=new ChampionAuditNativeFixture(data)){
var fixture=championFixture.base;var f=fixture.f;var actor=fixture.actor;championFixture.champion("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();var before=CombatExperienceNativeTest.record(f,actor);fixture.pulse();
ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(Vindicator.class::isInstance).count()==1);var first=fixture.spawned.stream().filter(Vindicator.class::isInstance).findFirst().orElseThrow();
var key=new org.bukkit.NamespacedKey(f.plugin,"adventure_attempt_id");String original=first.getPersistentDataContainer().get(key,org.bukkit.persistence.PersistentDataType.STRING);
f.dispatcher.accept(new org.bukkit.event.world.ChunkUnloadEvent(actor.world.getChunkAt(0,-1),false));assertFalse(first.isValid());assertFalse(fixture.controller.hasActiveEncounter());assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());
fixture.pulse();ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(Vindicator.class::isInstance).count()==2);var second=fixture.spawned.stream().filter(Vindicator.class::isInstance).skip(1).findFirst().orElseThrow();String successor=second.getPersistentDataContainer().get(key,org.bukkit.persistence.PersistentDataType.STRING);assertNotEquals(original,successor);assertEquals(before,CombatExperienceNativeTest.record(f,actor));
ReskillEventsNativeTest.await(f,()->{try{return rows("adventure.champion.spawned").size()==2&&rows("adventure.champion.reset").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");
// Existing immutable shutdown intent is audit-only, never persisted native authority.
f.plugin.setEnabled(true);ReskillEventsNativeTest.await(f,()->{try{return rows("adventure.champion.reset").size()==2;}catch(Exception failure){throw new AssertionError(failure);}});
assertFalse(((games.dmg.fantasystick.adventure.AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController")).hasActiveEncounter());
assertEquals(before,CombatExperienceNativeTest.record(f,actor));
assertEquals(quest,((games.dmg.fantasystick.adventure.AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests")).findQuest(quest.id()).orElseThrow());
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");var resets=rows("adventure.champion.reset");
assertEquals(2,resets.size(),"Actual chunk unload and disable must record their distinct transient champion resets, including immutable shutdown replay");
assertEquals(java.util.Set.of(original,successor),resets.stream().map(row->row.getAsJsonObject("data").get("attempt_id").getAsString()).collect(java.util.stream.Collectors.toSet()));
assertEquals(java.util.Set.of("chunk_unload","plugin_disable"),resets.stream().map(row->row.getAsJsonObject("data").get("reason").getAsString()).collect(java.util.stream.Collectors.toSet()));
for(var row:resets){var values=row.getAsJsonObject("data");assertEquals("succeeded",values.get("native_outcome").getAsString());assertEquals(0,values.get("remaining_entity_count").getAsInt());assertEquals(quest.catalog().toString(),values.get("catalog_id").getAsString());}
assertTrue(rows("adventure.champion.defeated").isEmpty());assertTrue(rows("adventure.champion.cleared").isEmpty());assertTrue(rows("adventure.trophy_earned").isEmpty());
}
}
@Test void returnedSpawnWithCancelledNativeAdditionCannotClaimSuccessfulChampionMaterializationClearOrTrophy()throws Exception{
try(var championFixture=new ChampionAuditNativeFixture(data)){
var fixture=championFixture.base;var f=fixture.f;var actor=fixture.actor;championFixture.champion("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();var before=CombatExperienceNativeTest.record(f,actor);
fixture.nativeSpawnOutcome=entity->{if(entity instanceof Vindicator)when(entity.isValid()).thenReturn(false);};fixture.pulse();ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().anyMatch(Vindicator.class::isInstance));
assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
ReskillEventsNativeTest.await(f,()->{try{return rows("adventure.champion.spawned").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");
var starts=rows("adventure.champion.spawned");assertEquals(1,starts.size(),"A returned cancelled native spawn still needs one truthful observed champion attempt, not silence or invented success");
var values=starts.getFirst().getAsJsonObject("data");assertEquals("not_materialized",values.get("native_outcome").getAsString());assertEquals("native_not_materialized",values.get("outcome").getAsString());assertEquals(0,values.getAsJsonObject("native_materialization").get("remaining_native_entities").getAsInt());
assertTrue(rows("adventure.champion.defeated").isEmpty());assertTrue(rows("adventure.champion.cleared").isEmpty());assertTrue(rows("adventure.trophy_earned").isEmpty());
}
}
private java.util.List<com.google.gson.JsonObject> rows(String type)throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)).toList();}
}
@@ -0,0 +1,39 @@
package games.dmg.fantasystick;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.util.concurrent.atomic.AtomicReference;
import org.bukkit.attribute.Attribute;
import org.bukkit.attribute.AttributeInstance;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Vindicator;
/** Existing supported-interface fixture, extended only with champion health/attribute/name ports. */
final class ChampionAuditNativeFixture implements AutoCloseable {
final CampAuditNativeFixture base;
ChampionAuditNativeFixture(Path data)throws Exception{
base=new CampAuditNativeFixture(data);base.nativeSpawnSetup=ChampionAuditNativeFixture::championPorts;
playerPorts(base.actor);
}
static void playerPorts(FantasyStickLifecycleNativeTest.Actor actor){doReturn(org.bukkit.entity.EntityType.PLAYER).when(actor.player).getType();}
private static void championPorts(LivingEntity entity){
if(!(entity instanceof Vindicator))return;
var health=new AtomicReference<Double>(20.0);var name=new AtomicReference<net.kyori.adventure.text.Component>();
when(entity.getHealth()).thenAnswer(call->health.get());doAnswer(call->{health.set(call.getArgument(0));return null;}).when(entity).setHealth(org.mockito.ArgumentMatchers.anyDouble());
var maximum=attribute(20);var damage=attribute(5);
when(entity.getAttribute(Attribute.MAX_HEALTH)).thenReturn(maximum);when(entity.getAttribute(Attribute.ATTACK_DAMAGE)).thenReturn(damage);
when(entity.customName()).thenAnswer(call->name.get());doAnswer(call->{name.set(call.getArgument(0));return null;}).when(entity).customName(org.mockito.ArgumentMatchers.any(net.kyori.adventure.text.Component.class));
}
private static AttributeInstance attribute(double initial){
var value=new AtomicReference<Double>(initial);var attribute=mock(AttributeInstance.class);
when(attribute.getValue()).thenAnswer(call->value.get());when(attribute.getBaseValue()).thenAnswer(call->value.get());
doAnswer(call->{value.set(call.getArgument(0));return null;}).when(attribute).setBaseValue(org.mockito.ArgumentMatchers.anyDouble());return attribute;
}
void champion(String mode)throws Exception{
var f=base.f;long count=base.actor.messages.stream().filter(value->value.startsWith("Placed champion")).count();
f.adminCommand.execute(base.actor.player,"fstickadmin",new String[]{"adventure-site","place","champion","--mode",mode,"Same Champion"});
ReskillEventsNativeTest.await(f,()->base.actor.messages.stream().filter(value->value.startsWith("Placed champion")).count()==count+1);
}
@Override public void close()throws java.io.IOException{base.close();}
}
@@ -0,0 +1,60 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static games.dmg.fantasystick.WitchAuditTestSupport.*;
import games.dmg.fantasystick.events.ChampionEventBridge;
import games.dmg.fantasystick.events.LocalEventLog;
import games.dmg.fantasystick.persistence.AdventureQuestSettings;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.channels.FileChannel;
import java.nio.file.StandardOpenOption;
import java.util.List;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ChampionAuditSafetyReplayNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void rejectedRegisteredCreationQuestNativeAndConfigurationRecordsReplayEveryOriginalPropertyOfflineWithoutGameplayAccess()throws Exception{
try(var championFixture=new ChampionAuditNativeFixture(data)){
var fixture=championFixture.base;var f=fixture.f;var actor=fixture.actor;f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);
((LocalEventLog)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"eventLog")).close();
championFixture.champion("individual-only");fixture.giver();fixture.resetCatalog();fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();fixture.pulse();
ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().anyMatch(org.bukkit.entity.Vindicator.class::isInstance));var champion=fixture.spawned.stream().filter(org.bukkit.entity.Vindicator.class::isInstance).findFirst().orElseThrow();
String key=AdventureQuestSettings.CHAMPION_HEALTH;f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",key});f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",key,"200"});f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",key,"225"});actor.core.location.setX(19);
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Saved "+key+"=225")));assertEquals(100,champion.getHealth());
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);
var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,champion,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);when(champion.getLastDamageCause()).thenReturn(hit);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());
var death=new org.bukkit.event.entity.EntityDeathEvent(champion,source,new java.util.ArrayList<>(),0);f.dispatcher.accept(death);f.dispatcher.accept(death);
var trophies=(games.dmg.fantasystick.adventure.FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");ReskillEventsNativeTest.await(f,()->fixture.directory.active().isEmpty()&&trophies.trophies(quest.unit()).size()==1);var trophy=trophies.trophies(quest.unit()).getFirst();assertEquals(before,CombatExperienceNativeTest.record(f,actor));
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");drain(f,"configurationDrain");var original=intents(data.resolve("champion-event-intents"));assertEquals(7,original.size());assertTrue(championRows().isEmpty());
var config=Files.readAllBytes(data.resolve("config.yml"));var adventure=Files.readAllBytes(data.resolve("adventure-quests.yml"));var inventory=actor.core.snapshot();var messages=List.copyOf(actor.messages);int spawnCount=fixture.spawned.size();
f.players.clear();clearInvocations(actor.player);f.plugin.setEnabled(true);ReskillEventsNativeTest.await(f,()->{try{return championRows().size()==7;}catch(Exception failure){throw new AssertionError(failure);}});
assertArrayEquals(config,Files.readAllBytes(data.resolve("config.yml")));assertArrayEquals(adventure,Files.readAllBytes(data.resolve("adventure-quests.yml")));assertArrayEquals(inventory,actor.core.snapshot());assertEquals(messages,actor.messages);assertEquals(spawnCount,fixture.spawned.size());verifyNoInteractions(actor.player);
var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(before,storage.find(before.id()).get().orElseThrow());
var reloaded=(games.dmg.fantasystick.adventure.FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");assertEquals(List.of(trophy),reloaded.trophies(quest.unit()));assertFalse(((games.dmg.fantasystick.adventure.AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController")).hasActiveEncounter());
var rows=championRows();for(var intent:original){for(var property:intent.stringPropertyNames())if(intent.getProperty(property).equals("l"))intent.setProperty(property,"j[]");assertOriginal(intent,rows.stream().filter(row->row.get("id").getAsString().equals(intent.getProperty("id"))).findFirst().orElseThrow());}
var changes=rows.stream().filter(row->row.get("type").getAsString().endsWith("config.changed")).map(row->row.getAsJsonObject("data")).toList();assertEquals(java.util.Map.of("100","200","200","225"),changes.stream().collect(java.util.stream.Collectors.toMap(value->value.get("before").getAsString(),value->value.get("after").getAsString())));
var bridge=(ChampionEventBridge)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"championAuditEvents");bridge.replay();bridge.replay();f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");assertEquals(7,championRows().size());assertEquals(messages,actor.messages);verifyNoInteractions(actor.player);
}
}
@Test void deniedInvalidLockedClosedAndUnchangedChampionSettingsCannotPublishPositiveHistory()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();var denied=new EquipmentAdminCommandNativeTest.Sender();denied.allowed=false;
var settings=(AdventureQuestSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestSettings");String key=AdventureQuestSettings.CHAMPION_DAMAGE;
f.adminCommand.execute(denied.port,"fstickadmin",new String[]{"config",key});f.adminCommand.execute(denied.port,"fstickadmin",new String[]{"config",key,"12"});
for(var invalid:List.of("0","2049","NaN","1.5"))f.adminCommand.execute(sender.port,"fstickadmin",new String[]{"config",key,invalid});f.adminCommand.execute(sender.port,"fstickadmin",new String[]{"config",key,"12","extra"});assertEquals("8",settings.value(key));
f.adminCommand.execute(sender.port,"fstickadmin",new String[]{"config",key,"8"});ReskillEventsNativeTest.await(f,()->sender.messages.stream().anyMatch(value->value.startsWith("Saved "+key+"=8")));var bytes=Files.readAllBytes(data.resolve("config.yml"));
try(var channel=FileChannel.open(data.resolve(".config.lock"),StandardOpenOption.CREATE,StandardOpenOption.WRITE);var lock=channel.lock()){
assertTrue(lock.isValid());f.adminCommand.execute(sender.port,"fstickadmin",new String[]{"config",key,"12"});ReskillEventsNativeTest.await(f,()->sender.messages.contains("Adventure quest configuration save failed; effective values are unchanged."));assertEquals("8",settings.value(key));assertArrayEquals(bytes,Files.readAllBytes(data.resolve("config.yml")));
}
settings.close();sender.messages.clear();f.adminCommand.execute(sender.port,"fstickadmin",new String[]{"config",key,"16"});ReskillEventsNativeTest.await(f,()->sender.messages.contains("Adventure quest configuration save failed; effective values are unchanged."));assertEquals("8",settings.value(key));assertArrayEquals(bytes,Files.readAllBytes(data.resolve("config.yml")));
f.adminCommand.execute(sender.port,"fstickadmin",new String[]{"config",key});ReskillEventsNativeTest.await(f,()->{try{return championRows().size()==1;}catch(Exception failure){throw new AssertionError(failure);}});f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");assertEquals(1,championRows().size());assertTrue(championRows().getFirst().get("type").getAsString().endsWith("config.read"));
}
}
private List<com.google.gson.JsonObject> championRows()throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->{var values=row.getAsJsonObject("data");return values.has("site_kind")&&values.get("site_kind").getAsString().equals("champion")||values.has("key")&&ChampionEventBridge.ownsSetting(values.get("key").getAsString());}).toList();}
}
@@ -0,0 +1,70 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static games.dmg.fantasystick.WitchAuditTestSupport.*;
import games.dmg.fantasystick.persistence.AdventureQuestSettings;
import java.nio.file.Path;
import java.util.List;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ChampionConfigurationEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
private record Setting(String key,String before,String after,String scope){}
private static final List<Setting> SETTINGS=List.of(
new Setting(AdventureQuestSettings.CHAMPION_HEALTH,"100","200","future_attempts"),
new Setting(AdventureQuestSettings.CHAMPION_DAMAGE,"8","12","future_attempts"),
new Setting(AdventureQuestSettings.CHAMPION_REWARD_XP,"150","225","future_acceptances"),
new Setting(AdventureQuestSettings.CHAMPION_REWARD_COINS,"40","60","future_acceptances"));
@Test void allFourRegisteredChampionReadsAndPublicationsKeepOriginalRequestContextAndCurrentQuestDefinitionAndNativeStats()throws Exception{
try(var championFixture=new ChampionAuditNativeFixture(data)){
var fixture=championFixture.base;var f=fixture.f;var actor=fixture.actor;championFixture.champion("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);
var quest=fixture.directory.active().getFirst();var catalog=fixture.directory.snapshot().catalog();var site=fixture.directory.findSite(quest.site()).orElseThrow();
var character=CombatExperienceNativeTest.record(f,actor);fixture.pulse();ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().anyMatch(org.bukkit.entity.Vindicator.class::isInstance));
var nativeChampion=fixture.spawned.stream().filter(org.bukkit.entity.Vindicator.class::isInstance).findFirst().orElseThrow();
var settings=(AdventureQuestSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestSettings");
String session=((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(actor.core.id).orElseThrow().session().toString();
for(var setting:SETTINGS){
actor.core.location.setX(12);f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",setting.key()});
assertTrue(actor.messages.stream().anyMatch(value->value.startsWith(setting.key()+"="+setting.before()+" ")));
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",setting.key(),setting.after()});actor.core.location.setX(77);
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Saved "+setting.key()+"="+setting.after()+" for later")));
assertEquals(setting.after(),settings.value(setting.key()));actor.core.location.setX(12);f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",setting.key()});
// These refused/unchanged paths must not become acknowledged configuration changes.
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",setting.key(),setting.after()});
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",setting.key(),"-1"});
actor.allowed=false;f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",setting.key()});f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",setting.key(),setting.before()});actor.allowed=true;
}
assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());assertEquals(catalog,fixture.directory.snapshot().catalog());assertEquals(site,fixture.directory.findSite(quest.site()).orElseThrow());assertEquals(character,CombatExperienceNativeTest.record(f,actor));
assertEquals(100,nativeChampion.getHealth());assertEquals(100,nativeChampion.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH).getBaseValue());assertEquals(8,nativeChampion.getAttribute(org.bukkit.attribute.Attribute.ATTACK_DAMAGE).getBaseValue());
ReskillEventsNativeTest.await(f,()->{try{return ownedRows().size()==12;}catch(Exception failure){throw new AssertionError(failure);}});
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");var rows=ownedRows();
assertEquals(12,rows.size(),"All four actual registered champion read/change/read histories must be owned and exported, with no refused or unchanged duplicates");
for(var setting:SETTINGS){var owned=rows.stream().filter(row->row.getAsJsonObject("data").get("key").getAsString().equals(setting.key())).toList();
var reads=owned.stream().filter(row->row.get("type").getAsString().endsWith("config.read")).toList();assertEquals(2,reads.size());assertEquals(java.util.Set.of(setting.before(),setting.after()),reads.stream().map(row->row.getAsJsonObject("data").get("effective").getAsString()).collect(java.util.stream.Collectors.toSet()));
var changes=owned.stream().filter(row->row.get("type").getAsString().endsWith("config.changed")).toList();assertEquals(1,changes.size());var values=changes.getFirst().getAsJsonObject("data");assertEquals(setting.before(),values.get("before").getAsString());assertEquals(setting.after(),values.get("after").getAsString());
for(var row:owned){var valuesAll=row.getAsJsonObject("data");assertEquals(setting.before(),valuesAll.get("default").getAsString());assertEquals(setting.scope(),valuesAll.get("application_scope").getAsString());assertEquals("champion",valuesAll.get("feature").getAsString());
assertEquals(12,valuesAll.getAsJsonObject("location").get("x").getAsDouble());assertEquals(actor.core.id.toString(),row.get("playerid").getAsString());assertEquals(character.id().toString(),row.get("characterid").getAsString());assertEquals(session,valuesAll.get("session_id").getAsString());}
}
}
}
@Test void queuedChampionSettingPublicationsCaptureAcknowledgedBeforeRatherThanRequestSnapshotAndFailedSaveHasNoHistory()throws Exception{
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);var settings=(AdventureQuestSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestSettings");
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",AdventureQuestSettings.CHAMPION_HEALTH,"200"});f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",AdventureQuestSettings.CHAMPION_HEALTH,"225"});
ReskillEventsNativeTest.await(f,()->settings.value(AdventureQuestSettings.CHAMPION_HEALTH).equals("225"));
var file=data.resolve("config.yml");var bytes=java.nio.file.Files.readAllBytes(file);java.nio.file.Files.delete(file);java.nio.file.Files.createDirectory(file);
int messages=actor.messages.size();f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",AdventureQuestSettings.CHAMPION_HEALTH,"300"});ReskillEventsNativeTest.await(f,()->actor.messages.subList(messages,actor.messages.size()).stream().anyMatch(value->value.contains("save failed")));
assertEquals("225",settings.value(AdventureQuestSettings.CHAMPION_HEALTH));assertEquals(before,CombatExperienceNativeTest.record(f,actor));java.nio.file.Files.delete(file);java.nio.file.Files.write(file,bytes);
ReskillEventsNativeTest.await(f,()->{try{return ownedRows().stream().filter(row->row.get("type").getAsString().endsWith("config.changed")).count()==2;}catch(Exception failure){throw new AssertionError(failure);}});
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");var changes=ownedRows().stream().filter(row->row.get("type").getAsString().endsWith("config.changed")).toList();
assertEquals(2,changes.size(),"Exactly two real queued champion publications, never the failed third save, own history");
assertEquals(java.util.Map.of("100","200","200","225"),changes.stream().map(row->row.getAsJsonObject("data")).collect(java.util.stream.Collectors.toMap(value->value.get("before").getAsString(),value->value.get("after").getAsString())));
}
}
private java.util.List<com.google.gson.JsonObject> ownedRows()throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->row.getAsJsonObject("data").has("key")&&SETTINGS.stream().anyMatch(setting->setting.key().equals(row.getAsJsonObject("data").get("key").getAsString()))).toList();}
}
@@ -0,0 +1,71 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static games.dmg.fantasystick.WitchAuditTestSupport.*;
import java.nio.file.Path;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
class ChampionFaultEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@ParameterizedTest @ValueSource(booleans={false,true})
void closedOrUnwritableChampionAuditCannotPreventActualQuestNativeClearOrExistingHeadPersistence(boolean closed)throws Exception{
if(!closed)java.nio.file.Files.writeString(data.resolve("champion-event-intents"),"not a directory");
try(var championFixture=new ChampionAuditNativeFixture(data)){
var fixture=championFixture.base;var f=fixture.f;var actor=fixture.actor;
if(closed)((games.dmg.fantasystick.events.ChampionEventBridge)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"championAuditEvents")).close();
championFixture.champion("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();fixture.pulse();
ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().anyMatch(org.bukkit.entity.Vindicator.class::isInstance));var champion=fixture.spawned.stream().filter(org.bukkit.entity.Vindicator.class::isInstance).findFirst().orElseThrow();
var source=source(actor);var hit=hit(actor,champion,source);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());var death=new org.bukkit.event.entity.EntityDeathEvent(champion,source,new java.util.ArrayList<>(),0);f.dispatcher.accept(death);f.dispatcher.accept(death);
var trophies=(games.dmg.fantasystick.adventure.FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");ReskillEventsNativeTest.await(f,()->fixture.directory.active().isEmpty()&&trophies.trophies(quest.unit()).size()==1);assertEquals(quest.id(),trophies.trophies(quest.unit()).getFirst().quest());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
ReskillEventsNativeTest.await(f,()->{try{return rows("adventure.trophy_earned").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");
assertTrue(rows("adventure.champion.spawned").isEmpty());assertTrue(rows("adventure.champion.defeated").isEmpty());assertEquals(1,rows("adventure.trophy_earned").size());
}
}
@Test void failedTrophyPersistenceDoesNotCompleteOrPayOriginalQuestDespiteGenuineNativeDefeatRecord()throws Exception{
try(var championFixture=new ChampionAuditNativeFixture(data)){
var fixture=championFixture.base;var f=fixture.f;var actor=fixture.actor;championFixture.champion("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();fixture.pulse();ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().anyMatch(org.bukkit.entity.Vindicator.class::isInstance));var champion=fixture.spawned.stream().filter(org.bukkit.entity.Vindicator.class::isInstance).findFirst().orElseThrow();
var trophies=(games.dmg.fantasystick.adventure.FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");trophies.close();var source=source(actor);f.dispatcher.accept(hit(actor,champion,source));f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(champion,source,new java.util.ArrayList<>(),0));
ReskillEventsNativeTest.await(f,()->{try{return rows("adventure.champion.defeated").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());assertTrue(trophies.trophies(quest.unit()).isEmpty());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
assertTrue(rows("adventure.trophy_earned").isEmpty());assertTrue(rows("adventure.reward_claimed").isEmpty());f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");
}
}
@Test void absenceResetsThenUnrepresentedNativeDefeatLeavesQuestAvailableAndMissingOriginalNameUnavailable()throws Exception{
var config=new org.bukkit.configuration.file.YamlConfiguration();config.set("adventure-site-reset-seconds",0);config.save(data.resolve("config.yml").toFile());
try(var championFixture=new ChampionAuditNativeFixture(data)){
var fixture=championFixture.base;var f=fixture.f;var actor=fixture.actor;championFixture.champion("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();
fixture.nativeSpawnOutcome=entity->{if(entity instanceof org.bukkit.entity.Vindicator)doThrow(new IllegalStateException("original native name unavailable")).when(entity).customName();};fixture.pulse();ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(org.bukkit.entity.Vindicator.class::isInstance).count()==1);var first=fixture.spawned.stream().filter(org.bukkit.entity.Vindicator.class::isInstance).findFirst().orElseThrow();
actor.core.location.setX(77);fixture.pulse();fixture.pulse();assertFalse(first.isValid());assertFalse(fixture.controller.hasActiveEncounter());assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());
actor.core.location.setX(12);fixture.pulse();ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(org.bukkit.entity.Vindicator.class::isInstance).count()==2);var second=fixture.spawned.stream().filter(org.bukkit.entity.Vindicator.class::isInstance).skip(1).findFirst().orElseThrow();
var nativeSource=mock(org.bukkit.damage.DamageSource.class);f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(second,nativeSource,new java.util.ArrayList<>(),0));
ReskillEventsNativeTest.await(f,()->{try{return rows("adventure.champion.spawned").size()==2&&rows("adventure.champion.reset").size()==1&&rows("adventure.champion.defeated").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});
assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());assertEquals(before,CombatExperienceNativeTest.record(f,actor));assertTrue(rows("adventure.trophy_earned").isEmpty());
var reset=rows("adventure.champion.reset").getFirst().getAsJsonObject("data");assertEquals("no_eligible_accepted_character_remained",reset.get("reason").getAsString());var defeat=rows("adventure.champion.defeated").getFirst().getAsJsonObject("data");assertEquals(0,defeat.get("represented_unit_total").getAsInt());assertEquals("unavailable",defeat.getAsJsonObject("defeat_source").get("killer_context_capture_quality").getAsString());
for(var row:rows("adventure.champion.spawned")){var values=row.getAsJsonObject("data");assertFalse(values.has("champion_name"));assertEquals("unavailable",values.get("champion_name_capture_quality").getAsString());assertEquals(100,values.get("configured_health").getAsDouble());}
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");
}
}
@Test void failedNativeSpawnAndFailedSavedAcceptanceCannotInventChampionOrQuestSuccess()throws Exception{
try(var championFixture=new ChampionAuditNativeFixture(data)){
var fixture=championFixture.base;var f=fixture.f;var actor=fixture.actor;championFixture.champion("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);
var file=data.resolve("adventure-quests.yml");var bytes=java.nio.file.Files.readAllBytes(file);java.nio.file.Files.delete(file);java.nio.file.Files.createDirectory(file);
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->actor.messages.contains("Quest acceptance failed; retained state is unchanged."));assertTrue(fixture.directory.active().isEmpty());assertTrue(rows("adventure.quest.accepted").isEmpty());java.nio.file.Files.delete(file);java.nio.file.Files.write(file,bytes);
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();
fixture.nativeSpawnSetup=entity->{if(entity instanceof org.bukkit.entity.Vindicator)throw new IllegalStateException("native spawn rejected before configuration");};fixture.pulse();
ReskillEventsNativeTest.await(f,()->{try{return rows("adventure.champion.spawned").size()==1&&rows("adventure.champion.reset").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});
assertFalse(fixture.controller.hasActiveEncounter());assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());assertEquals(before,CombatExperienceNativeTest.record(f,actor));var value=rows("adventure.champion.spawned").getFirst().getAsJsonObject("data");
assertEquals("failed",value.get("native_outcome").getAsString());assertEquals("native_failed",value.get("outcome").getAsString());assertEquals("native_spawn_or_configuration_failed",value.get("reason").getAsString());assertFalse(value.has("configured_health"));assertFalse(value.has("champion_name"));
assertEquals(1,rows("adventure.quest.accepted").size());assertTrue(rows("adventure.champion.defeated").isEmpty());assertTrue(rows("adventure.trophy_earned").isEmpty());f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");
}
}
private static org.bukkit.damage.DamageSource source(FantasyStickLifecycleNativeTest.Actor actor){var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);return source;}
private static org.bukkit.event.entity.EntityDamageByEntityEvent hit(FantasyStickLifecycleNativeTest.Actor actor,org.bukkit.entity.LivingEntity target,org.bukkit.damage.DamageSource source){var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,target,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);when(target.getLastDamageCause()).thenReturn(hit);return hit;}
private java.util.List<com.google.gson.JsonObject> rows(String type)throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)).toList();}
}
@@ -0,0 +1,50 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static games.dmg.fantasystick.CampAttemptEventsNativeTest.slot;
import games.dmg.fantasystick.adventure.AdventureQuestState;
import games.dmg.fantasystick.adventure.FishingState;
import java.nio.file.Path;
import java.util.Set;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ChampionPartyEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void mutedSharedChampionClearPreservesUnrepresentedCancelledSoloAndAddedMemberThenRedemptionPaysOnlyOfflineCarriersFrozenMemberShare()throws Exception{
try(var championFixture=new ChampionAuditNativeFixture(data)){
var fixture=championFixture.base;var f=fixture.f;var leader=fixture.actor;f.activate(leader);
var member=new FantasyStickLifecycleNativeTest.Actor(data);member.core.location.setWorld(leader.world);f.players.put(member.core.id,member.player);f.activate(member);ChampionAuditNativeFixture.playerPorts(member);
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()));
championFixture.champion("party-available");fixture.giver();fixture.resetCatalog();fixture.click(leader,fixture.open(leader),true);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();
var solo=new FantasyStickLifecycleNativeTest.Actor(data);solo.core.location.setWorld(leader.world);f.players.put(solo.core.id,solo.player);f.activate(solo);fixture.click(solo,fixture.open(solo),false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==2);
var soloQuest=fixture.directory.active().stream().filter(value->value.unitType()==AdventureQuestState.UnitType.SOLO).findFirst().orElseThrow();
var added=new FantasyStickLifecycleNativeTest.Actor(data);added.core.location.setWorld(leader.world);f.players.put(added.core.id,added.player);f.activate(added);assertTrue(parties.accept(added.core.id,parties.invite(leader.core.id,party.id(),added.core.id).orElseThrow().id()));assertFalse(quest.snapshot().contains(added.core.record.id()));
for(var holder:java.util.List.of(leader,member)){assertTrue(f.playerCommand.execute(holder.player,"fstick",new String[]{"feedback","off"}));ReskillEventsNativeTest.await(f,()->holder.messages.contains("Fantasy feedback is now off."));}
var preferences=(games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"feedbackPreferences");assertFalse(preferences.enabled(leader.core.id));assertFalse(preferences.enabled(member.core.id));
var leaderBefore=CombatExperienceNativeTest.record(f,leader);var memberBefore=CombatExperienceNativeTest.record(f,member);var soloBefore=CombatExperienceNativeTest.record(f,solo);var addedBefore=CombatExperienceNativeTest.record(f,added);
fixture.pulse();ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().anyMatch(org.bukkit.entity.Vindicator.class::isInstance));var champion=fixture.spawned.stream().filter(org.bukkit.entity.Vindicator.class::isInstance).findFirst().orElseThrow();
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(member.player);when(source.getDirectEntity()).thenReturn(member.player);
var cancelledSource=mock(org.bukkit.damage.DamageSource.class);when(cancelledSource.getCausingEntity()).thenReturn(solo.player);when(cancelledSource.getDirectEntity()).thenReturn(solo.player);
var cancelled=new org.bukkit.event.entity.EntityDamageByEntityEvent(solo.player,champion,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,cancelledSource,1);cancelled.setCancelled(true);f.dispatcher.accept(cancelled);assertTrue(cancelled.isCancelled());
var rejected=new org.bukkit.event.entity.EntityDamageByEntityEvent(added.player,champion,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);f.dispatcher.accept(rejected);assertTrue(rejected.isCancelled());
doReturn(false).when(leader.player).isOnline();var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(member.player,champion,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);when(champion.getLastDamageCause()).thenReturn(hit);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());
var death=new org.bukkit.event.entity.EntityDeathEvent(champion,source,new java.util.ArrayList<>(),0);f.dispatcher.accept(death);f.dispatcher.accept(death);
var trophies=(games.dmg.fantasystick.adventure.FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");ReskillEventsNativeTest.await(f,()->fixture.directory.findQuest(quest.id()).isEmpty()&&trophies.trophies(leaderBefore.id()).size()==1);
var trophy=trophies.trophies(leaderBefore.id()).getFirst();assertEquals(FishingState.TrophyType.CHAMPION,trophy.type());assertEquals(quest.id(),trophy.quest());assertEquals(Set.of(memberBefore.id()),trophy.rewards().keySet());assertEquals(quest.rewardXp(),trophy.total().xp());assertEquals(quest.rewardCoins(),trophy.total().coins());
assertEquals(soloQuest,fixture.directory.findQuest(soloQuest.id()).orElseThrow());assertEquals(leaderBefore,CombatExperienceNativeTest.record(f,leader));assertEquals(memberBefore,CombatExperienceNativeTest.record(f,member));assertEquals(soloBefore,CombatExperienceNativeTest.record(f,solo));assertEquals(addedBefore,CombatExperienceNativeTest.record(f,added));
doReturn(true).when(leader.player).isOnline();var menu=fixture.open(leader);fixture.click(leader,menu,slot(menu,"Turn in quest items"),false);menu=leader.inventories.getLast();fixture.click(leader,menu,slot(menu,"Redeem Daybreak Champion Head"),false);
ReskillEventsNativeTest.await(f,()->trophies.trophies(leaderBefore.id()).isEmpty()&&!trophies.unclaimed(memberBefore.id()).equals(FishingState.Reward.empty()));assertEquals(leaderBefore,CombatExperienceNativeTest.record(f,leader));assertEquals(memberBefore,CombatExperienceNativeTest.record(f,member));
menu=fixture.open(member);fixture.click(member,menu,slot(menu,"Turn in quest items"),false);menu=member.inventories.getLast();fixture.click(member,menu,slot(menu,"Claim earned quest rewards"),false);ReskillEventsNativeTest.await(f,()->trophies.unclaimed(memberBefore.id()).equals(FishingState.Reward.empty()));
var paid=CombatExperienceNativeTest.record(f,member);assertEquals(memberBefore.experience().add(quest.rewardXp()),paid.experience());assertEquals(memberBefore.coins().add(quest.rewardCoins()),paid.coins());assertEquals(leaderBefore,CombatExperienceNativeTest.record(f,leader));assertEquals(soloBefore,CombatExperienceNativeTest.record(f,solo));assertEquals(addedBefore,CombatExperienceNativeTest.record(f,added));
ReskillEventsNativeTest.await(f,()->{try{var rows=ReskillEventsNativeTest.records(data);return java.util.stream.Stream.of("adventure.champion.spawned","adventure.champion.defeated","adventure.trophy_earned","adventure.trophy_redeemed","adventure.reward_claimed").allMatch(type->rows.stream().filter(row->row.get("type").getAsString().endsWith(type)).count()==1);}catch(Exception failure){throw new AssertionError(failure);}});
var rows=ReskillEventsNativeTest.records(data);var defeat=rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.champion.defeated")).findFirst().orElseThrow().getAsJsonObject("data");
assertEquals(trophy.source().toString(),defeat.get("attempt_id").getAsString());assertEquals(1,defeat.get("represented_unit_total").getAsInt());var represented=defeat.getAsJsonArray("represented_units").get(0).getAsJsonObject();assertEquals(quest.id().toString(),represented.get("quest_id").getAsString());assertEquals(party.id().toString(),represented.get("unit_id").getAsString());assertEquals(memberBefore.id().toString(),defeat.getAsJsonObject("defeat_source").get("killer_character_id").getAsString());
assertTrue(rows.stream().noneMatch(row->row.get("type").getAsString().endsWith("adventure.quest.expired")));assertFalse(preferences.enabled(member.core.id));f.plugin.setEnabled(false);f.awaitWorkers();games.dmg.fantasystick.WitchAuditTestSupport.drain(f,"championAuditDrain");
}
}
}
@@ -0,0 +1,87 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static games.dmg.fantasystick.WitchAuditTestSupport.*;
import games.dmg.fantasystick.adventure.AdventureQuestState;
import java.nio.file.Path;
import java.math.BigInteger;
import java.util.Set;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
class ChampionQuestEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@ParameterizedTest @ValueSource(strings={"individual-only","party-available","party-required"})
void registeredExplicitChampionModesAbandonmentAndRerollExportOnlyFrozenAcknowledgedSoloOrPersistentPartyAuthority(String mode)throws Exception{
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var leader=fixture.actor;var member=new FantasyStickLifecycleNativeTest.Actor(data);
member.core.location.setWorld(leader.world);f.players.put(member.core.id,member.player);f.activate(leader);f.activate(member);
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()));
f.adminCommand.execute(leader.player,"fstickadmin",new String[]{"adventure-site","place","champion","--mode",mode,"Same Champion"});
ReskillEventsNativeTest.await(f,()->fixture.directory.snapshot().sites().size()==1);var site=fixture.directory.snapshot().sites().getFirst();
fixture.giver();fixture.resetCatalog();var leaderBefore=CombatExperienceNativeTest.record(f,leader);var memberBefore=CombatExperienceNativeTest.record(f,member);
boolean personal=mode.equals("individual-only");
if(personal){fixture.click(leader,fixture.open(leader),true);assertTrue(fixture.directory.active().isEmpty());assertTrue(leader.messages.contains("Quest acceptance refused: this site is individual-only."));}
else{fixture.click(member,fixture.open(member),true);assertTrue(fixture.directory.active().isEmpty());assertTrue(member.messages.contains("Only the active party leader may accept a party quest."));
if(mode.equals("party-required")){fixture.click(leader,fixture.open(leader),false);assertTrue(fixture.directory.active().isEmpty());assertTrue(leader.messages.contains("Quest acceptance refused: this site is party-required."));}}
var accepting=personal?member:leader;fixture.click(accepting,fixture.open(accepting),!personal);
ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var original=fixture.directory.active().getFirst();
assertEquals(mode,original.participationMode().value());assertEquals(personal?Set.of(memberBefore.id()):Set.of(leaderBefore.id(),memberBefore.id()),original.snapshot());
assertEquals(personal?memberBefore.id():party.id(),original.unit());assertEquals(BigInteger.valueOf(150),original.rewardXp());assertEquals(BigInteger.valueOf(40),original.rewardCoins());
String futureMode=personal?"party-required":"individual-only";
f.adminCommand.execute(leader.player,"fstickadmin",new String[]{"adventure-site","mode",site.id().toString(),futureMode});
ReskillEventsNativeTest.await(f,()->fixture.directory.findSite(site.id()).orElseThrow().participationMode().value().equals(futureMode));
var settings=(games.dmg.fantasystick.persistence.AdventureQuestSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestSettings");
f.adminCommand.execute(leader.player,"fstickadmin",new String[]{"config","adventure-champion-reward-xp","175"});
f.adminCommand.execute(leader.player,"fstickadmin",new String[]{"config","adventure-champion-reward-coins","45"});
ReskillEventsNativeTest.await(f,()->settings.value("adventure-champion-reward-xp").equals("175")&&settings.value("adventure-champion-reward-coins").equals("45"));
assertEquals(original,fixture.directory.findQuest(original.id()).orElseThrow());
fixture.click(accepting,fixture.open(accepting),true);ReskillEventsNativeTest.await(f,()->fixture.directory.active().isEmpty());
f.adminCommand.execute(leader.player,"fstickadmin",new String[]{"adventure-site","mode",site.id().toString(),mode});
ReskillEventsNativeTest.await(f,()->fixture.directory.findSite(site.id()).orElseThrow().participationMode().value().equals(mode));
fixture.click(accepting,fixture.open(accepting),!personal);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);
var successor=fixture.directory.active().getFirst();assertNotEquals(original.id(),successor.id());assertEquals(BigInteger.valueOf(175),successor.rewardXp());assertEquals(BigInteger.valueOf(45),successor.rewardCoins());
if(!personal){
var durable=(games.dmg.fantasystick.party.CharacterPartyDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterParties");
assertEquals(games.dmg.fantasystick.party.CharacterPartyDirectory.Status.APPLIED,durable.transfer(leaderBefore.id(),party.id(),memberBefore.id()).get(5,java.util.concurrent.TimeUnit.SECONDS).status());
var added=new FantasyStickLifecycleNativeTest.Actor(data);added.core.location.setWorld(leader.world);f.players.put(added.core.id,added.player);f.activate(added);
assertTrue(parties.accept(added.core.id,parties.invite(member.core.id,party.id(),added.core.id).orElseThrow().id()));assertFalse(successor.snapshot().contains(added.core.record.id()));
assertEquals(successor,fixture.directory.findQuest(successor.id()).orElseThrow());
}
fixture.resetCatalog();assertTrue(fixture.directory.active().isEmpty());assertEquals(leaderBefore,CombatExperienceNativeTest.record(f,leader));assertEquals(memberBefore,CombatExperienceNativeTest.record(f,member));
ReskillEventsNativeTest.await(f,()->{
try{return rows("adventure.quest.accepted").size()==2&&rows("adventure.quest.abandoned").size()==1&&rows("adventure.quest.expired").size()==1;}
catch(Exception failure){throw new AssertionError(failure);}
});
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");
var accepted=rows("adventure.quest.accepted");var abandoned=rows("adventure.quest.abandoned");var expired=rows("adventure.quest.expired");
assertEquals(4,accepted.size()+abandoned.size()+expired.size(),"Only the actual two saved champion acceptances, acknowledged abandonment and full-reroll expiry may export, not refused choices");
assertEquals(2,accepted.size());assertEquals(1,abandoned.size());assertEquals(1,expired.size());
for(var quest:java.util.List.of(original,successor)){
var row=accepted.stream().filter(value->value.getAsJsonObject("data").get("quest_id").getAsString().equals(quest.id().toString())).findFirst().orElseThrow();var values=row.getAsJsonObject("data");
assertEquals(accepting.core.id.toString(),row.get("playerid").getAsString());assertEquals(quest.catalog().toString(),values.get("catalog_id").getAsString());assertEquals(site.id().toString(),values.get("site_id").getAsString());
assertTrue(values.has("giver_id"),"Acknowledged champion acceptance must retain the actual original Quest Giver target, not infer it during replay");
var giver=fixture.directory.snapshot().givers().getFirst();assertEquals(giver.id().toString(),values.get("giver_id").getAsString());assertEquals(giver.world().toString(),values.getAsJsonObject("giver_location").get("world_id").getAsString());
assertEquals(mode,values.get("captured_mode").getAsString());assertEquals(personal?"solo":"party",values.get("unit_kind").getAsString());assertEquals(quest.unit().toString(),values.get("unit_id").getAsString());
assertEquals(quest.rewardXp().toString(),values.get("reward_xp_pool").getAsString());assertEquals(quest.rewardCoins().toString(),values.get("reward_coin_pool").getAsString());assertEquals(quest.expiresAt().toString(),values.get("deadline").getAsString());
if(!personal)assertEquals(leaderBefore.id().toString(),values.get("accepting_leader_character_id").getAsString());
}
assertEquals(original.id().toString(),abandoned.getFirst().getAsJsonObject("data").get("quest_id").getAsString());
assertEquals("unavailable_not_in_saved_quest",abandoned.getFirst().getAsJsonObject("data").get("accepting_actor_capture_quality").getAsString());
var ended=expired.getFirst().getAsJsonObject("data");assertEquals(successor.id().toString(),ended.get("quest_id").getAsString());assertEquals("full_day_reroll",ended.get("reason").getAsString());
assertEquals(mode,ended.get("captured_mode").getAsString());assertEquals(successor.rewardXp().toString(),ended.get("reward_xp_pool").getAsString());
assertEquals(successor.snapshot().stream().map(Object::toString).collect(java.util.stream.Collectors.toSet()),
java.util.stream.StreamSupport.stream(ended.getAsJsonArray("accepted_roster").spliterator(),false).map(value->value.getAsString()).collect(java.util.stream.Collectors.toSet()));
assertTrue(rows("adventure.trophy_earned").isEmpty());assertTrue(rows("adventure.reward_claimed").isEmpty());
}
}
private java.util.List<com.google.gson.JsonObject> rows(String type)throws Exception{
return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)
&&row.getAsJsonObject("data").has("site_kind")&&row.getAsJsonObject("data").get("site_kind").getAsString().equals("champion")).toList();
}
}
@@ -0,0 +1,96 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static games.dmg.fantasystick.WitchAuditTestSupport.*;
import games.dmg.fantasystick.adventure.AdventureQuestState;
import games.dmg.fantasystick.persistence.YamlAdventureQuestRepository;
import java.nio.file.Path;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ChampionSiteEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void registeredSameNameChampionModeAndRemovalKeepExactRevisionsAndRejectUnchangedDeniedInvalidStaleAndFailedMutations()throws Exception{
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);
for(int index=0;index<2;index++){
int wanted=index+1;assertTrue(f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","place","champion","Same Champion"}));
ReskillEventsNativeTest.await(f,()->fixture.directory.snapshot().sites().size()==wanted);
}
var original=fixture.directory.snapshot().sites().getFirst();var other=fixture.directory.snapshot().sites().getLast();
assertNotEquals(original.id(),other.id());assertNotEquals(original.registrationRevision(),other.registrationRevision());
actor.allowed=false;f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","mode",original.id().toString(),"party-required"});
assertEquals(original,fixture.directory.findSite(original.id()).orElseThrow());actor.allowed=true;
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","mode",original.id().toString(),"bad-mode"});
assertEquals(original,fixture.directory.findSite(original.id()).orElseThrow());
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","mode",original.id().toString(),"party-required"});
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Updated adventure site")));
var changed=fixture.directory.findSite(original.id()).orElseThrow();assertNotNull(changed.registrationRevision());
assertNotEquals(original.registrationRevision(),changed.registrationRevision());assertEquals(changed,new YamlAdventureQuestRepository(data).load().sites().stream().filter(value->value.id().equals(changed.id())).findFirst().orElseThrow());
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","mode",changed.id().toString(),"party-required"});
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.contains("mode is already party-required")));
assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.ModeStatus.STALE,fixture.directory.setSiteMode(changed.id(),
original.participationMode(),AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY).get(5,java.util.concurrent.TimeUnit.SECONDS).status());
var file=data.resolve("adventure-quests.yml");var bytes=java.nio.file.Files.readAllBytes(file);java.nio.file.Files.delete(file);java.nio.file.Files.createDirectory(file);
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","mode",changed.id().toString(),"individual-only"});
ReskillEventsNativeTest.await(f,()->actor.messages.contains("Adventure site mode save failed; the acknowledged mode is unchanged."));
assertEquals(changed,fixture.directory.findSite(changed.id()).orElseThrow());java.nio.file.Files.delete(file);java.nio.file.Files.write(file,bytes);
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","remove",other.id().toString()});
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Removed adventure site "+other.id())));
assertEquals(changed,fixture.directory.findSite(changed.id()).orElseThrow());assertTrue(fixture.directory.findSite(other.id()).isEmpty());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
ReskillEventsNativeTest.await(f,()->{
try{return championRows("adventure.site.registered").size()==2&&championRows("adventure.site.mode_changed").size()==1
&&championRows("adventure.site.removed").size()==1;}catch(Exception failure){throw new AssertionError(failure);}
});
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");
var modes=championRows("adventure.site.mode_changed");var removals=championRows("adventure.site.removed");
assertEquals(2,modes.size()+removals.size(),"Actual acknowledged champion mode/removal must export exactly their own mutations, never refused/failed copies");
assertEquals(1,modes.size());assertEquals(1,removals.size());var mode=modes.getFirst().getAsJsonObject("data");var removed=removals.getFirst().getAsJsonObject("data");
assertEquals(changed.id().toString(),mode.get("site_id").getAsString());assertEquals(changed.registrationRevision().toString(),mode.get("site_revision").getAsString());
assertEquals(original.registrationRevision().toString(),mode.get("requested_site_revision").getAsString());assertEquals("party-available",mode.get("mode_before").getAsString());assertEquals("party-required",mode.get("mode_after").getAsString());
assertEquals("future_acceptances",mode.get("application_scope").getAsString());assertEquals(other.id().toString(),removed.get("site_id").getAsString());assertEquals(other.registrationRevision().toString(),removed.get("site_revision").getAsString());
assertEquals("retained",removed.get("earned_reward_impact").getAsString());assertEquals("Same Champion",removed.get("site_name").getAsString());
}
}
private java.util.List<com.google.gson.JsonObject> championRows(String type)throws Exception{
return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)
&&row.getAsJsonObject("data").get("site_kind").getAsString().equals("champion")).toList();
}
@Test void registeredChampionCreationPersistsIndependentRevisionAndExportsExactOriginalActorDefinitionAfterReload()throws Exception{
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);
String originalSession=((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions"))
.active(actor.core.id).orElseThrow().session().toString();
assertTrue(f.adminCommand.getExecutor().onCommand(actor.player,f.adminCommand,"fstickadmin",
new String[]{"adventure-site","place","champion","--mode","individual-only","8","16","Same Champion"}));
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Placed champion")));
var saved=fixture.directory.snapshot().sites().getFirst();
assertEquals(AdventureQuestState.SiteType.CHAMPION,saved.type());assertEquals(AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY,saved.participationMode());
assertNotNull(saved.registrationRevision(),"Actual acknowledged champion creation must retain its independent definition revision");
assertNotEquals(saved.id(),saved.registrationRevision());assertEquals(AdventureQuestState.Arena.rectangle(8,16),saved.arena());
assertEquals(saved,new YamlAdventureQuestRepository(data).load().sites().getFirst());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
ReskillEventsNativeTest.await(f,()->{
try{return ReskillEventsNativeTest.records(data).stream().anyMatch(row->row.get("type").getAsString().endsWith("adventure.site.registered")
&&row.getAsJsonObject("data").get("site_kind").getAsString().equals("champion"));}
catch(Exception failure){throw new AssertionError(failure);}
});
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");
var rows=ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.site.registered")
&&row.getAsJsonObject("data").get("site_kind").getAsString().equals("champion")).toList();
assertEquals(1,rows.size(),"Actual saved registered champion administration must export one owner record, not depend on camp-only filtering");
var row=rows.getFirst();var values=row.getAsJsonObject("data");
assertEquals(actor.core.id.toString(),row.get("playerid").getAsString());assertEquals(before.id().toString(),row.get("characterid").getAsString());assertEquals(originalSession,values.get("session_id").getAsString());
assertEquals(saved.id().toString(),values.get("site_id").getAsString());assertEquals(saved.registrationRevision().toString(),values.get("site_revision").getAsString());
assertEquals("champion",values.get("site_kind").getAsString());assertEquals("persisted",values.get("revision_capture_quality").getAsString());
assertEquals("individual-only",values.get("mode_after").getAsString());assertEquals("Same Champion",values.get("site_name").getAsString());
assertEquals(saved.world().toString(),values.get("world_id").getAsString());assertEquals(12,values.get("x").getAsDouble());assertEquals(70,values.get("y").getAsDouble());assertEquals(-8,values.get("z").getAsDouble());
assertEquals(8,values.getAsJsonObject("arena").get("x_blocks").getAsInt());assertEquals(16,values.getAsJsonObject("arena").get("z_blocks").getAsInt());
}
}
}
@@ -1755,6 +1755,7 @@ class EquipmentSessionsNativeTest {
assertSame(main, Thread.currentThread(), "Worker accessed Player: " + method.getName());
return switch (method.getName()) {
case "getUniqueId" -> this.id;
case "getType" -> org.bukkit.entity.EntityType.PLAYER;
case "isOnline" -> online;
case "getInventory" -> inventory;
case "getPersistentDataContainer" -> pdc;
@@ -57,7 +57,7 @@ class CampRegistrationRevisionTest {
assertNotNull(camp.registrationRevision()); assertNotNull(giver.registrationRevision());
assertNotEquals(camp.id(),camp.registrationRevision()); assertNotEquals(giver.id(),giver.registrationRevision());
var champion=directory.placeSite(AdventureQuestState.SiteType.CHAMPION,"Same",camp.world(),0,64,0).get(5,TimeUnit.SECONDS);
assertNull(champion.registrationRevision(),"This camp-only enabler does not claim a newly delivered champion revision");
assertNotNull(champion.registrationRevision(),"The approved champion owner now uses the optional revision enabler without changing camp/giver authority");
directory.setSiteMode(champion.id(),champion.participationMode(),AdventureQuestState.ParticipationMode.PARTY_REQUIRED).get(5,TimeUnit.SECONDS);
assertEquals(camp,directory.findSite(camp.id()).orElseThrow());
assertEquals(giver,directory.findGiver(giver.id()).orElseThrow());
@@ -0,0 +1,55 @@
package games.dmg.fantasystick.adventure;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.persistence.AdventureQuestSettings;
import games.dmg.fantasystick.persistence.YamlAdventureQuestRepository;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.List;
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;
class ChampionRegistrationRevisionTest {
@TempDir Path data;
@Test void acknowledgedChampionRevisionSurvivesUnrelatedCampGiverFishingMutationsAndIsReturnedUnchangedOnRemoval()throws Exception{
var rules=new AdventureQuestSettings.Rules(3,16,30,8,BigInteger.valueOf(100),BigInteger.valueOf(20),100,8,BigInteger.valueOf(150),BigInteger.valueOf(40));
var world=UUID.randomUUID();var repository=new YamlAdventureQuestRepository(data);AdventureQuestState.Site champion;
try(var directory=new AdventureQuestDirectory(repository,Clock.systemUTC(),()->rules,new java.util.Random(1))){
var original=directory.placeSite(AdventureQuestState.SiteType.CHAMPION,"Same",world,12,70,-8,AdventureQuestState.Arena.rectangle(8,16),AdventureQuestState.ParticipationMode.PARTY_AVAILABLE).get(5,java.util.concurrent.TimeUnit.SECONDS);
champion=directory.setSiteMode(original.id(),original.participationMode(),AdventureQuestState.ParticipationMode.PARTY_REQUIRED).get(5,java.util.concurrent.TimeUnit.SECONDS).site().orElseThrow();assertNotNull(champion.registrationRevision());assertNotEquals(original.registrationRevision(),champion.registrationRevision());
var camp=directory.placeSite(AdventureQuestState.SiteType.CAMP,"Same",world,20,70,20).get(5,java.util.concurrent.TimeUnit.SECONDS);directory.setSiteMode(camp.id(),camp.participationMode(),AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY).get(5,java.util.concurrent.TimeUnit.SECONDS);
directory.placeGiver("Guide",world,12,70,-8,0,0).get(5,java.util.concurrent.TimeUnit.SECONDS);
var fishing=directory.placeFishingSite("Same","Fish",world,12,70,-8,16,java.math.BigDecimal.TEN,BigInteger.ONE,BigInteger.TEN,BigInteger.ONE,AdventureQuestState.ParticipationMode.PARTY_AVAILABLE).get(5,java.util.concurrent.TimeUnit.SECONDS);
directory.updateFishingSite(fishing.id(),fishing,"Renamed",fishing.fishing(),AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY).get(5,java.util.concurrent.TimeUnit.SECONDS);directory.removeSite(camp.id()).get(5,java.util.concurrent.TimeUnit.SECONDS);
assertEquals(champion,directory.findSite(champion.id()).orElseThrow());assertEquals(champion,repository.load().sites().stream().filter(site->site.id().equals(champion.id())).findFirst().orElseThrow());
}
try(var directory=new AdventureQuestDirectory(repository,Clock.systemUTC(),()->rules,new java.util.Random(1))){assertEquals(champion,directory.findSite(champion.id()).orElseThrow());assertEquals(champion,directory.removeSite(champion.id()).get(5,java.util.concurrent.TimeUnit.SECONDS).orElseThrow());assertTrue(directory.findSite(champion.id()).isEmpty());}
}
@Test void legacyChampionSchemaOneAndTwoCurrentDayReadsNeverBackfillOrRewriteAbsentDefinitionRevisions()throws Exception{
var clock=Clock.fixed(Instant.parse("2026-09-23T19:00:00Z"),ZoneOffset.UTC);
var rules=new AdventureQuestSettings.Rules(3,16,30,8,BigInteger.valueOf(100),BigInteger.valueOf(20),100,8,BigInteger.valueOf(150),BigInteger.valueOf(40));
var site=new AdventureQuestState.Site(UUID.randomUUID(),AdventureQuestState.SiteType.CHAMPION,"Legacy Champion",UUID.randomUUID(),12,70,-8);
var catalog=new AdventureQuestState.Catalog(LocalDate.of(2026,9,23),UUID.randomUUID(),clock.instant(),List.of(site.id()));
var state=new AdventureQuestState(List.of(site),List.of(),catalog,List.of(),Set.of(),Set.of(),Map.of());
new YamlAdventureQuestRepository(data).save(state);var file=data.resolve("adventure-quests.yml");var original=Files.readString(file);
assertFalse(original.contains("registration-revision"));
for(int schema:List.of(1,2)){
Files.writeString(file,original.replace("schema-version: 2","schema-version: "+schema));var bytes=Files.readAllBytes(file);
try(var directory=new AdventureQuestDirectory(new YamlAdventureQuestRepository(data),clock,()->rules,new java.util.Random(1))){
assertNull(directory.findSite(site.id()).orElseThrow().registrationRevision());
assertEquals(catalog,directory.ensureToday().get(5,java.util.concurrent.TimeUnit.SECONDS));
assertArrayEquals(bytes,Files.readAllBytes(file));
}
assertEquals(state,new YamlAdventureQuestRepository(data).load());assertArrayEquals(bytes,Files.readAllBytes(file));
}
}
}
@@ -0,0 +1,50 @@
package games.dmg.fantasystick.events;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.adventure.AdventureQuestState;
import java.math.BigInteger;
import java.nio.file.Path;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ChampionEventBridgeTest {
@TempDir Path data;
@Test void multipartRepresentedUnitsAndTwoSameOperationQuestExpiriesReplayExactOriginalFactsWithDistinctStableIds()throws Exception{
var rejected=new java.util.concurrent.CopyOnWriteArrayList<EventInput>();var failures=new java.util.concurrent.CopyOnWriteArrayList<Throwable>();
UUID catalog=UUID.randomUUID(),attempt=UUID.randomUUID(),operation=UUID.randomUUID();
var site=new AdventureQuestState.Site(UUID.randomUUID(),AdventureQuestState.SiteType.CHAMPION,"Same Champion",UUID.randomUUID(),12,70,-8,
AdventureQuestState.Arena.rectangle(8,16),AdventureQuestState.ParticipationMode.PARTY_AVAILABLE,null,UUID.randomUUID());
var units=new ArrayList<Map<String,Object>>();for(int index=0;index<41;index++)units.add(Map.of("quest_id",UUID.randomUUID().toString(),"unit_id",UUID.randomUUID().toString(),"unit_kind","solo"));
var members=java.util.stream.IntStream.range(0,41).mapToObj(index->UUID.randomUUID()).collect(java.util.stream.Collectors.toSet());
var first=new AdventureQuestState.Quest(UUID.randomUUID(),catalog,site.id(),AdventureQuestState.UnitType.PARTY,UUID.randomUUID(),members,Instant.parse("2026-09-24T00:00:00Z"),BigInteger.valueOf(150),BigInteger.valueOf(40));
var second=new AdventureQuestState.Quest(UUID.randomUUID(),catalog,site.id(),AdventureQuestState.UnitType.PARTY,UUID.randomUUID(),members,first.expiresAt(),first.rewardXp(),first.rewardCoins());
var actor=new CampEventBridge.Actor(null,Map.of("actor_kind","system","actor_capture_quality","unavailable"));
var originalUnits=List.copyOf(units);
{
var bridge=new ChampionEventBridge(event->{rejected.add(event);return CompletableFuture.completedFuture(LocalEventLog.Delivery.REJECTED);},data,failures::add);
var facts=new CampEventBridge.CampAttempt(attempt,catalog,site,site.arena(),30,List.of("VINDICATOR"),List.of(Map.of("entity_id",UUID.randomUUID().toString(),"entity_type","VINDICATOR")),units,0,UUID.randomUUID(),Map.of("world_id",site.world().toString(),"x",12.0,"y",70.0,"z",-8.0),Instant.parse("2026-09-23T19:00:00Z"),true,Map.of("status","not_applicable"));
bridge.attemptTransition("adventure.champion.defeated",facts,Map.of("configured_health",100.0,"configured_damage",8.0),"final_native_enemy_defeated",true);
bridge.questTransition("adventure.quest.expired",operation,actor,first,site,null,"full_day_reroll");bridge.questTransition("adventure.quest.expired",operation,actor,second,site,null,"full_day_reroll");units.clear();
bridge.close();bridge.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
}
assertTrue(failures.isEmpty());assertEquals(9,rejected.size());assertEquals(9,rejected.stream().map(EventInput::id).distinct().count());
var deaths=rejected.stream().filter(event->event.type().equals("adventure.champion.defeated")).toList();assertEquals(3,deaths.size());
assertEquals(originalUnits,deaths.stream().flatMap(event->((List<?>)event.data().get("represented_units")).stream()).toList());
for(var quest:List.of(first,second)){var parts=rejected.stream().filter(event->quest.id().toString().equals(event.data().get("quest_id"))).toList();assertEquals(3,parts.size());assertEquals(members.stream().map(UUID::toString).collect(java.util.stream.Collectors.toSet()),parts.stream().flatMap(event->((List<?>)event.data().get("accepted_roster")).stream()).collect(java.util.stream.Collectors.toSet()));}
var replayed=new java.util.concurrent.CopyOnWriteArrayList<EventInput>();
{
var bridge=new ChampionEventBridge(event->{replayed.add(event);return CompletableFuture.completedFuture(LocalEventLog.Delivery.EXPORTED);},data,failures::add);
bridge.replay();bridge.replay();bridge.close();bridge.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
}
assertTrue(failures.isEmpty());assertEquals(rejected.stream().collect(java.util.stream.Collectors.toMap(EventInput::id,event->event)),replayed.stream().collect(java.util.stream.Collectors.toMap(EventInput::id,event->event)));assertEquals(9,replayed.size());
}
}