feat(events): audit registered daily camp lifecycles
Release / release (push) Failing after 12m15s

This commit is contained in:
dmg committed 2026-10-02 23:07:48 -04:00
1 parent 11d85a0a69
commit 6552a90a93
22 files changed
+1704 -36

No files matched your search

@@ -189,6 +189,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
private java.util.concurrent.CompletionStage<Void> combatRewardAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.StickKnockbackEventBridge stickKnockbackEvents;
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.HardMobXpEventBridge hardMobXpAuditEvents;
private java.util.concurrent.CompletionStage<Void> hardMobXpAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.TrainingEventBridge trainingAuditEvents;
@@ -916,6 +918,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
partyAuditEvents = new games.dmg.fantasystick.events.PartyEventBridge(eventLog::emit, Clock.systemUTC());partyManagement.attachEvents(partyAuditEvents);admin.attachPartyEvents(partyAuditEvents);
partyCombat.attachProtectionEvents(new games.dmg.fantasystick.events.PartyProtectionEventBridge(
eventLog::emit, Clock.systemUTC()));
campAuditEvents = new games.dmg.fantasystick.events.CampEventBridge(eventLog::emit, getDataFolder().toPath(), sessions,
failure -> getLogger().log(Level.WARNING, "Camp event intent failed", failure));
admin.attachCampEvents(campAuditEvents);
adventureQuestCommands.attachCampEvents(campAuditEvents);
adventureQuestController.attachCampEvents(campAuditEvents);
adventureQuests.attachCatalogEvents(campAuditEvents::catalogPublished);
adventureQuests.attachQuestExpiryEvents(campAuditEvents::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);
@@ -1111,6 +1120,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (failure != null) getLogger().log(Level.WARNING, "Previous hard-mob XP event drain failed", failure);
if (replayHardMobXpEvents != null) replayHardMobXpEvents.replay();
});
var replayCampEvents = campAuditEvents;
campAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous camp event drain failed", failure);
if (replayCampEvents != null) replayCampEvents.replay();
});
var replayTrainingEvents = trainingAuditEvents;
trainingAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous training event drain failed", failure);
@@ -1226,6 +1240,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingGrantEvents = adminExperienceEvents; adminExperienceEvents = null;
var closingKnockbackEvents = stickKnockbackEvents; stickKnockbackEvents = null;
var closingHardMobXpEvents = hardMobXpAuditEvents; hardMobXpAuditEvents = null;
var closingCampEvents = campAuditEvents; campAuditEvents = null;
var closingTrainingEvents = trainingAuditEvents; trainingAuditEvents = null;
var closingArmorEvents = armorAuditEvents; armorAuditEvents = null;
var closingCoinEvents = coinEvents;
@@ -1605,6 +1620,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
closingHardMobXpEvents.close(); return null;
}).thenCompose(ignored -> closingHardMobXpEvents.drained());
});
if (closingCampEvents != null) cleanup("camp event intent drain", () -> {
var authorityDrained = closingAdventureQuests == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingAdventureQuests.drained();
campAuditDrain = java.util.concurrent.CompletableFuture.allOf(authorityDrained.toCompletableFuture(), configurationDrain.toCompletableFuture())
.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Camp authority drain failed", failure);
closingCampEvents.close(); return null;
}).thenCompose(ignored -> closingCampEvents.drained());
});
if (closingTrainingEvents != null) cleanup("training event intent drain", () -> {
var registrationsDrained = closingTrainingDummies == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingTrainingDummies.drained();
@@ -14,9 +14,11 @@ public final class AdventureQuestCommands {
public AdventureQuestCommands(Plugin plugin,AdventureQuestDirectory directory,AdventureQuestController controller,Consumer<Runnable> nextTick){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.controller=Objects.requireNonNull(controller);this.nextTick=Objects.requireNonNull(nextTick);locations=new AdventureLocationPresentation(plugin.getServer());teleports=new AdventureAdminTeleport(plugin,nextTick);}
private games.dmg.fantasystick.events.FishingEventBridge fishingEvents;
public void attachFishingEvents(games.dmg.fantasystick.events.FishingEventBridge value){fishingEvents=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();directory.rerollToday(expected).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;directory.placeSite(type,placement.name(),Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),placement.arena(),mode).whenComplete((value,failure)->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);directory.setSiteMode(id,current.participationMode(),requested).whenComplete((result,failure)->{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;}directory.removeSite(id).whenComplete((removed,failure)->respond(sender,()->{if(failure!=null)sender.sendMessage("Adventure site removal failed; desired state is unchanged.");else if(removed.isEmpty())sender.sendMessage("Adventure site not found.");else{controller.removeSiteLoaded(id);sender.sendMessage("Removed adventure site "+id+"; its quests expired.");}}));}case"teleport"->{if(args.length!=3){usageSite(sender);return;}if(!(sender instanceof Player)){sender.sendMessage("Adventure-site teleport requires a player.");return;}UUID id=parse(args[2]);if(id==null){usageSite(sender);return;}var site=site(id).orElse(null);if(site==null){sender.sendMessage("Adventure site not found.");return;}teleports.teleport(sender,"Adventure-site",target(site),()->site(id).map(AdventureQuestCommands::target));}default->usageSite(sender);}}
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();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 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())));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;}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{controller.removeGiverLoaded(id);sender.sendMessage("Removed Quest Giver "+id+".");}}));}default->usageGiver(sender);}}
private games.dmg.fantasystick.events.CampEventBridge campEvents;
public void attachCampEvents(games.dmg.fantasystick.events.CampEventBridge value){campEvents=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 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);}}
private void fishing(CommandSender sender,String[] args){if(args.length<3){usageSite(sender);return;}UUID id=parse(args[2]);var current=id==null?null:directory.findSite(id).filter(value->value.type()==AdventureQuestState.SiteType.FISHING).orElse(null);if(current==null){sender.sendMessage("Fishing site not found.");return;}if(args.length==3){sender.sendMessage(current.id()+" "+current.displayName()+"; "+fishingDetails(current)+".");return;}var options=options(args,3,Set.of("name","item","required","chance","xp","coins","radius","mode"));if(options==null||options.isEmpty()){usageSite(sender);return;}try{var before=current.fishing();var definition=new AdventureQuestState.FishingDefinition(before.itemId(),options.getOrDefault("item",before.itemName()),options.containsKey("required")?positive(options.get("required")):before.required(),options.containsKey("chance")?percent(options.get("chance")):before.chancePercent(),options.containsKey("xp")?nonnegative(options.get("xp")):before.rewardXp(),options.containsKey("coins")?nonnegative(options.get("coins")):before.rewardCoins(),options.containsKey("radius")?bounded(options.get("radius"),4,64):before.radius());var mode=options.containsKey("mode")?AdventureQuestState.ParticipationMode.parse(options.get("mode")):current.participationMode();var audit=fishingEvents;var actor=audit==null?null:audit.actor(sender);directory.updateFishingSite(id,current,options.getOrDefault("name",current.name()),definition,mode).whenComplete((result,failure)->{if(failure==null&&result.status()==AdventureQuestDirectory.FishingUpdateStatus.APPLIED&&audit!=null){var changes=new LinkedHashMap<String,Object>();for(var field:options.keySet())changes.put(field,options.get(field));audit.site("adventure.site.updated",id,actor,result.site().orElseThrow(),changes);}respond(sender,()->{if(failure!=null){sender.sendMessage("Fishing site update failed; the acknowledged definition is unchanged.");return;}sender.sendMessage(switch(result.status()){case APPLIED->"Updated fishing site "+id+"; "+fishingDetails(result.site().orElseThrow())+".";case UNCHANGED->"Fishing site "+id+" is unchanged.";case STALE->"Fishing site update refused: the acknowledged definition changed; inspect and retry.";case NOT_FOUND->"Fishing site not found.";});});});}catch(IllegalArgumentException invalid){usageSite(sender);}}
@@ -31,6 +33,12 @@ public final class AdventureQuestCommands {
private Optional<AdventureQuestState.Site> site(UUID id){return directory.findSite(id).filter(value->value.type()!=AdventureQuestState.SiteType.DUNGEON);}
private static AdventureAdminTeleport.Target target(AdventureQuestState.Site site){return new AdventureAdminTeleport.Target(site.id(),site.displayName(),switch(site.type()){case CAMP->"monster camp";case CHAMPION->"champion";case FISHING->"fishing site";case DUNGEON->"dungeon entrance";},site.world(),site.x(),site.y(),site.z(),0,0,true);}
private boolean authorized(CommandSender sender){if(sender.hasPermission(PERMISSION))return true;sender.sendMessage("Permission denied: you need fantasystick.admin to administer Fantasy Stick.");return false;}
private void auditGiver(games.dmg.fantasystick.events.CampEventBridge bridge,String type,UUID operation,
games.dmg.fantasystick.events.CampEventBridge.Actor actor,AdventureQuestState.Giver giver,
AdventureQuestController.ObservedGiver before,boolean nativeReturned){
try{if(bridge!=null)bridge.giverMutation(type,operation,actor,giver,before,controller.observedGiver(giver),nativeReturned);}
catch(RuntimeException ignored){}
}
private void respond(CommandSender sender,Runnable response){try{nextTick.accept(()->{if(sender.hasPermission(PERMISSION)&&(!(sender instanceof Player player)||player.isOnline()))response.run();});}catch(RuntimeException ignored){}}
private record Placement(String name,AdventureQuestState.Arena arena){}
private static Placement placement(String[] args,int start){if(args.length==start)return new Placement("",null);if(Arrays.stream(args,start,args.length).anyMatch(value->value.startsWith("--")))return null;Integer first=whole(args[start]);if(first==null)return looksNumeric(args[start])||args[start].startsWith("--")?null:new Placement(join(args,start),null);if(args.length>start+1){Integer second=whole(args[start+1]);if(second!=null)return first>=8&&first<=128&&second>=8&&second<=128?new Placement(join(args,start+2),AdventureQuestState.Arena.rectangle(first,second)):null;if(looksNumeric(args[start+1]))return null;}return first>=4&&first<=64?new Placement(join(args,start+1),AdventureQuestState.Arena.circle(first)):null;}
@@ -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 {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;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());}
@@ -74,16 +74,38 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
?Optional.of(origin):Optional.empty();
}
public void reconcile(Chunk chunk){if(closed)return;var desired=new HashMap<UUID,AdventureQuestState.Giver>();for(var giver:directory.snapshot().givers())if(giver.world().equals(chunk.getWorld().getUID())&&section(giver.x())==chunk.getX()&&section(giver.z())==chunk.getZ())desired.put(giver.id(),giver);var retained=new HashSet<UUID>();for(var entity:chunk.getEntities()){if(managed(entity)){entity.remove();continue;}if(!giver(entity))continue;UUID id=id(entity,idKey);var value=id==null?null:desired.get(id);if(!(entity instanceof Villager villager)||value==null||!retained.add(id)){entity.remove();continue;}configure(villager,value);var expected=location(value);var current=villager.getLocation();if(current.distanceSquared(expected)>0.000001||Math.abs(current.getYaw()-expected.getYaw())>0.001||Math.abs(current.getPitch()-expected.getPitch())>0.001){relocating.add(villager);boolean moved;try{moved=villager.teleport(expected);}finally{relocating.remove(villager);}if(!moved){villager.remove();spawnGiver(value);}}}for(var value:desired.values())if(!retained.contains(value.id()))spawnGiver(value);}
public record ObservedGiver(java.util.List<UUID> ids,String chunkStatus,String quality) {
public ObservedGiver{ids=java.util.List.copyOf(ids);}
}
/** Read only currently loaded native entities; optional audit getters never force a load or authorize reconciliation. */
public ObservedGiver observedGiver(AdventureQuestState.Giver value){
try{
var world=plugin.getServer().getWorld(value.world());
if(world==null)return new ObservedGiver(java.util.List.of(),"world_unavailable","unavailable");
if(!world.isChunkLoaded(section(value.x()),section(value.z())))return new ObservedGiver(java.util.List.of(),"unloaded","not_loaded");
var entities=world.getEntities();
if(entities==null)return new ObservedGiver(java.util.List.of(),"loaded","unavailable");
var ids=new java.util.ArrayList<UUID>();boolean complete=true;
for(var entity:entities){
try{if(giver(entity)&&value.id().equals(id(entity,idKey))&&entity.isValid()){
var nativeId=entity.getUniqueId();if(nativeId==null)complete=false;else ids.add(nativeId);
}}catch(RuntimeException unavailable){complete=false;}
}
return new ObservedGiver(ids.stream().sorted().toList(),"loaded",complete?"native":"partial");
}catch(RuntimeException unavailable){return new ObservedGiver(java.util.List.of(),"unavailable","unavailable");}
}
public void removeGiverLoaded(UUID id){for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(giver(entity)&&id.equals(id(entity,idKey)))entity.remove();}
public void removeSiteLoaded(UUID id){var attempt=attempts.remove(id);if(attempt!=null)cleanup(attempt);readyAt.remove(id);refreshTrackingPlayers();}
public void removeSiteLoaded(UUID id){var attempt=attempts.remove(id);if(attempt!=null)cleanup(attempt,"site_removed");readyAt.remove(id);refreshTrackingPlayers();}
public boolean hasActiveEncounter(){return !attempts.isEmpty()||!starting.isEmpty();}
public AdventureQuestState.Arena arena(AdventureQuestState.Site site){return site.arenaOrDefault(settings.rules().radius());}
public void registerFishing(FishingController fishing){if(closed||this.fishing!=null)throw new IllegalStateException("Fishing already attached or adventure controller closed");this.fishing=Objects.requireNonNull(fishing);}
private games.dmg.fantasystick.events.FishingEventBridge fishingEvents;
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);}
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);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;}
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;}
private void spawnGiver(AdventureQuestState.Giver value){var world=plugin.getServer().getWorld(value.world());if(world==null||!world.isChunkLoaded(section(value.x()),section(value.z())))return;world.spawn(location(value),Villager.class,villager->configure(villager,value));}
private void configure(Villager villager,AdventureQuestState.Giver value){var data=villager.getPersistentDataContainer();data.set(typeKey,PersistentDataType.STRING,GIVER);data.set(idKey,PersistentDataType.STRING,value.id().toString());villager.setAI(false);villager.setGravity(false);villager.setSilent(true);villager.setInvulnerable(true);villager.setCollidable(false);villager.setCanPickupItems(false);villager.setLeashHolder(null);villager.setRemoveWhenFarAway(false);villager.setAdult();villager.setAgeLock(true);villager.customName(net.kyori.adventure.text.Component.text(value.displayName()));villager.setCustomNameVisible(true);}
public void open(Player player){open(player,0);}
@@ -94,13 +116,13 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
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;}directory.abandon(active.id(),active.unit()).whenComplete((changed,failure)->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;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(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 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.");}));}
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();}
public void tracking(Player player,Boolean enabled){var context=context(player);if(context==null){player.sendMessage("Quest tracking refused: actively play a living fantasy character first.");return;}var quest=directory.activeFor(context.character,context.partyId).filter(value->value.snapshot().contains(context.character)).orElse(null);if(quest==null){player.sendMessage("Quest tracking refused: this character has no eligible active quest.");refreshTracking(player);return;}if(enabled==null){player.sendMessage("Quest tracking is "+(directory.trackingEnabled(quest.id(),context.character)?"on":"off")+" for the current adventure quest.");return;}directory.setTracking(quest.id(),context.character,enabled).whenComplete((status,failure)->schedule(()->{if(failure!=null||status==AdventureQuestDirectory.TrackingStatus.INELIGIBLE){player.sendMessage("Quest tracking change failed; retained state is unchanged.");return;}refreshTracking(player);player.sendMessage("Quest tracking is now "+(enabled?"on.":"off."));}));}
public void inspect(Player player){var context=context(player);if(context==null){player.sendMessage("No active fantasy character.");return;}var quest=directory.activeFor(context.character,context.partyId).orElse(null);if(quest==null){player.sendMessage("No active adventure quest.");return;}var site=directory.findSite(quest.site()).orElse(null);if(site==null){player.sendMessage("Active quest site is unavailable.");return;}player.sendMessage(site.displayName()+"; state="+(attempts.containsKey(site.id())?"active":"waiting")+"; expires "+quest.expiresAt()+"; Quest Compass guidance="+(directory.trackingEnabled(quest.id(),context.character)?"on":"off")+".");}
private void pulse(){if(closed)return;directory.ensureToday().whenComplete((catalog,failure)->{if(failure==null)schedule(()->{if(directory.snapshot().reset()!=null)publishPendingReset();});});if(directory.snapshot().reset()!=null){publishPendingReset();return;}directory.expireParties(parties::exists);refreshTrackingPlayers(true);showArenaBoundaries();for(var attempt:new ArrayList<>(attempts.values())){var catalog=directory.snapshot().catalog();if(catalog==null||!catalog.revision().equals(attempt.catalog)||!clock.instant().isBefore(catalog.expiresAt())){cleanup(attempt);attempts.remove(attempt.site);readyAt.remove(attempt.site);continue;}contain(attempt);syncBossBar(attempt);boolean present=eligibleInside(attempt.site,attempt.arena).findAny().isPresent();if(present)attempt.absentSince=null;else if(attempt.absentSince==null)attempt.absentSince=clock.instant();else if(!clock.instant().isBefore(attempt.absentSince.plusSeconds(attempt.resetSeconds))){cleanup(attempt);attempts.remove(attempt.site);readyAt.put(attempt.site,clock.instant());}}for(var quest:directory.active()){var type=directory.findSite(quest.site()).map(AdventureQuestState.Site::type).orElse(null);if(type==AdventureQuestState.SiteType.DUNGEON||type==AdventureQuestState.SiteType.FISHING)continue;if(attempts.containsKey(quest.site())||starting.contains(quest.site()))continue;var ready=readyAt.get(quest.site());if(ready!=null&&clock.instant().isBefore(ready))continue;if(eligibleInside(quest.site()).findAny().isPresent())start(quest.site(),quest.catalog());}}
private void pulse(){if(closed)return;directory.ensureToday().whenComplete((catalog,failure)->{if(failure==null)schedule(()->{if(directory.snapshot().reset()!=null)publishPendingReset();});});if(directory.snapshot().reset()!=null){publishPendingReset();return;}directory.expireParties(parties::exists);refreshTrackingPlayers(true);showArenaBoundaries();for(var attempt:new ArrayList<>(attempts.values())){var catalog=directory.snapshot().catalog();if(catalog==null||!catalog.revision().equals(attempt.catalog)||!clock.instant().isBefore(catalog.expiresAt())){cleanup(attempt,"catalog_or_utc_expired");attempts.remove(attempt.site);readyAt.remove(attempt.site);continue;}contain(attempt);syncBossBar(attempt);boolean present=eligibleInside(attempt.site,attempt.arena).findAny().isPresent();if(present)attempt.absentSince=null;else if(attempt.absentSince==null)attempt.absentSince=clock.instant();else if(!clock.instant().isBefore(attempt.absentSince.plusSeconds(attempt.resetSeconds))){cleanup(attempt,"no_eligible_accepted_character_remained");attempts.remove(attempt.site);readyAt.put(attempt.site,clock.instant());}}for(var quest:directory.active()){var type=directory.findSite(quest.site()).map(AdventureQuestState.Site::type).orElse(null);if(type==AdventureQuestState.SiteType.DUNGEON||type==AdventureQuestState.SiteType.FISHING)continue;if(attempts.containsKey(quest.site())||starting.contains(quest.site()))continue;var ready=readyAt.get(quest.site());if(ready!=null&&clock.instant().isBefore(ready))continue;if(eligibleInside(quest.site()).findAny().isPresent())start(quest.site(),quest.catalog());}}
private void showArenaBoundaries(){for(UUID siteId:directory.active().stream().map(AdventureQuestState.Quest::site).distinct().toList()){var site=directory.findSite(siteId).orElse(null);if(site==null||site.type()==AdventureQuestState.SiteType.DUNGEON)continue;var world=plugin.getServer().getWorld(site.world());if(world==null)continue;var center=new Location(world,site.x(),site.y(),site.z());var points=perimeter(arena(site));for(var player:plugin.getServer().getOnlinePlayers()){if(eligible(player,siteId)==null)continue;var viewer=player.getLocation();if(viewer.getWorld()==null||!viewer.getWorld().getUID().equals(site.world())||viewer.distanceSquared(center)>64d*64d)continue;for(var point:points)particles.viewer(player,Particle.END_ROD,new Location(world,site.x()+point[0],site.y()+0.2,site.z()+point[1]),1,0,0,0,0);}}}
private static List<double[]> perimeter(AdventureQuestState.Arena arena){var result=new ArrayList<double[]>();if(arena.shape()==AdventureQuestState.ArenaShape.CIRCLE){int count=Math.min(256,Math.max(16,(int)Math.ceil(Math.PI*arena.xBlocks())));for(int index=0;index<count;index++){double angle=2*Math.PI*index/count;result.add(new double[]{Math.cos(angle)*arena.xBlocks(),Math.sin(angle)*arena.xBlocks()});}}else{double halfX=arena.xBlocks()/2d,halfZ=arena.zBlocks()/2d;edge(result,-halfX,halfX,-halfZ,true);edge(result,-halfX,halfX,halfZ,true);edge(result,-halfZ,halfZ,-halfX,false);edge(result,-halfZ,halfZ,halfX,false);}return result.size()<=256?List.copyOf(result):List.copyOf(result.subList(0,256));}
private static void edge(List<double[]> result,double from,double to,double fixed,boolean xAxis){for(double value=from;value<to;value+=2)result.add(xAxis?new double[]{value,fixed}:new double[]{fixed,value});result.add(xAxis?new double[]{to,fixed}:new double[]{fixed,to});}
@@ -108,10 +130,10 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
private java.util.stream.Stream<Player> eligibleInside(UUID site){var value=directory.findSite(site).orElse(null);return value==null?java.util.stream.Stream.empty():eligibleInside(site,arena(value));}
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());attempts.put(site,attempt);for(int index=0;index<roster.size();index++)spawnEnemy(world,current,roster.get(index),attempt,index);}));}
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 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);}attempt.entities.add(mob.getUniqueId());}
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);}}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void accepted(EntityDamageByEntityEvent event){if(!enemy(event.getEntity())||event.getFinalDamage()<=0||!Double.isFinite(event.getFinalDamage()))return;var player=source(event);if(player==null)return;var quest=eligible(player,site(event.getEntity()));if(quest!=null){var attempt=attempts.get(quest.site());if(attempt!=null){attempt.represented.add(quest.id());if(attempt.champion==event.getEntity()&&attempt.bossBar!=null){double maximum=Objects.requireNonNull(attempt.champion.getAttribute(Attribute.MAX_HEALTH)).getValue();attempt.bossBar.setProgress(Math.max(0,Math.min(1,(attempt.champion.getHealth()-event.getFinalDamage())/maximum)));}}}}
@EventHandler(priority=EventPriority.HIGHEST)public void death(EntityDeathEvent event){
@@ -158,7 +180,37 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
return Map.copyOf(value);
}catch(RuntimeException unavailable){return Map.of("status","unavailable");}
}
private void clear(Attempt attempt){attempts.remove(attempt.site);if(attempt.bossBar!=null)attempt.bossBar.removeAll();readyAt.put(attempt.site,clock.instant().plusSeconds(attempt.resetSeconds));for(UUID questId:Set.copyOf(attempt.represented)){var quest=directory.findQuest(questId).orElse(null);if(quest==null)continue;var recipients=recipients(quest,attempt.site,attempt.arena);if(!recipients.isEmpty())award(quest,recipients,attempt);} }
private void clear(Attempt attempt){attempts.remove(attempt.site);if(attempt.bossBar!=null)attempt.bossBar.removeAll();readyAt.put(attempt.site,clock.instant().plusSeconds(attempt.resetSeconds));auditAttempt("adventure.camp.cleared",attempt,"final_native_enemy_defeated",true);for(UUID questId:Set.copyOf(attempt.represented)){var quest=directory.findQuest(questId).orElse(null);if(quest==null)continue;var recipients=recipients(quest,attempt.site,attempt.arena);if(!recipients.isEmpty())award(quest,recipients,attempt);} }
private static void auditNativeEnemy(Attempt attempt,UUID id,Mob mob){
try{if(id!=null){var type=mob.getType();attempt.auditEntities.put(id,type==null?"unavailable":type.name());}}
catch(RuntimeException unavailable){if(id!=null)attempt.auditEntities.put(id,"unavailable");}
}
private void auditAttempt(String type,Attempt attempt,String reason,boolean succeeded){
try{
if(campEvents==null||attempt.type!=AdventureQuestState.SiteType.CAMP||attempt.auditSite==null)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()){
var quest=directory.findQuest(id).orElse(null);var unit=new LinkedHashMap<String,Object>();unit.put("quest_id",id.toString());
if(quest!=null){unit.put("unit_id",quest.unit().toString());unit.put("unit_kind",quest.unitType().name().toLowerCase(Locale.ROOT));unit.put("captured_mode",quest.participationMode().value());}
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,
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);
}catch(RuntimeException ignored){/* Metadata cannot spawn, reset, represent, clear or award gameplay. */}
}
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");
if(!world.isChunkLoaded(section(attempt.auditSite.x()),section(attempt.auditSite.z())))return Map.of("status","not_loaded");
var entities=world.getEntities();if(entities==null)return Map.of("status","unavailable");
int remaining=Math.toIntExact(entities.stream().filter(entity->attempt.auditEntities.containsKey(entity.getUniqueId())).filter(Entity::isValid).count());
return Map.of("status","loaded_observed","remaining_native_entities",remaining);
}catch(RuntimeException unavailable){return Map.of("status","unavailable");}
}
private List<UUID> recipients(AdventureQuestState.Quest quest,UUID site){var value=directory.findSite(site).orElse(null);return value==null?List.of():recipients(quest,site,arena(value));}
private List<UUID> recipients(AdventureQuestState.Quest quest,UUID site,AdventureQuestState.Arena arena){var value=directory.findSite(site).orElse(null);if(value==null)return List.of();var result=new ArrayList<UUID>();for(UUID character:quest.snapshot()){if(directory.characterClaimed(quest.catalog(),quest.site(),character))continue;var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);if(active==null)continue;var player=plugin.getServer().getPlayer(active.player());if(player==null||!inside(player.getLocation(),value,arena))continue;if(quest.unitType()==AdventureQuestState.UnitType.PARTY){var party=parties.partyOf(player.getUniqueId()).orElse(null);if(party==null||!party.id().equals(quest.unit())||!party.characters().contains(character))continue;}result.add(character);}return result.stream().distinct().sorted().toList();}
private void award(AdventureQuestState.Quest quest,List<UUID> recipients,Attempt attempt){var unit=new RewardAllocator.Unit<UUID>(quest.unit(),recipients);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);if((attempt.type==AdventureQuestState.SiteType.CHAMPION||attempt.type==AdventureQuestState.SiteType.CAMP)&&fishing!=null){var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(UUID character:recipients)rewards.put(character,new FishingState.Reward(xp.get(character),coins.get(character)));UUID carrier=quest.unitType()==AdventureQuestState.UnitType.SOLO?quest.unit():recipients.stream().map(parties::partyOfCharacter).flatMap(Optional::stream).filter(value->value.id().equals(quest.unit())).map(PartyRoster.Party::leaderCharacter).findFirst().orElse(null);var site=directory.findSite(quest.site()).orElse(null);if(carrier==null||site==null){plugin.getLogger().warning("Champion trophy carrier unavailable for quest "+quest.id());return;}var trophyType=attempt.type==AdventureQuestState.SiteType.CHAMPION?FishingState.TrophyType.CHAMPION:FishingState.TrophyType.CAMP;UUID trophy=UUID.nameUUIDFromBytes((trophyType.name().toLowerCase(java.util.Locale.ROOT)+":"+quest.id()).getBytes(java.nio.charset.StandardCharsets.UTF_8));String trophyName=trophyType==FishingState.TrophyType.CHAMPION?"Daybreak Champion Head":"Spoils of "+site.displayName();var audit=new LinkedHashMap<String,String>();
@@ -191,12 +243,13 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void leash(PlayerLeashEntityEvent event){if(giver(event.getEntity())||managed(event.getEntity()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void teleport(EntityTeleportEvent event){if((giver(event.getEntity())||managed(event.getEntity()))&&!relocating.contains(event.getEntity()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.MONITOR)public void chunkLoad(org.bukkit.event.world.ChunkLoadEvent event){reconcile(event.getChunk());}
@EventHandler(priority=EventPriority.MONITOR)public void chunkUnload(org.bukkit.event.world.ChunkUnloadEvent event){for(var entity:event.getChunk().getEntities())if(enemy(entity)){UUID site=site(entity);var attempt=site==null?null:attempts.remove(site);if(attempt!=null){cleanup(attempt);readyAt.put(site,clock.instant());}}}
@EventHandler(priority=EventPriority.MONITOR)public void chunkUnload(org.bukkit.event.world.ChunkUnloadEvent event){for(var entity:event.getChunk().getEntities())if(enemy(entity)){UUID site=site(entity);var attempt=site==null?null:attempts.remove(site);if(attempt!=null){cleanup(attempt,"chunk_unload");readyAt.put(site,clock.instant());}}}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void giverDamage(EntityDamageEvent event){if(giver(event.getEntity()))event.setCancelled(true);}
private Player source(EntityDamageByEntityEvent event){if(event.getDamager() instanceof Player player)return player;if(event.getDamager() instanceof org.bukkit.entity.Projectile projectile&&projectile.getShooter() instanceof Player player)return player;return null;}
private UUID site(Entity entity){return parse(entity.getPersistentDataContainer().get(siteKey,PersistentDataType.STRING));}
private void syncBossBar(Attempt attempt){if(attempt.bossBar==null||attempt.champion==null)return;var viewers=eligibleInside(attempt.site,attempt.arena).collect(java.util.stream.Collectors.toSet());for(var player:plugin.getServer().getOnlinePlayers())if(viewers.contains(player))attempt.bossBar.addPlayer(player);else attempt.bossBar.removePlayer(player);double maximum=Objects.requireNonNull(attempt.champion.getAttribute(Attribute.MAX_HEALTH)).getValue();attempt.bossBar.setProgress(Math.max(0,Math.min(1,attempt.champion.getHealth()/maximum)));}
private void cleanup(Attempt attempt){if(attempt.bossBar!=null)attempt.bossBar.removeAll();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(enemy(entity)&&attempt.site.equals(site(entity)))entity.remove();attempt.entities.clear();}
private void cleanup(Attempt attempt){cleanup(attempt,"native_cleanup");}
private void cleanup(Attempt attempt,String reason){boolean cleaned=false;try{if(attempt.bossBar!=null)attempt.bossBar.removeAll();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(enemy(entity)&&attempt.site.equals(site(entity)))entity.remove();attempt.entities.clear();cleaned=true;}finally{auditAttempt("adventure.camp.reset",attempt,reason,cleaned);}}
private void refreshTrackingPlayers(){refreshTrackingPlayers(false);}
private void refreshTrackingPlayers(boolean actionBar){for(var value:plugin.getServer().getOnlinePlayers())refreshTracking(value,actionBar);}
private void refreshTracking(Player player){refreshTracking(player,false);}
@@ -208,5 +261,5 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
private static UUID parse(String value){try{return value==null?null:UUID.fromString(value);}catch(IllegalArgumentException invalid){return null;}}
private static UUID id(Entity entity,NamespacedKey key){return parse(entity.getPersistentDataContainer().get(key,PersistentDataType.STRING));}
private static <T>List<T> safe(Collection<T> values){return values==null?List.of():List.copyOf(values);}
@Override public void close(){for(var player:plugin.getServer().getOnlinePlayers()){sessions.active(player.getUniqueId()).filter(sessions::current).ifPresent(sessions::restoreBook);}closed=true;if(pulseTask!=null)pulseTask.cancel();for(var attempt:new ArrayList<>(attempts.values()))cleanup(attempt);attempts.clear();dyingOrigins.clear();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(giver(entity)||enemy(entity))entity.remove();}
@Override public void close(){for(var player:plugin.getServer().getOnlinePlayers()){sessions.active(player.getUniqueId()).filter(sessions::current).ifPresent(sessions::restoreBook);}closed=true;if(pulseTask!=null)pulseTask.cancel();for(var attempt:new ArrayList<>(attempts.values()))cleanup(attempt,"plugin_disable");attempts.clear();dyingOrigins.clear();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(giver(entity)||enemy(entity))entity.remove();}
}
@@ -17,7 +17,21 @@ public final class AdventureQuestDirectory implements AutoCloseable {
public enum FishingUpdateStatus { APPLIED, UNCHANGED, STALE, NOT_FOUND }
public enum RerollStatus { APPLIED, STALE }
public enum TrackingStatus { APPLIED, UNCHANGED, INELIGIBLE }
public record AcceptResult(Acceptance status,Optional<AdventureQuestState.Quest> quest) { }
public record CatalogPublication(AdventureQuestState.Catalog catalog,UUID operation,String reason,
AdventureQuestSettings.Rules settings,List<AdventureQuestState.Site> selected,
games.dmg.fantasystick.events.CampEventBridge.Actor actor,boolean originalCapture) {
public CatalogPublication{selected=List.copyOf(selected);}
}
private volatile java.util.function.Consumer<CatalogPublication> catalogObserver=ignored->{};
private CatalogPublication pendingCatalogPublication;
public void attachCatalogEvents(java.util.function.Consumer<CatalogPublication> observer){catalogObserver=Objects.requireNonNull(observer);}
public record QuestExpiry(UUID operation,String reason,AdventureQuestState.Quest quest,AdventureQuestState.Site site,
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);}
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());}
}
public record ModeResult(ModeStatus status,Optional<AdventureQuestState.Site> site) { }
public record FishingUpdateResult(FishingUpdateStatus status,Optional<AdventureQuestState.Site> site) { }
public record RerollResult(RerollStatus status,Optional<AdventureQuestState.Catalog> catalog) { }
@@ -41,25 +55,26 @@ 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);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?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);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());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<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<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);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);});}
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);});}
public CompletableFuture<Optional<AdventureQuestState.Giver>> removeGiver(UUID id){return mutate(state->{var found=state.givers().stream().filter(v->v.id().equals(id)).findFirst();if(found.isEmpty())return result(state,Optional.empty());return result(copy(state,state.sites(),state.givers().stream().filter(v->!v.id().equals(id)).toList(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),found);});}
public CompletableFuture<AdventureQuestState.Catalog> appendOffer(UUID site){return mutate(state->{if(state.reset()!=null||site(state,site).filter(v->v.type()==AdventureQuestState.SiteType.DUNGEON).isEmpty()||state.catalog()==null||!state.catalog().day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC))))throw new IllegalArgumentException("Dungeon offer unavailable");if(state.catalog().sites().contains(site)){if(!state.catalog().closed().contains(site))return result(state,state.catalog());var closed=new HashSet<>(state.catalog().closed());closed.remove(site);var reopened=new AdventureQuestState.Catalog(state.catalog().day(),state.catalog().revision(),state.catalog().createdAt(),state.catalog().sites(),closed);return result(copy(state,state.sites(),state.givers(),reopened,state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),reopened);}if(state.catalog().sites().size()>=256)throw new IllegalArgumentException("Adventure offer limit reached");var offers=new ArrayList<>(state.catalog().sites());offers.add(site);var catalog=new AdventureQuestState.Catalog(state.catalog().day(),state.catalog().revision(),state.catalog().createdAt(),offers,state.catalog().closed());return result(copy(state,state.sites(),state.givers(),catalog,state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),catalog);});}
public CompletableFuture<Boolean> closeOffer(UUID site){return mutate(state->{if(state.catalog()==null||!state.catalog().sites().contains(site)||state.catalog().closed().contains(site))return result(state,false);var closed=new HashSet<>(state.catalog().closed());closed.add(site);var catalog=new AdventureQuestState.Catalog(state.catalog().day(),state.catalog().revision(),state.catalog().createdAt(),state.catalog().sites(),closed);var quests=state.quests().stream().filter(value->!value.site().equals(site)).toList();return result(copy(state,state.sites(),state.givers(),catalog,quests,state.unitClaims(),state.characterClaims(),state.rosters()),true);});}
public CompletableFuture<AdventureQuestState.Catalog> ensureToday(){return mutate(state->{if(state.reset()!=null)return result(state,state.catalog());var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));if(state.catalog()!=null&&state.catalog().day().equals(today))return result(expire(state),state.catalog());var prior=state.catalog();var catalog=roll(state,today,prior==null?Set.of():Set.copyOf(prior.sites()));var reset=prior==null?null:new AdventureQuestState.Reset(UUID.randomUUID(),catalog.revision(),clock.instant());var next=copyReset(state,state.sites(),state.givers(),catalog,List.of(),Set.of(),Set.of(),Map.of(),reset);return result(next,catalog);});}
public CompletableFuture<Boolean> closeOffer(UUID site){return mutate("offer_closed",state->{if(state.catalog()==null||!state.catalog().sites().contains(site)||state.catalog().closed().contains(site))return result(state,false);var closed=new HashSet<>(state.catalog().closed());closed.add(site);var catalog=new AdventureQuestState.Catalog(state.catalog().day(),state.catalog().revision(),state.catalog().createdAt(),state.catalog().sites(),closed);var quests=state.quests().stream().filter(value->!value.site().equals(site)).toList();return result(copy(state,state.sites(),state.givers(),catalog,quests,state.unitClaims(),state.characterClaims(),state.rosters()),true);});}
public CompletableFuture<AdventureQuestState.Catalog> ensureToday(){return mutate(state->{if(state.reset()!=null)return result(state,state.catalog());var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));if(state.catalog()!=null&&state.catalog().day().equals(today))return result(expire(state),state.catalog());var prior=state.catalog();var rolled=roll(state,today,prior==null?Set.of():Set.copyOf(prior.sites()));var catalog=rolled.catalog();var reset=prior==null?null:new AdventureQuestState.Reset(UUID.randomUUID(),catalog.revision(),clock.instant());var next=copyReset(state,state.sites(),state.givers(),catalog,List.of(),Set.of(),Set.of(),Map.of(),reset);return new Result<>(next,catalog,new CatalogPublication(catalog,reset==null?catalog.revision():reset.operation(),prior==null?"initial_current_day":"utc_day_rollover",rolled.settings(),rolled.selected(),null,true));});}
public CompletableFuture<RerollResult> rerollToday(){var current=snapshot.catalog();return rerollToday(current==null?null:current.revision());}
public CompletableFuture<RerollResult> rerollToday(UUID expectedRevision){return mutate(original->{var state=expire(original);var current=state.catalog();UUID actual=current==null?null:current.revision();if(state.reset()!=null||!Objects.equals(expectedRevision,actual))return result(state,new RerollResult(RerollStatus.STALE,Optional.ofNullable(current)));var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));boolean sameDay=current!=null&&current.day().equals(today);var catalog=roll(state,today,sameDay?Set.copyOf(current.sites()):Set.of());var reset=new AdventureQuestState.Reset(UUID.randomUUID(),catalog.revision(),clock.instant());var next=copyReset(state,state.sites(),state.givers(),catalog,List.of(),Set.of(),Set.of(),Map.of(),reset);return result(next,new RerollResult(RerollStatus.APPLIED,Optional.of(catalog)));});}
public CompletableFuture<RerollResult> rerollToday(UUID expectedRevision){return rerollToday(expectedRevision,null);}
public CompletableFuture<RerollResult> rerollToday(UUID expectedRevision,games.dmg.fantasystick.events.CampEventBridge.Actor actor){return mutate(original->{var state=expire(original);var current=state.catalog();UUID actual=current==null?null:current.revision();if(state.reset()!=null||!Objects.equals(expectedRevision,actual))return result(state,new RerollResult(RerollStatus.STALE,Optional.ofNullable(current)));var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));boolean sameDay=current!=null&&current.day().equals(today);var rolled=roll(state,today,sameDay?Set.copyOf(current.sites()):Set.of());var catalog=rolled.catalog();var reset=new AdventureQuestState.Reset(UUID.randomUUID(),catalog.revision(),clock.instant());var next=copyReset(state,state.sites(),state.givers(),catalog,List.of(),Set.of(),Set.of(),Map.of(),reset);return new Result<>(next,new RerollResult(RerollStatus.APPLIED,Optional.of(catalog)),new CatalogPublication(catalog,reset.operation(),"admin_full_reroll",rolled.settings(),rolled.selected(),actor,true));});}
public CompletableFuture<Boolean> completeReset(UUID operation){return mutate(state->{if(state.reset()==null||!state.reset().operation().equals(operation))return result(state,false);return result(copyReset(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters(),null),true);});}
public CompletableFuture<AcceptResult> accept(UUID catalog,UUID site,AdventureQuestState.UnitType type,UUID unit,Set<UUID> members){var offered=findSite(site).orElse(null);return offered==null?CompletableFuture.completedFuture(new AcceptResult(Acceptance.UNAVAILABLE,Optional.empty())):accept(catalog,site,type,unit,members,offered.participationMode());}
public CompletableFuture<AcceptResult> accept(UUID catalog,UUID site,AdventureQuestState.UnitType type,UUID unit,Set<UUID> members,AdventureQuestState.ParticipationMode expectedMode){Objects.requireNonNull(expectedMode);var requestedMembers=Set.copyOf(members);return mutate(original->{var state=expire(original);var current=state.catalog();if(state.reset()!=null||current==null||!current.day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC)))||!current.revision().equals(catalog))return result(state,new AcceptResult(Acceptance.STALE,Optional.empty()));var offered=site(state,site).filter(this::available).orElse(null);if(!current.sites().contains(site)||current.closed().contains(site)||offered==null)return result(state,new AcceptResult(Acceptance.UNAVAILABLE,Optional.empty()));if(offered.participationMode()!=expectedMode)return result(state,new AcceptResult(Acceptance.STALE,Optional.empty()));if(!offered.participationMode().permits(type))return result(state,new AcceptResult(Acceptance.POLICY,Optional.empty()));boolean unitClaimed=state.unitClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.type()==type&&claim.unit().equals(unit));boolean characterClaimed=requestedMembers.stream().anyMatch(member->state.characterClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.character().equals(member)));if(unitClaimed||characterClaimed)return result(state,new AcceptResult(Acceptance.CLAIMED,Optional.empty()));boolean conflict=state.quests().stream().anyMatch(q->q.unit().equals(unit)||!Collections.disjoint(q.snapshot(),requestedMembers));if(conflict)return result(state,new AcceptResult(Acceptance.CONFLICT,Optional.empty()));var reward=reward(offered);var quest=new AdventureQuestState.Quest(UUID.randomUUID(),catalog,site,type,unit,requestedMembers,current.expiresAt(),reward.xp(),reward.coins(),offered.participationMode(),offered.fishing()==null?null:offered.fishing().terms());var quests=new ArrayList<>(state.quests());quests.add(quest);var tracking=new HashSet<>(state.tracking());for(UUID character:requestedMembers)tracking.add(new AdventureQuestState.Tracking(quest.id(),character,true));return result(copyTracking(state,state.sites(),state.givers(),current,List.copyOf(quests),state.unitClaims(),state.characterClaims(),state.rosters(),tracking),new AcceptResult(Acceptance.APPLIED,Optional.of(quest)));});}
public CompletableFuture<Integer> expireOfferQuests(UUID site){return mutate(state->{var quests=state.quests().stream().filter(value->!value.site().equals(site)).toList();int removed=state.quests().size()-quests.size();return removed==0?result(state,0):result(copy(state,state.sites(),state.givers(),state.catalog(),quests,state.unitClaims(),state.characterClaims(),state.rosters()),removed);});}
public CompletableFuture<Boolean> abandon(UUID quest,UUID unit){return mutate(state->{var found=state.quests().stream().filter(q->q.id().equals(quest)&&q.unit().equals(unit)).findFirst();if(found.isEmpty())return result(state,false);return result(copy(state,state.sites(),state.givers(),state.catalog(),state.quests().stream().filter(q->!q.id().equals(quest)).toList(),state.unitClaims(),state.characterClaims(),state.rosters()),true);});}
public CompletableFuture<AcceptResult> accept(UUID catalog,UUID site,AdventureQuestState.UnitType type,UUID unit,Set<UUID> members,AdventureQuestState.ParticipationMode expectedMode){Objects.requireNonNull(expectedMode);var requestedMembers=Set.copyOf(members);return mutate(original->{var state=expire(original);var current=state.catalog();if(state.reset()!=null||current==null||!current.day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC)))||!current.revision().equals(catalog))return result(state,new AcceptResult(Acceptance.STALE,Optional.empty()));var offered=site(state,site).filter(this::available).orElse(null);if(!current.sites().contains(site)||current.closed().contains(site)||offered==null)return result(state,new AcceptResult(Acceptance.UNAVAILABLE,Optional.empty()));if(offered.participationMode()!=expectedMode)return result(state,new AcceptResult(Acceptance.STALE,Optional.empty()));if(!offered.participationMode().permits(type))return result(state,new AcceptResult(Acceptance.POLICY,Optional.empty()));boolean unitClaimed=state.unitClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.type()==type&&claim.unit().equals(unit));boolean characterClaimed=requestedMembers.stream().anyMatch(member->state.characterClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.character().equals(member)));if(unitClaimed||characterClaimed)return result(state,new AcceptResult(Acceptance.CLAIMED,Optional.empty()));boolean conflict=state.quests().stream().anyMatch(q->q.unit().equals(unit)||!Collections.disjoint(q.snapshot(),requestedMembers));if(conflict)return result(state,new AcceptResult(Acceptance.CONFLICT,Optional.empty()));var reward=reward(offered);var quest=new AdventureQuestState.Quest(UUID.randomUUID(),catalog,site,type,unit,requestedMembers,current.expiresAt(),reward.xp(),reward.coins(),offered.participationMode(),offered.fishing()==null?null:offered.fishing().terms());var quests=new ArrayList<>(state.quests());quests.add(quest);var tracking=new HashSet<>(state.tracking());for(UUID character:requestedMembers)tracking.add(new AdventureQuestState.Tracking(quest.id(),character,true));return result(copyTracking(state,state.sites(),state.givers(),current,List.copyOf(quests),state.unitClaims(),state.characterClaims(),state.rosters(),tracking),new AcceptResult(Acceptance.APPLIED,Optional.of(quest),Optional.of(offered)));});}
public CompletableFuture<Integer> expireOfferQuests(UUID site){return mutate("offer_expired",state->{var quests=state.quests().stream().filter(value->!value.site().equals(site)).toList();int removed=state.quests().size()-quests.size();return removed==0?result(state,0):result(copy(state,state.sites(),state.givers(),state.catalog(),quests,state.unitClaims(),state.characterClaims(),state.rosters()),removed);});}
public CompletableFuture<Boolean> abandon(UUID quest,UUID unit){return mutate("player_abandoned",state->{var found=state.quests().stream().filter(q->q.id().equals(quest)&&q.unit().equals(unit)).findFirst();if(found.isEmpty())return result(state,false);return result(copy(state,state.sites(),state.givers(),state.catalog(),state.quests().stream().filter(q->!q.id().equals(quest)).toList(),state.unitClaims(),state.characterClaims(),state.rosters()),true);});}
public CompletableFuture<List<EntityType>> roster(UUID site){return mutate(state->{state=expire(state);if(state.reset()!=null||state.catalog()==null||!state.catalog().day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC)))||!state.catalog().sites().contains(site)||find(state.sites(),site).filter(v->v.type()==AdventureQuestState.SiteType.CAMP).isEmpty())throw new IllegalArgumentException("Unavailable camp");var existing=state.rosters().get(site);if(existing!=null)return result(state,existing);var rolled=CampRoster.roll(settings.get().campEnemies(),random);var rosters=new HashMap<>(state.rosters());rosters.put(site,rolled);return result(copy(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),rosters),rolled);});}
public CompletableFuture<Boolean> complete(UUID questId,Set<UUID> recipients){return mutate(state->{var quest=state.quests().stream().filter(q->q.id().equals(questId)).findFirst().orElse(null);if(quest==null)return result(state,false);var units=new HashSet<>(state.unitClaims());units.add(new AdventureQuestState.UnitClaim(quest.catalog(),quest.site(),quest.unitType(),quest.unit()));var characters=new HashSet<>(state.characterClaims());for(UUID recipient:recipients)characters.add(new AdventureQuestState.CharacterClaim(quest.catalog(),quest.site(),recipient));return result(copy(state,state.sites(),state.givers(),state.catalog(),state.quests().stream().filter(q->!q.id().equals(questId)).toList(),units,characters,state.rosters()),true);});}
public List<AdventureQuestState.Quest> active(){return expireView(snapshot).quests();}
@@ -78,10 +93,11 @@ public final class AdventureQuestDirectory implements AutoCloseable {
public CompletableFuture<Optional<AdventureQuestState.RewardClaim>> beginLegacyClaim(UUID character){return mutate(original->{var state=expire(original);var pending=state.rewards().operations().stream().filter(value->value.character().equals(character)).findFirst();if(pending.isPresent())return result(state,pending);var allocations=state.rewards().allocations().stream().filter(value->value.character().equals(character)).sorted(Comparator.comparing(AdventureQuestState.RewardAllocation::id)).toList();if(allocations.isEmpty())return result(state,Optional.empty());BigInteger xp=allocations.stream().map(AdventureQuestState.RewardAllocation::xp).reduce(BigInteger.ZERO,BigInteger::add),coins=allocations.stream().map(AdventureQuestState.RewardAllocation::coins).reduce(BigInteger.ZERO,BigInteger::add);var ids=allocations.stream().map(AdventureQuestState.RewardAllocation::id).collect(java.util.stream.Collectors.toSet());var first=allocations.getFirst();UUID operation=UUID.nameUUIDFromBytes((character+":"+ids.stream().sorted().map(UUID::toString).collect(java.util.stream.Collectors.joining(","))).getBytes(java.nio.charset.StandardCharsets.UTF_8));var claim=new AdventureQuestState.RewardClaim(operation,first.catalog(),first.site(),character,ids,xp,coins);var operations=new ArrayList<>(state.rewards().operations());operations.add(claim);var ledger=new AdventureQuestState.RewardLedger(state.rewards().allocations(),operations,state.rewards().receipts());return result(copyRewards(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters(),ledger),Optional.of(claim));});}
public CompletableFuture<Optional<AdventureQuestState.RewardClaim>> beginClaim(UUID catalog,UUID site,UUID character){return mutate(original->{var state=expire(original);var current=state.catalog();if(current==null||!current.revision().equals(catalog)||!current.sites().contains(site)||!clock.instant().isBefore(current.expiresAt()))return result(state,Optional.empty());var pending=state.rewards().operations().stream().filter(value->value.catalog().equals(catalog)&&value.site().equals(site)&&value.character().equals(character)).findFirst();if(pending.isPresent())return result(state,pending);var allocations=state.rewards().allocations().stream().filter(value->value.catalog().equals(catalog)&&value.site().equals(site)&&value.character().equals(character)).toList();if(allocations.isEmpty())return result(state,Optional.empty());BigInteger xp=allocations.stream().map(AdventureQuestState.RewardAllocation::xp).reduce(BigInteger.ZERO,BigInteger::add),coins=allocations.stream().map(AdventureQuestState.RewardAllocation::coins).reduce(BigInteger.ZERO,BigInteger::add);var ids=allocations.stream().map(AdventureQuestState.RewardAllocation::id).collect(java.util.stream.Collectors.toSet());UUID operation=UUID.nameUUIDFromBytes((catalog+":"+site+":"+character+":"+ids.stream().sorted().map(UUID::toString).collect(java.util.stream.Collectors.joining(","))).getBytes(java.nio.charset.StandardCharsets.UTF_8));var claim=new AdventureQuestState.RewardClaim(operation,catalog,site,character,ids,xp,coins);var operations=new ArrayList<>(state.rewards().operations());operations.add(claim);var ledger=new AdventureQuestState.RewardLedger(state.rewards().allocations(),operations,state.rewards().receipts());return result(copyRewards(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters(),ledger),Optional.of(claim));});}
public CompletableFuture<Boolean> finishClaim(UUID operation){return mutate(state->{var claim=state.rewards().operations().stream().filter(value->value.id().equals(operation)).findFirst().orElse(null);if(claim==null)return result(state,false);var allocations=state.rewards().allocations().stream().filter(value->!claim.allocations().contains(value.id())).toList();var operations=state.rewards().operations().stream().filter(value->!value.id().equals(operation)).toList();var receipts=new HashSet<>(state.rewards().receipts());receipts.add(new AdventureQuestState.RewardReceipt(claim.catalog(),claim.site(),claim.character()));var ledger=new AdventureQuestState.RewardLedger(allocations,operations,receipts);return result(copyRewards(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters(),ledger),true);});}
public CompletableFuture<Integer> expireParties(java.util.function.Predicate<UUID> exists){return mutate(state->{var quests=state.quests().stream().filter(q->q.unitType()!=AdventureQuestState.UnitType.PARTY||exists.test(q.unit())).toList();int removed=state.quests().size()-quests.size();return removed==0?result(state,0):result(copy(state,state.sites(),state.givers(),state.catalog(),quests,state.unitClaims(),state.characterClaims(),state.rosters()),removed);});}
public CompletableFuture<Integer> expireParties(java.util.function.Predicate<UUID> exists){return mutate("party_invalidated",state->{var quests=state.quests().stream().filter(q->q.unitType()!=AdventureQuestState.UnitType.PARTY||exists.test(q.unit())).toList();int removed=state.quests().size()-quests.size();return removed==0?result(state,0):result(copy(state,state.sites(),state.givers(),state.catalog(),quests,state.unitClaims(),state.characterClaims(),state.rosters()),removed);});}
private AdventureQuestState expire(AdventureQuestState state){var view=expireView(state);return view.equals(state)?state:view;}
private AdventureQuestState expireView(AdventureQuestState state){if(state.catalog()==null||clock.instant().isBefore(state.catalog().expiresAt()))return state;return new AdventureQuestState(state.sites(),state.givers(),state.catalog(),List.of(),state.unitClaims(),state.characterClaims(),state.rosters(),Set.of(),pendingRewards(state.rewards()),state.reset());}
private AdventureQuestState.Catalog roll(AdventureQuestState state,LocalDate day,Set<UUID> previous){var candidates=candidateSites(state);var alternatives=candidates.stream().map(AdventureQuestState.Site::id).filter(id->!previous.contains(id)).collect(java.util.stream.Collectors.toCollection(ArrayList::new));var old=candidates.stream().map(AdventureQuestState.Site::id).filter(previous::contains).collect(java.util.stream.Collectors.toCollection(ArrayList::new));shuffle(alternatives);shuffle(old);int count=Math.min(settings.get().dailySiteCount(),candidates.size());var selected=new ArrayList<UUID>();selected.addAll(alternatives.subList(0,Math.min(count,alternatives.size())));if(selected.size()<count)selected.addAll(old.subList(0,count-selected.size()));return new AdventureQuestState.Catalog(day,UUID.randomUUID(),clock.instant(),selected);}
private record Rolled(AdventureQuestState.Catalog catalog,AdventureQuestSettings.Rules settings,List<AdventureQuestState.Site> selected){}
private Rolled roll(AdventureQuestState state,LocalDate day,Set<UUID> previous){var candidates=candidateSites(state);var alternatives=candidates.stream().map(AdventureQuestState.Site::id).filter(id->!previous.contains(id)).collect(java.util.stream.Collectors.toCollection(ArrayList::new));var old=candidates.stream().map(AdventureQuestState.Site::id).filter(previous::contains).collect(java.util.stream.Collectors.toCollection(ArrayList::new));shuffle(alternatives);shuffle(old);var capturedSettings=settings.get();int count=Math.min(capturedSettings.dailySiteCount(),candidates.size());var selected=new ArrayList<UUID>();selected.addAll(alternatives.subList(0,Math.min(count,alternatives.size())));if(selected.size()<count)selected.addAll(old.subList(0,count-selected.size()));return new Rolled(new AdventureQuestState.Catalog(day,UUID.randomUUID(),clock.instant(),selected),capturedSettings,candidates.stream().filter(site->selected.contains(site.id())).toList());}
private void shuffle(List<UUID> values){for(int i=values.size()-1;i>0;i--){int j=random.nextInt(i+1);var v=values.get(i);values.set(i,values.get(j));values.set(j,v);}}
private List<AdventureQuestState.Site> candidateSites(AdventureQuestState state){var values=new LinkedHashMap<UUID,AdventureQuestState.Site>();state.sites().stream().filter(value->value.type()!=AdventureQuestState.SiteType.DUNGEON).forEach(value->values.put(value.id(),value));dungeonSites.get().forEach(value->values.put(value.id(),value));return List.copyOf(values.values());}
private Optional<AdventureQuestState.Site> site(AdventureQuestState state,UUID id){var stored=find(state.sites(),id);return stored.isPresent()?stored:dungeonSites.get().stream().filter(value->value.id().equals(id)).findFirst();}
@@ -93,9 +109,43 @@ public final class AdventureQuestDirectory implements AutoCloseable {
private static AdventureQuestState.RewardLedger pendingRewards(AdventureQuestState.RewardLedger rewards){return rewards;}
private static Set<AdventureQuestState.Tracking> validTracking(Set<AdventureQuestState.Tracking> tracking,List<AdventureQuestState.Quest> quests){var active=quests.stream().collect(java.util.stream.Collectors.toMap(AdventureQuestState.Quest::id,value->value));return tracking.stream().filter(value->{var quest=active.get(value.quest());return quest!=null&&quest.snapshot().contains(value.character());}).collect(java.util.stream.Collectors.toUnmodifiableSet());}
private static UUID rewardId(AdventureQuestState.Quest quest,UUID source,UUID character){return UUID.nameUUIDFromBytes((quest.catalog()+":"+quest.site()+":"+quest.id()+":"+source+":"+character).getBytes(java.nio.charset.StandardCharsets.UTF_8));}
private record Result<T>(AdventureQuestState state,T value){}
private static <T>Result<T> result(AdventureQuestState state,T value){return new Result<>(state,value);}
private <T>CompletableFuture<T> mutate(java.util.function.Function<AdventureQuestState,Result<T>> operation){var future=new CompletableFuture<T>();synchronized(this){if(closed){future.completeExceptionally(new IOException("Adventure directory closed"));return future;}pending++;}worker.execute(()->{try{var result=operation.apply(snapshot);if(result.state()!=snapshot){repository.save(result.state());snapshot=result.state();}future.complete(result.value());}catch(Throwable failure){future.completeExceptionally(failure);}finally{finished();}});return future;}
private record Result<T>(AdventureQuestState state,T value,CatalogPublication publication){}
private static <T>Result<T> result(AdventureQuestState state,T value){return new Result<>(state,value,null);}
private void observeCatalogCommit(AdventureQuestState before,Result<?> result){
try{
var after=result.state();
if(result.publication()!=null)pendingCatalogPublication=result.publication();
if(after.reset()!=null)return;
if(pendingCatalogPublication==null&&before.reset()!=null&&after.catalog()!=null){
pendingCatalogPublication=new CatalogPublication(after.catalog(),before.reset().operation(),"recovery_pending_publication",null,
candidateSites(after).stream().filter(site->after.catalog().sites().contains(site.id())).toList(),null,false);
}
if(pendingCatalogPublication!=null&&after.catalog()!=null&&pendingCatalogPublication.catalog().revision().equals(after.catalog().revision())){
var publication=pendingCatalogPublication;pendingCatalogPublication=null;catalogObserver.accept(publication);
}
}catch(RuntimeException ignored){/* Metadata observation cannot change durable catalog authority. */}
}
private void observeQuestCommit(AdventureQuestState before,Result<?> result,String reason,UUID operation){
try{
if(reason.equals("player_abandoned"))return;
var after=result.state();var remaining=after.quests().stream().map(AdventureQuestState.Quest::id).collect(java.util.stream.Collectors.toSet());
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;
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,
publication==null?null:publication.actor()));
}
}catch(RuntimeException ignored){/* Removed authority is not reversible by audit metadata. */}
}
private <T>CompletableFuture<T> mutate(java.util.function.Function<AdventureQuestState,Result<T>> operation){return mutate("authoritative_state_transition",operation);}
private <T>CompletableFuture<T> mutate(String reason,java.util.function.Function<AdventureQuestState,Result<T>> operation){var future=new CompletableFuture<T>();UUID observation=UUID.randomUUID();synchronized(this){if(closed){future.completeExceptionally(new IOException("Adventure directory closed"));return future;}pending++;}worker.execute(()->{try{var before=snapshot;var result=operation.apply(before);if(result.state()!=before){repository.save(result.state());snapshot=result.state();observeQuestCommit(before,result,reason,observation);observeCatalogCommit(before,result);}future.complete(result.value());}catch(Throwable failure){future.completeExceptionally(failure);}finally{finished();}});return future;}
private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}
public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}
@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
@@ -41,16 +41,18 @@ public record AdventureQuestState(List<Site> sites,List<Giver> givers,Catalog ca
public boolean contains(double xOffset,double zOffset){return shape==ArenaShape.CIRCLE?xOffset*xOffset+zOffset*zOffset<=xBlocks*(double)xBlocks:Math.abs(xOffset)<=xBlocks/2d&&Math.abs(zOffset)<=zBlocks/2d;}
public String display(){return shape==ArenaShape.CIRCLE?"circle radius="+xBlocks:"rectangle x="+xBlocks+" z="+zBlocks;}
}
public record Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z,Arena arena,ParticipationMode participationMode,FishingDefinition fishing) {
public record Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z,Arena arena,ParticipationMode participationMode,FishingDefinition fishing,UUID registrationRevision) {
public Site { Objects.requireNonNull(id);Objects.requireNonNull(type);name=validName(name);Objects.requireNonNull(world);finite(x,y,z);Objects.requireNonNull(participationMode);if((type==SiteType.FISHING)!=(fishing!=null)||fishing!=null&&(!Arena.circle(fishing.radius()).equals(arena)))throw new IllegalArgumentException("Invalid fishing site"); }
public Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z,Arena arena,ParticipationMode participationMode,FishingDefinition fishing){this(id,type,name,world,x,y,z,arena,participationMode,fishing,null);}
public Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z,Arena arena,ParticipationMode participationMode){this(id,type,name,world,x,y,z,arena,participationMode,null);}
public Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z,Arena arena){this(id,type,name,world,x,y,z,arena,ParticipationMode.PARTY_AVAILABLE,null);}
public Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z){this(id,type,name,world,x,y,z,null,ParticipationMode.PARTY_AVAILABLE,null);}
public String displayName(){return name.isBlank()?switch(type){case CAMP->"Monster Camp";case CHAMPION->"Champion Site";case DUNGEON->"Dungeon";case FISHING->"Fishing Site";}:name;}
public Arena arenaOrDefault(int radius){return arena==null?Arena.circle(radius):arena;}
}
public record Giver(UUID id,String name,UUID world,double x,double y,double z,float yaw,float pitch) {
public record Giver(UUID id,String name,UUID world,double x,double y,double z,float yaw,float pitch,UUID registrationRevision) {
public Giver { Objects.requireNonNull(id);name=validName(name);Objects.requireNonNull(world);finite(x,y,z);if(!Float.isFinite(yaw)||!Float.isFinite(pitch))throw new IllegalArgumentException("Invalid facing"); }
public Giver(UUID id,String name,UUID world,double x,double y,double z,float yaw,float pitch){this(id,name,world,x,y,z,yaw,pitch,null);}
public String displayName(){return name.isBlank()?"Quest Giver":name;}
}
public record Catalog(LocalDate day,UUID revision,Instant createdAt,List<UUID> sites,Set<UUID> closed) {
@@ -0,0 +1,297 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.adventure.AdventureQuestState;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Clock;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
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;
import org.bukkit.Location;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
/** Immutable camp-owner observations. Audit availability and replay never authorize gameplay. */
public final class CampEventBridge implements AutoCloseable {
public record Actor(EventInput.Context context, Map<String,Object> captured) {
public Actor { captured=Map.copyOf(captured); }
}
private final FeatureEventOutbox outbox;
private final EquipmentSessions sessions;
private final Clock clock=Clock.systemUTC();
public CampEventBridge(Function<EventInput,CompletionStage<LocalEventLog.Delivery>> sink,
Path data,EquipmentSessions sessions,Consumer<Throwable> failures) {
this.sessions=java.util.Objects.requireNonNull(sessions);
outbox=new FeatureEventOutbox(data,"camp-event-intents",Set.of("adventure.site.registered","adventure.site.mode_changed","adventure.site.removed","adventure.quest_giver.placed","adventure.quest_giver.removed","adventure.catalog_refreshed","adventure.quest.accepted","adventure.quest.abandoned","adventure.quest.expired","adventure.camp.started","adventure.camp.cleared","adventure.camp.reset","config.read","config.changed"),sink,failures);
}
/** Capture independent request facts on the calling server thread, not on the persistence worker. */
public Actor actor(CommandSender sender,Location input) {
var values=new LinkedHashMap<String,Object>();
values.put("actor_kind",sender instanceof Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console");
if(!(sender instanceof Player player))return new Actor(null,values);
UUID id=null,character=null,session=null; String name=null,characterName=null;
EventInput.Location location=null;
try{id=player.getUniqueId();if(id!=null)values.put("request_player_id",id.toString());}catch(RuntimeException ignored){}
try{name=player.getName();if(name!=null)values.put("request_player_name",name);}catch(RuntimeException ignored){}
try{
var active=id==null?null:sessions.active(id).filter(sessions::current).orElse(null);
var record=active==null?null:sessions.currentRecord(active).orElse(null);
if(record!=null){character=record.id();characterName=record.name();session=active.session();
values.put("request_character_id",character.toString());values.put("request_session_id",session.toString());}
}catch(RuntimeException ignored){}
try{
var at=input==null?player.getLocation():input;var world=at.getWorld();
if(world!=null){
values.put("actor_location",Map.of("world_id",world.getUID().toString(),"x",at.getX(),"y",at.getY(),"z",at.getZ()));
location=new EventInput.Location(world.getUID(),world.getName(),at.getX(),at.getY(),at.getZ(),clock.instant(),"native");
}
}catch(RuntimeException ignored){}
EventInput.Context context=null;
try{context=new EventInput.Context(id,name,character,characterName,session,location);}catch(RuntimeException ignored){}
values.put("actor_capture_quality",context==null?"unavailable":"native");
if(!values.containsKey("actor_location"))values.put("actor_location_capture_quality","unavailable");
return new Actor(context,values);
}
public void siteRegistered(UUID operation,Actor actor,AdventureQuestState.Site site,AdventureQuestState.Arena arena) {
if(site.type()!=AdventureQuestState.SiteType.CAMP)return;
try{
var data=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){/* Cannot undo acknowledged registration. */}
}
private 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_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());
data.put("world_id",site.world().toString());data.put("x",site.x());data.put("y",site.y());data.put("z",site.z());
data.put("arena",Map.of("shape",arena.shape().name().toLowerCase(java.util.Locale.ROOT),"x_blocks",arena.xBlocks(),"z_blocks",arena.zBlocks()));
data.put("arena_definition",site.arena()==null?"effective_legacy_fallback":"persisted");
return data;
}
public void siteModeChanged(UUID operation,Actor actor,AdventureQuestState.Site requestedBefore,
AdventureQuestState.Site acknowledged,AdventureQuestState.Arena arena) {
try{
if(acknowledged.type()!=AdventureQuestState.SiteType.CAMP)return;
var data=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){/* Never alter mode publication. */}
}
public void siteRemoved(UUID operation,Actor actor,AdventureQuestState.Site removed,
AdventureQuestState.Arena arena,AdventureQuestState.Catalog catalog) {
try{
if(removed.type()!=AdventureQuestState.SiteType.CAMP)return;
var data=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){/* Never alter removal. */}
}
public void giverMutation(String type,UUID operation,Actor actor,AdventureQuestState.Giver giver,
games.dmg.fantasystick.adventure.AdventureQuestController.ObservedGiver before,
games.dmg.fantasystick.adventure.AdventureQuestController.ObservedGiver after,boolean nativeReturned) {
try{
var data=new LinkedHashMap<String,Object>();
data.put("giver_id",giver.id().toString());data.put("giver_name",giver.name());data.put("giver_display_name",giver.displayName());
data.put("revision_capture_quality",giver.registrationRevision()==null?"unavailable":"persisted");
if(giver.registrationRevision()!=null)data.put("giver_revision",giver.registrationRevision().toString());
data.put("world_id",giver.world().toString());data.put("x",giver.x());data.put("y",giver.y());data.put("z",giver.z());
data.put("yaw",(double)giver.yaw());data.put("pitch",(double)giver.pitch());
boolean placing=type.equals("adventure.quest_giver.placed");var observed=placing?after:before;
data.put("entity_ids",observed.ids().stream().map(UUID::toString).toList());
if(observed.ids().size()==1)data.put("entity_id",observed.ids().getFirst().toString());
data.put("chunk_status",observed.chunkStatus());data.put("entity_capture_quality",observed.quality());
data.put("entity_ids_after",after.ids().stream().map(UUID::toString).toList());
String outcome=!nativeReturned?"failed":!"loaded".equals(after.chunkStatus())?"not_loaded":!"native".equals(after.quality())?"unavailable":
placing?(after.ids().size()==1?"materialized":"not_materialized"):
("native".equals(before.quality())&&after.ids().isEmpty()?"removed":"not_confirmed_removed");
data.put("entity_outcome",outcome);data.put("catalog_impact","unchanged");data.put("active_quest_impact","unchanged");
accepted(type,operation,actor,data);
}catch(RuntimeException ignored){/* Registration acknowledgement and native outcomes remain separate. */}
}
public static boolean ownsSetting(String key){return switch(key){
case games.dmg.fantasystick.persistence.AdventureQuestSettings.DAILY_COUNT,games.dmg.fantasystick.persistence.AdventureQuestSettings.RADIUS,
games.dmg.fantasystick.persistence.AdventureQuestSettings.RESET_SECONDS,games.dmg.fantasystick.persistence.AdventureQuestSettings.CAMP_ENEMIES,
games.dmg.fantasystick.persistence.AdventureQuestSettings.CAMP_REWARD_XP,games.dmg.fantasystick.persistence.AdventureQuestSettings.CAMP_REWARD_COINS->true;
default->false;};}
private static String settingScope(String key){return switch(key){
case games.dmg.fantasystick.persistence.AdventureQuestSettings.DAILY_COUNT->"next_catalog";
case games.dmg.fantasystick.persistence.AdventureQuestSettings.RADIUS->"new_sites_and_future_legacy_geometry_attempts";
case games.dmg.fantasystick.persistence.AdventureQuestSettings.RESET_SECONDS->"future_attempts";
case games.dmg.fantasystick.persistence.AdventureQuestSettings.CAMP_ENEMIES->"future_uncaptured_catalog_day_rosters";
case games.dmg.fantasystick.persistence.AdventureQuestSettings.CAMP_REWARD_XP,games.dmg.fantasystick.persistence.AdventureQuestSettings.CAMP_REWARD_COINS->"future_acceptances";
default->throw new IllegalArgumentException("Not a camp/catalog setting");};}
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","camp_catalog");data.put("application_scope",settingScope(key));return data;}
public void configRead(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(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 is never rolled back or duplicated by audit. */}
}
public record CampAttempt(UUID id,UUID catalog,AdventureQuestState.Site site,AdventureQuestState.Arena arena,int resetSeconds,
List<String> roster,List<Map<String,Object>> entities,List<Map<String,Object>> representedUnits,
int liveEntities,UUID finalEnemy,Map<String,Object> finalLocation,java.time.Instant defeatedAt,boolean currentGeneration,Map<String,Object> nativePresence){
public CampAttempt{roster=List.copyOf(roster);entities=entities.stream().map(Map::copyOf).toList();representedUnits=representedUnits.stream().map(Map::copyOf).toList();finalLocation=Map.copyOf(finalLocation);nativePresence=Map.copyOf(nativePresence);}
}
public void attemptTransition(String type,CampAttempt attempt,String reason,boolean succeeded){
try{
var site=attempt.site();if(site.type()!=AdventureQuestState.SiteType.CAMP)return;
var data=new LinkedHashMap<String,Object>();data.put("actor_kind","system");data.put("site_id",site.id().toString());data.put("site_kind","camp");
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","acknowledged_catalog_day_roster");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);
String nativeOutcome=succeeded?"succeeded":"failed";
String outcome=succeeded?"native_observed":"native_failed";
if(type.equals("adventure.camp.started")){
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()==attempt.roster().size()
&&attempt.entities().size()==attempt.roster().size()?"succeeded":"not_materialized":
"not_loaded".equals(status)?"not_loaded":"unavailable";
if(!"succeeded".equals(nativeOutcome))outcome="native_"+nativeOutcome;}
}
if(type.equals("adventure.camp.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.roster().size(),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;
var roster=new ArrayList<Map<String,Object>>();for(int index=from;index<Math.min(from+20,attempt.roster().size());index++)
roster.add(Map.of("roster_index",index,"entity_type",attempt.roster().get(index)));
values.put("roster_entries",roster);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){/* Immutable observations have no native/gameplay authority. */}
}
private static <T>List<T> slice(List<T> values,int from){return from>=values.size()?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,Actor actor,AdventureQuestState.Quest quest,
AdventureQuestState.Site site,UUID acceptingLeader,String reason){
try{
if(site==null||site.type()!=AdventureQuestState.SiteType.CAMP)return;
var data=new LinkedHashMap<String,Object>();data.put("site_kind","camp");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());
boolean accepting=type.equals("adventure.quest.accepted");
data.put("accepting_actor_capture_quality",accepting?"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){/* Accepted authority and abandonment never depend on audit. */}
}
public void catalogPublished(games.dmg.fantasystick.adventure.AdventureQuestDirectory.CatalogPublication publication){
try{
var catalog=publication.catalog();var data=new LinkedHashMap<String,Object>();
data.put("catalog_id",catalog.revision().toString());data.put("catalog_revision",catalog.revision().toString());
data.put("utc_day",catalog.day().toString());data.put("created_at",catalog.createdAt().toString());data.put("next_midnight",catalog.expiresAt().toString());
data.put("reason",publication.reason());data.put("publication_boundary","complete_durable_catalog");
data.put("settings_capture_quality",publication.originalCapture()?"captured_roll":"unavailable_legacy_capture");
if(publication.settings()!=null){var rules=publication.settings();
data.put("offer_settings",Map.of("adventure-daily-site-count",rules.dailySiteCount(),"adventure-site-radius",rules.radius(),
"adventure-site-reset-seconds",rules.resetSeconds(),"adventure-camp-enemies",rules.campEnemies(),
"adventure-camp-reward-xp",rules.campRewardXp().toString(),"adventure-camp-reward-coins",rules.campRewardCoins().toString()));
}
var selected=new java.util.ArrayList<Map<String,Object>>();
for(var id:catalog.sites()){
var site=publication.selected().stream().filter(value->value.id().equals(id)).findFirst().orElse(null);
var row=new LinkedHashMap<String,Object>();row.put("site_id",id.toString());
if(site!=null&&publication.originalCapture()){
row.put("site_kind",site.type().name().toLowerCase(java.util.Locale.ROOT));row.put("site_name",site.name());
row.put("mode",site.participationMode().value());row.put("definition_capture_quality","captured_roll");
row.put("revision_capture_quality",site.registrationRevision()==null?"unavailable":"persisted");
if(site.registrationRevision()!=null)row.put("site_revision",site.registrationRevision().toString());
row.put("world_id",site.world().toString());row.put("x",site.x());row.put("y",site.y());row.put("z",site.z());
}else row.put("definition_capture_quality","unavailable_legacy_capture");
selected.add(Map.copyOf(row));
}
data.put("selected_total",selected.size());
int count=Math.max(1,(selected.size()+19)/20);
for(int part=0;part<count;part++){
var values=new LinkedHashMap<String,Object>(data);int from=part*20,to=Math.min(from+20,selected.size());
values.put("selected_sites",selected.subList(from,to));
if(count>1){values.put("part_index",part);values.put("part_count",count);}
accepted("adventure.catalog_refreshed",publication.operation(),publication.actor(),values,count==1?-1:part);
}
}catch(RuntimeException ignored){/* Catalog identity and sampled authority cannot be rerolled by audit. */}
}
private void accepted(String type,UUID operation,Actor actor,Map<String,Object> input){accepted(type,operation,actor,input,-1);}
private void accepted(String type,UUID operation,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");
UUID id=UUID.nameUUIDFromBytes(("fantasystick:camp:"+operation+":"+type+(type.startsWith("adventure.quest.")?":quest:"+input.get("quest_id"):"")+(part<0?"":":part:"+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();}
}
@@ -21,8 +21,8 @@ public final class YamlAdventureQuestRepository {
if(Files.notExists(file))return AdventureQuestState.empty();
try{
Object decoded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));var root=map(decoded);int schema=number(root.get("schema-version"));if(schema!=1&&schema!=2)throw new IllegalArgumentException();legacyDungeonRewardTerms=schema==1;
var sites=new ArrayList<AdventureQuestState.Site>();for(Object item:list(root.get("sites"))){var r=map(item);AdventureQuestState.Arena arena=null;if(r.containsKey("arena-shape")){var shape=AdventureQuestState.ArenaShape.valueOf(text(r.get("arena-shape")));int x=number(r.get("arena-x")),z=number(r.get("arena-z"));arena=new AdventureQuestState.Arena(shape,x,z);}AdventureQuestState.FishingDefinition fishing=null;if(r.get("fishing")!=null){var f=map(r.get("fishing"));fishing=new AdventureQuestState.FishingDefinition(uuid(f,"item-id"),text(f.get("item-name")),new BigInteger(text(f.get("required"))),new java.math.BigDecimal(text(f.get("chance-percent"))),new BigInteger(text(f.get("reward-xp"))),new BigInteger(text(f.get("reward-coins"))),number(f.get("radius")));}sites.add(new AdventureQuestState.Site(uuid(r,"id"),AdventureQuestState.SiteType.valueOf(text(r.get("type"))),text(r.get("name")),uuid(r,"world"),decimal(r,"x"),decimal(r,"y"),decimal(r,"z"),arena,r.containsKey("participation-mode")?AdventureQuestState.ParticipationMode.parse(text(r.get("participation-mode"))):AdventureQuestState.ParticipationMode.PARTY_AVAILABLE,fishing));}
var givers=new ArrayList<AdventureQuestState.Giver>();for(Object item:list(root.get("givers"))){var r=map(item);givers.add(new AdventureQuestState.Giver(uuid(r,"id"),text(r.get("name")),uuid(r,"world"),decimal(r,"x"),decimal(r,"y"),decimal(r,"z"),(float)decimal(r,"yaw"),(float)decimal(r,"pitch")));}
var sites=new ArrayList<AdventureQuestState.Site>();for(Object item:list(root.get("sites"))){var r=map(item);AdventureQuestState.Arena arena=null;if(r.containsKey("arena-shape")){var shape=AdventureQuestState.ArenaShape.valueOf(text(r.get("arena-shape")));int x=number(r.get("arena-x")),z=number(r.get("arena-z"));arena=new AdventureQuestState.Arena(shape,x,z);}AdventureQuestState.FishingDefinition fishing=null;if(r.get("fishing")!=null){var f=map(r.get("fishing"));fishing=new AdventureQuestState.FishingDefinition(uuid(f,"item-id"),text(f.get("item-name")),new BigInteger(text(f.get("required"))),new java.math.BigDecimal(text(f.get("chance-percent"))),new BigInteger(text(f.get("reward-xp"))),new BigInteger(text(f.get("reward-coins"))),number(f.get("radius")));}sites.add(new AdventureQuestState.Site(uuid(r,"id"),AdventureQuestState.SiteType.valueOf(text(r.get("type"))),text(r.get("name")),uuid(r,"world"),decimal(r,"x"),decimal(r,"y"),decimal(r,"z"),arena,r.containsKey("participation-mode")?AdventureQuestState.ParticipationMode.parse(text(r.get("participation-mode"))):AdventureQuestState.ParticipationMode.PARTY_AVAILABLE,fishing,r.containsKey("registration-revision")?uuid(r,"registration-revision"):null));}
var givers=new ArrayList<AdventureQuestState.Giver>();for(Object item:list(root.get("givers"))){var r=map(item);givers.add(new AdventureQuestState.Giver(uuid(r,"id"),text(r.get("name")),uuid(r,"world"),decimal(r,"x"),decimal(r,"y"),decimal(r,"z"),(float)decimal(r,"yaw"),(float)decimal(r,"pitch"),r.containsKey("registration-revision")?uuid(r,"registration-revision"):null));}
AdventureQuestState.Catalog catalog=null;if(root.get("catalog")!=null){var r=map(root.get("catalog"));catalog=new AdventureQuestState.Catalog(LocalDate.parse(text(r.get("day"))),uuid(r,"revision"),Instant.parse(text(r.get("created-at"))),list(r.get("sites")).stream().map(value->UUID.fromString(text(value))).toList(),r.containsKey("closed")?list(r.get("closed")).stream().map(value->UUID.fromString(text(value))).collect(java.util.stream.Collectors.toSet()):Set.of());}
var quests=new ArrayList<AdventureQuestState.Quest>();for(Object item:list(root.get("quests"))){var r=map(item);quests.add(new AdventureQuestState.Quest(uuid(r,"id"),uuid(r,"catalog"),uuid(r,"site"),AdventureQuestState.UnitType.valueOf(text(r.get("unit-type"))),uuid(r,"unit"),list(r.get("snapshot")).stream().map(value->UUID.fromString(text(value))).collect(java.util.stream.Collectors.toSet()),Instant.parse(text(r.get("expires-at"))),new BigInteger(text(r.get("reward-xp"))),new BigInteger(text(r.get("reward-coins"))),r.containsKey("participation-mode")?AdventureQuestState.ParticipationMode.parse(text(r.get("participation-mode"))):AdventureQuestState.ParticipationMode.PARTY_AVAILABLE,r.get("fishing")==null?null:fishingTerms(map(r.get("fishing")))));}
var unitClaims=new HashSet<AdventureQuestState.UnitClaim>();for(Object item:list(root.get("unit-claims"))){var r=map(item);unitClaims.add(new AdventureQuestState.UnitClaim(uuid(r,"catalog"),uuid(r,"site"),AdventureQuestState.UnitType.valueOf(text(r.get("unit-type"))),uuid(r,"unit")));}
@@ -48,7 +48,7 @@ public final class YamlAdventureQuestRepository {
public void save(AdventureQuestState state)throws IOException{
Objects.requireNonNull(state);var root=new LinkedHashMap<String,Object>();root.put("schema-version",2);
root.put("sites",state.sites().stream().sorted(Comparator.comparing(AdventureQuestState.Site::id)).map(YamlAdventureQuestRepository::siteRow).toList());
root.put("givers",state.givers().stream().sorted(Comparator.comparing(AdventureQuestState.Giver::id)).map(v->row("id",v.id(),"name",v.name(),"world",v.world(),"x",v.x(),"y",v.y(),"z",v.z(),"yaw",v.yaw(),"pitch",v.pitch())).toList());
root.put("givers",state.givers().stream().sorted(Comparator.comparing(AdventureQuestState.Giver::id)).map(YamlAdventureQuestRepository::giverRow).toList());
if(state.catalog()!=null)root.put("catalog",row("day",state.catalog().day(),"revision",state.catalog().revision(),"created-at",state.catalog().createdAt(),"sites",state.catalog().sites(),"closed",state.catalog().closed().stream().sorted().toList()));else root.put("catalog",null);
root.put("quests",state.quests().stream().sorted(Comparator.comparing(AdventureQuestState.Quest::id)).map(v->row("id",v.id(),"catalog",v.catalog(),"site",v.site(),"unit-type",v.unitType(),"unit",v.unit(),"snapshot",v.snapshot().stream().sorted().toList(),"expires-at",v.expiresAt(),"reward-xp",v.rewardXp(),"reward-coins",v.rewardCoins(),"participation-mode",v.participationMode().value(),"fishing",v.fishing()==null?null:fishing(v.fishing()))).toList());
root.put("unit-claims",state.unitClaims().stream().sorted(Comparator.comparing(v->v.catalog().toString()+v.site()+v.unit())).map(v->row("catalog",v.catalog(),"site",v.site(),"unit-type",v.type(),"unit",v.unit())).toList());
@@ -59,7 +59,8 @@ public final class YamlAdventureQuestRepository {
root.put("reset",state.reset()==null?null:row("operation",state.reset().operation(),"generation",state.reset().generation(),"started-at",state.reset().startedAt()));
Files.createDirectories(file.getParent());Path temporary=Files.createTempFile(file.getParent(),"adventure-quests-",".tmp");try{byte[] bytes=yaml().dump(root).getBytes(StandardCharsets.UTF_8);try(var output=java.nio.channels.FileChannel.open(temporary,StandardOpenOption.WRITE)){var buffer=java.nio.ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}try{Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);}catch(AtomicMoveNotSupportedException unsupported){throw new IOException("Atomic adventure persistence unavailable",unsupported);}DurableFiles.forceDirectory(file.getParent());}finally{Files.deleteIfExists(temporary);}legacyDungeonRewardTerms=false;
}
private static Map<String,Object> siteRow(AdventureQuestState.Site site){var result=row("id",site.id(),"type",site.type(),"name",site.name(),"world",site.world(),"x",site.x(),"y",site.y(),"z",site.z(),"participation-mode",site.participationMode().value(),"fishing",site.fishing()==null?null:fishing(site.fishing().terms()));if(site.arena()!=null){result.put("arena-shape",encode(site.arena().shape()));result.put("arena-x",site.arena().xBlocks());result.put("arena-z",site.arena().zBlocks());}return result;}
private static Map<String,Object> siteRow(AdventureQuestState.Site site){var result=row("id",site.id(),"type",site.type(),"name",site.name(),"world",site.world(),"x",site.x(),"y",site.y(),"z",site.z(),"participation-mode",site.participationMode().value(),"fishing",site.fishing()==null?null:fishing(site.fishing().terms()));if(site.registrationRevision()!=null)result.put("registration-revision",site.registrationRevision().toString());if(site.arena()!=null){result.put("arena-shape",encode(site.arena().shape()));result.put("arena-x",site.arena().xBlocks());result.put("arena-z",site.arena().zBlocks());}return result;}
private static Map<String,Object> giverRow(AdventureQuestState.Giver giver){var result=row("id",giver.id(),"name",giver.name(),"world",giver.world(),"x",giver.x(),"y",giver.y(),"z",giver.z(),"yaw",giver.yaw(),"pitch",giver.pitch());if(giver.registrationRevision()!=null)result.put("registration-revision",giver.registrationRevision().toString());return result;}
private static Map<String,Object> fishing(AdventureQuestState.FishingTerms value){return row("item-id",value.itemId(),"item-name",value.itemName(),"required",value.required(),"chance-percent",value.chancePercent(),"reward-xp",value.rewardXp(),"reward-coins",value.rewardCoins(),"radius",value.radius());}
private static AdventureQuestState.FishingTerms fishingTerms(Map<?,?> value){return new AdventureQuestState.FishingTerms(uuid(value,"item-id"),text(value.get("item-name")),new BigInteger(text(value.get("required"))),new java.math.BigDecimal(text(value.get("chance-percent"))),new BigInteger(text(value.get("reward-xp"))),new BigInteger(text(value.get("reward-coins"))),number(value.get("radius")));}
private static Map<String,Object> row(Object...pairs){var row=new LinkedHashMap<String,Object>();for(int i=0;i<pairs.length;i+=2)row.put((String)pairs[i],encode(pairs[i+1]));return row;}
@@ -86,6 +86,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.events.HardMobXpEventBridge hardMobXpEvents;
private games.dmg.fantasystick.persistence.AdventureQuestSettings adventureQuestSettings;
private games.dmg.fantasystick.events.FishingEventBridge fishingEvents;
private games.dmg.fantasystick.events.CampEventBridge campEvents;
private games.dmg.fantasystick.adventure.AdventureQuestCommands adventureQuestCommands;
private games.dmg.fantasystick.dungeon.DungeonCommands dungeonCommands;
private games.dmg.fantasystick.party.CombatExperience combatExperience;
@@ -389,6 +390,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
adventureQuestCommands = Objects.requireNonNull(commands); adventureQuestSettings = Objects.requireNonNull(settings);
}
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);}
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){
@@ -2107,9 +2109,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
||key.equals(games.dmg.fantasystick.persistence.AdventureQuestSettings.FISHING_CATCH_XP_MAX);
var audit=fishingKey?fishingEvents:null;
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);
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));
sender.sendMessage(key+"="+effective+" (applies to the owning later catalog, acceptance or encounter attempt)");return;
}
var operationId=UUID.randomUUID();
@@ -2122,6 +2127,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
var effective=adventureQuestSettings.value(key);
if(applied&&audit!=null&&!before.equals(effective))audit.configChanged(actor,operationId,key,before,effective,
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(!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+".");
@@ -0,0 +1,133 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.adventure.FishingDirectory;
import games.dmg.fantasystick.adventure.FishingState;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.bukkit.entity.Villager;
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 CampAttemptEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void actualChunkUnloadAndDisableResetTransientAttemptsWithoutRerollingRosterCancellingQuestOrRewarding()throws Exception{
var config=new org.bukkit.configuration.file.YamlConfiguration();config.set("adventure-camp-enemies",4);config.save(data.resolve("config.yml").toFile());
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;fixture.camp("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(v->v.startsWith("Accepted Same Camp;")));
var quest=fixture.directory.active().getFirst();var before=CombatExperienceNativeTest.record(f,actor);fixture.pulse();
ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(entity->!(entity instanceof Villager)).count()==4);
var first=fixture.spawned.stream().filter(entity->!(entity instanceof Villager)).toList();
var key=new org.bukkit.NamespacedKey(f.plugin,"adventure_attempt_id");
String original=first.getFirst().getPersistentDataContainer().get(key,org.bukkit.persistence.PersistentDataType.STRING);
var roster=List.copyOf(fixture.directory.snapshot().rosters().get(quest.site()));
f.dispatcher.accept(new org.bukkit.event.world.ChunkUnloadEvent(actor.world.getChunkAt(0,-1),false));
assertFalse(fixture.controller.hasActiveEncounter());assertTrue(first.stream().noneMatch(org.bukkit.entity.Entity::isValid));
assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());assertEquals(roster,fixture.directory.snapshot().rosters().get(quest.site()));
fixture.pulse();ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(entity->!(entity instanceof Villager)).count()==8);
var second=fixture.spawned.stream().filter(entity->!(entity instanceof Villager)).skip(4).toList();
String successor=second.getFirst().getPersistentDataContainer().get(key,org.bukkit.persistence.PersistentDataType.STRING);
assertNotEquals(original,successor);assertEquals(roster,second.stream().map(org.bukkit.entity.Entity::getType).toList());
assertEquals(before,CombatExperienceNativeTest.record(f,actor));
ReskillEventsNativeTest.await(f,()->{
try{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.camp.reset")).count()==1;}
catch(Exception failure){throw new AssertionError(failure);}
});
f.plugin.setEnabled(false);f.awaitWorkers();
((java.util.concurrent.CompletionStage<?>)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"campAuditDrain"))
.toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
// A closed writer may reject the last shutdown export; immutable audit replay, never gameplay replay, finishes it.
f.plugin.setEnabled(true);
ReskillEventsNativeTest.await(f,()->{
try{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.camp.reset")).count()==2;}
catch(Exception failure){throw new AssertionError(failure);}
});
assertFalse(((games.dmg.fantasystick.adventure.AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController")).hasActiveEncounter());
f.plugin.setEnabled(false);f.awaitWorkers();
var rows=ReskillEventsNativeTest.records(data);var resets=rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.camp.reset")).toList();
assertEquals(2,resets.size(),"Actual chunk-unload and orderly disable must each export the original transient native attempt reset");
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(quest.catalog().toString(),values.get("catalog_id").getAsString());assertEquals(4,values.get("roster_total").getAsInt());assertEquals(0,values.get("remaining_entity_count").getAsInt());}
assertTrue(rows.stream().noneMatch(row->row.get("type").getAsString().endsWith("adventure.camp.cleared")||row.get("type").getAsString().endsWith("adventure.trophy_earned")));
}
}
@Test void registeredPersonalCampUsesAcknowledgedRosterRealNativeAttemptAndExistingSpoilsTurnInClaimsWithoutCopies()throws Exception{
var config=new org.bukkit.configuration.file.YamlConfiguration();config.set("adventure-camp-enemies",4);
config.save(data.resolve("config.yml").toFile());
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;fixture.camp("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,()->actor.messages.stream().anyMatch(v->v.startsWith("Accepted Same Camp;")));
var quest=fixture.directory.active().getFirst();fixture.pulse();
ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(entity->!(entity instanceof Villager)).count()==4);
var enemies=fixture.spawned.stream().filter(entity->!(entity instanceof Villager)).toList();
var roster=fixture.directory.snapshot().rosters().get(quest.site());assertEquals(4,roster.size());
assertEquals(roster,enemies.stream().map(org.bukkit.entity.Entity::getType).toList());
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);
var outsider=new FantasyStickLifecycleNativeTest.Actor(data);outsider.core.location.setWorld(actor.world);f.players.put(outsider.core.id,outsider.player);
var rejected=new EntityDamageByEntityEvent(outsider.player,enemies.getFirst(),EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);
f.dispatcher.accept(rejected);assertTrue(rejected.isCancelled());
for(var enemy:enemies){
var hit=new EntityDamageByEntityEvent(actor.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);
when(enemy.getLastDamageCause()).thenReturn(hit);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());
var death=new org.bukkit.event.entity.EntityDeathEvent(enemy,source,new ArrayList<>(),7);f.dispatcher.accept(death);
assertTrue(death.getDrops().isEmpty());assertEquals(0,death.getDroppedExp());
f.dispatcher.accept(death); // Original duplicate native callback cannot clear or earn again.
}
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),"Native clear stores spoils, never immediate XP or coins");
var trophy=trophies.trophies(quest.unit()).getFirst();assertEquals(FishingState.TrophyType.CAMP,trophy.type());
assertEquals(java.util.Set.of(quest.unit()),trophy.rewards().keySet());assertEquals(quest.id(),trophy.quest());
var giverMenu=fixture.open(actor);fixture.click(actor,giverMenu,slot(giverMenu,"Turn in quest items"),false);
var turnMenu=actor.inventories.getLast();fixture.click(actor,turnMenu,slot(turnMenu,"Redeem Spoils of"),false);
ReskillEventsNativeTest.await(f,()->trophies.trophies(quest.unit()).isEmpty()&&!trophies.unclaimed(quest.unit()).equals(FishingState.Reward.empty()));
assertEquals(before,CombatExperienceNativeTest.record(f,actor),"Exact item consumption only unlocks its frozen share");
giverMenu=fixture.open(actor);fixture.click(actor,giverMenu,slot(giverMenu,"Turn in quest items"),false);
turnMenu=actor.inventories.getLast();fixture.click(actor,turnMenu,slot(turnMenu,"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{var rows=ReskillEventsNativeTest.records(data);return java.util.stream.Stream.of("adventure.camp.started","adventure.camp.cleared",
"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);}
});
f.plugin.setEnabled(false);f.awaitWorkers();
((java.util.concurrent.CompletionStage<?>)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"campAuditDrain"))
.toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
var rows=ReskillEventsNativeTest.records(data);var started=rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.camp.started")).toList();
var cleared=rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.camp.cleared")).toList();
assertEquals(1,started.size(),"Actual saved roster and registered native spawn must export their sole transient camp attempt");
assertEquals(1,cleared.size(),"Actual final native death must export one clear, not duplicate callbacks or reward copies");
for(var row:List.of(started.getFirst(),cleared.getFirst())){var values=row.getAsJsonObject("data");
assertEquals(quest.catalog().toString(),values.get("catalog_id").getAsString());assertEquals(quest.site().toString(),values.get("site_id").getAsString());
assertEquals(trophy.source().toString(),values.get("attempt_id").getAsString());assertEquals("native_transient_not_persisted",values.get("attempt_capture_quality").getAsString());
assertEquals(4,values.get("roster_total").getAsInt());assertEquals(4,values.getAsJsonArray("roster_entries").size());
assertEquals("succeeded",values.get("native_outcome").getAsString());
}
var evidence=cleared.getFirst().getAsJsonObject("data");assertEquals(quest.id().toString(),evidence.getAsJsonArray("represented_quest_ids").get(0).getAsString());
assertEquals(enemies.getLast().getUniqueId().toString(),evidence.get("final_enemy_id").getAsString());
assertEquals(1,rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.trophy_earned")).count());
assertEquals(1,rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.trophy_redeemed")).count());
assertEquals(1,rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.reward_claimed")).count());
assertTrue(rows.stream().noneMatch(row->row.get("type").getAsString().endsWith("adventure.quest.expired")),"Trophy completion is never an expiry");
}
}
static int slot(org.bukkit.inventory.Inventory menu,String prefix){
var plain=net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText();
return java.util.stream.IntStream.range(0,menu.getSize()).filter(index->{var item=menu.getItem(index);
return item!=null&&item.getItemMeta().displayName()!=null&&plain.serialize(item.getItemMeta().displayName()).startsWith(prefix);}).findFirst().orElseThrow();
}
}
@@ -0,0 +1,87 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.adventure.AdventureQuestController;
import games.dmg.fantasystick.adventure.AdventureQuestDirectory;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Chunk;
import org.bukkit.Location;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Villager;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.Inventory;
/** Typed supported camp/giver native ports; real controllers, storage, commands and writer remain installed. */
final class CampAuditNativeFixture implements AutoCloseable {
final FantasyStickLifecycleNativeTest.Fixture f;
final FantasyStickLifecycleNativeTest.Actor actor;
final AdventureQuestDirectory directory;
final AdventureQuestController controller;
final ArrayList<LivingEntity> spawned=new ArrayList<>();
Consumer<LivingEntity> nativeSpawnOutcome=ignored->{};
CampAuditNativeFixture(Path data)throws Exception{
f=new FantasyStickLifecycleNativeTest.Fixture(data);f.plugin.setEnabled(true);
actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);
when(actor.world.getUID()).thenReturn(UUID.randomUUID());when(actor.world.getName()).thenReturn("Camp Realm");
when(actor.world.isChunkLoaded(anyInt(),anyInt())).thenReturn(true);
var chunk=mock(Chunk.class);when(chunk.getWorld()).thenReturn(actor.world);when(chunk.getX()).thenReturn(0);when(chunk.getZ()).thenReturn(-1);
when(chunk.getEntities()).thenAnswer(call->spawned.stream().filter(Entity::isValid).toArray(Entity[]::new));
when(actor.world.getEntities()).thenAnswer(call->new ArrayList<Entity>(spawned.stream().filter(Entity::isValid).toList()));
when(actor.world.getLoadedChunks()).thenReturn(new Chunk[]{chunk});when(actor.world.getChunkAt(anyInt(),anyInt())).thenReturn(chunk);
doAnswer(call->{
Class<? extends LivingEntity> type=call.getArgument(1);LivingEntity entity=mock(type);
var pdc=new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());
when(entity.getUniqueId()).thenReturn(UUID.randomUUID());when(entity.getPersistentDataContainer()).thenReturn(pdc);
when(entity.getWorld()).thenReturn(actor.world);Location at=call.getArgument(0);when(entity.getLocation()).thenReturn(at.clone());
when(entity.isValid()).thenReturn(true);when(entity.getVelocity()).thenReturn(new org.bukkit.util.Vector());
when(entity.getType()).thenReturn(entity instanceof Villager?org.bukkit.entity.EntityType.VILLAGER:
entity instanceof org.bukkit.entity.Husk?org.bukkit.entity.EntityType.HUSK:
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;
}).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");
}
void camp(String mode)throws Exception{
long count=actor.messages.stream().filter(v->v.startsWith("Placed camp")).count();
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","place","camp","--mode",mode,"Same Camp"});
ReskillEventsNativeTest.await(f,()->actor.messages.stream().filter(v->v.startsWith("Placed camp")).count()==count+1);
}
void giver()throws Exception{
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"quest-giver","place","Guide"});
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(v->v.startsWith("Placed Quest Giver")));
}
void resetCatalog()throws Exception{
long count=actor.messages.stream().filter(v->v.startsWith("Reset daily adventures:")).count();
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure","reroll","confirm"});
ReskillEventsNativeTest.await(f,()->actor.messages.stream().filter(v->v.startsWith("Reset daily adventures:")).count()==count+1);
}
Inventory open(FantasyStickLifecycleNativeTest.Actor holder)throws Exception{
int count=holder.inventories.size();var giver=spawned.stream().filter(Villager.class::isInstance).filter(Entity::isValid).findFirst().orElseThrow();
var event=new org.bukkit.event.player.PlayerInteractEntityEvent(holder.player,giver,org.bukkit.inventory.EquipmentSlot.HAND);
f.dispatcher.accept(event);assertTrue(event.isCancelled());ReskillEventsNativeTest.await(f,()->holder.inventories.size()>count);
return holder.inventories.getLast();
}
void click(FantasyStickLifecycleNativeTest.Actor holder,Inventory menu,boolean right){click(holder,menu,0,right);}
void click(FantasyStickLifecycleNativeTest.Actor holder,Inventory menu,int slot,boolean right){
var view=mock(org.bukkit.inventory.InventoryView.class);
when(view.getTopInventory()).thenReturn(menu);when(view.getBottomInventory()).thenReturn(holder.core.inventory);
when(view.getPlayer()).thenReturn(holder.player);when(view.getType()).thenReturn(menu.getType());
when(view.getItem(slot)).thenReturn(menu.getItem(slot));when(view.getInventory(slot)).thenReturn(menu);
var event=new InventoryClickEvent(view,org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,slot,
right?org.bukkit.event.inventory.ClickType.RIGHT:org.bukkit.event.inventory.ClickType.LEFT,
org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);
f.dispatcher.accept(event);assertTrue(event.isCancelled());
}
void pulse(){for(var task:java.util.List.copyOf(f.pulses))task.run();}
@Override public void close()throws java.io.IOException{f.close();}
}
@@ -0,0 +1,76 @@
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.CampEventBridge;
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 CampAuditSafetyReplayNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void deniedInvalidSameValueLockedAndClosedCampSettingsCannotPublishPositiveCopies()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");
var key=AdventureQuestSettings.DAILY_COUNT;
f.adminCommand.execute(denied.port,"fstickadmin",new String[]{"config",key});f.adminCommand.execute(denied.port,"fstickadmin",new String[]{"config",key,"4"});
for(String invalid:List.of("-1","21","NaN","1.5"))f.adminCommand.execute(sender.port,"fstickadmin",new String[]{"config",key,invalid});
f.adminCommand.execute(sender.port,"fstickadmin",new String[]{"config",key,"4","extra"});assertEquals("3",settings.value(key));
f.adminCommand.execute(sender.port,"fstickadmin",new String[]{"config",key,"3"});
ReskillEventsNativeTest.await(f,()->sender.messages.stream().anyMatch(value->value.startsWith("Saved "+key+"=3")));
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,"4"});
ReskillEventsNativeTest.await(f,()->sender.messages.contains("Adventure quest configuration save failed; effective values are unchanged."));
assertEquals("3",settings.value(key));assertArrayEquals(bytes,Files.readAllBytes(data.resolve("config.yml")));
}
sender.messages.clear();settings.close();f.adminCommand.execute(sender.port,"fstickadmin",new String[]{"config",key,"5"});
ReskillEventsNativeTest.await(f,()->sender.messages.contains("Adventure quest configuration save failed; effective values are unchanged."));
assertEquals("3",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 configRows().size()==1;}catch(Exception failure){throw new AssertionError(failure);}});
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"campAuditDrain");assertEquals(1,configRows().size());
assertTrue(configRows().getFirst().get("type").getAsString().endsWith("config.read"));
}
}
@Test void rejectedRealCampReadAndQueuedPublicationsReplayEveryOriginalPropertyOfflineWithoutGameplayOrConfigAccess()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);
((LocalEventLog)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"eventLog")).close();
String key=AdventureQuestSettings.DAILY_COUNT;
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",key});
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",key,"4"});
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",key,"5"});actor.core.location.setX(77);
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Saved "+key+"=5")));
assertEquals(before,CombatExperienceNativeTest.record(f,actor));
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"campAuditDrain");drain(f,"configurationDrain");
var original=intents(data.resolve("camp-event-intents"));assertEquals(3,original.size());assertTrue(configRows().isEmpty());
var bytes=Files.readAllBytes(data.resolve("config.yml"));var inventory=actor.core.snapshot();var messages=List.copyOf(actor.messages);
f.players.clear();clearInvocations(actor.player);f.plugin.setEnabled(true);
ReskillEventsNativeTest.await(f,()->{try{return configRows().size()==3;}catch(Exception failure){throw new AssertionError(failure);}});
assertArrayEquals(bytes,Files.readAllBytes(data.resolve("config.yml")));assertArrayEquals(inventory,actor.core.snapshot());assertEquals(messages,actor.messages);
var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");
assertEquals(before,storage.find(before.id()).get().orElseThrow());verifyNoInteractions(actor.player);
var rows=configRows();var changed=rows.stream().filter(row->row.get("type").getAsString().endsWith("config.changed")).toList();assertEquals(2,changed.size());
assertEquals(java.util.Map.of("3","4","4","5"),changed.stream().collect(java.util.stream.Collectors.toMap(
row->row.getAsJsonObject("data").get("before").getAsString(),row->row.getAsJsonObject("data").get("after").getAsString())),
"Replay preserves exact acknowledged transitions, not UUID-file arrival order");
for(var intent:original)assertOriginal(intent,rows.stream().filter(row->row.get("id").getAsString().equals(intent.getProperty("id"))).findFirst().orElseThrow());
var bridge=(CampEventBridge)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"campAuditEvents");bridge.replay();bridge.replay();
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"campAuditDrain");assertEquals(3,configRows().size());assertEquals(messages,actor.messages);
}
}
private List<com.google.gson.JsonObject> configRows()throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->
row.getAsJsonObject("data").has("key")&&CampEventBridge.ownsSetting(row.getAsJsonObject("data").get("key").getAsString())).toList();}
}
@@ -0,0 +1,60 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;
import games.dmg.fantasystick.adventure.AdventureQuestDirectory;
import java.nio.file.Path;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class CampCatalogEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void actualRegisteredConfirmedResetPublishesOneCompleteSharedUtcCatalogWithCapturedOfferSettings() throws Exception {
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(actor.core.id,actor.player);when(actor.world.getUID()).thenReturn(UUID.randomUUID());
when(actor.world.getName()).thenReturn("Catalog Realm");
var directory=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
for(int i=0;i<2;i++){
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","place","camp","--mode",i==0?"individual-only":"party-required","Same Name"});
final int count=i+1;ReskillEventsNativeTest.await(f,()->actor.messages.stream().filter(v->v.startsWith("Placed camp")).count()==count);
}
var sites=directory.snapshot().sites();
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure","reroll","confirm"});
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(v->v.startsWith("Reset daily adventures:")));
var catalog=directory.snapshot().catalog();assertNotNull(catalog);assertNull(directory.snapshot().reset());
assertEquals(Set.copyOf(sites.stream().map(v->v.id()).toList()),Set.copyOf(catalog.sites()));
ReskillEventsNativeTest.await(f,()->{
try{return ReskillEventsNativeTest.records(data).stream().anyMatch(row->row.get("type").getAsString().endsWith("adventure.catalog_refreshed")
&&row.getAsJsonObject("data").get("catalog_id").getAsString().equals(catalog.revision().toString()));}
catch(Exception failure){throw new AssertionError(failure);}
});
f.plugin.setEnabled(false);f.awaitWorkers();
((java.util.concurrent.CompletionStage<?>)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"campAuditDrain"))
.toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
var rows=ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.catalog_refreshed")
&&row.getAsJsonObject("data").get("catalog_id").getAsString().equals(catalog.revision().toString())).toList();
assertEquals(1,rows.size(),"Actual complete durable UTC catalog publication must export one owner event, not a menu or pending-reset copy");
var row=rows.getFirst();var values=row.getAsJsonObject("data");
assertEquals(catalog.revision().toString(),values.get("catalog_revision").getAsString());
assertEquals(catalog.day().toString(),values.get("utc_day").getAsString());
assertEquals(catalog.createdAt().toString(),values.get("created_at").getAsString());
assertEquals(catalog.expiresAt().toString(),values.get("next_midnight").getAsString());
assertEquals("admin_full_reroll",values.get("reason").getAsString());assertEquals("acknowledged",values.get("outcome").getAsString());
assertEquals(3,values.getAsJsonObject("offer_settings").get("adventure-daily-site-count").getAsInt());
assertEquals(2,values.getAsJsonArray("selected_sites").size());
for(var site:sites){var selected=java.util.stream.StreamSupport.stream(values.getAsJsonArray("selected_sites").spliterator(),false)
.map(v->v.getAsJsonObject()).filter(v->v.get("site_id").getAsString().equals(site.id().toString())).findFirst().orElseThrow();
assertEquals("camp",selected.get("site_kind").getAsString());assertEquals(site.participationMode().value(),selected.get("mode").getAsString());
assertEquals(site.registrationRevision().toString(),selected.get("site_revision").getAsString());
}
assertEquals(actor.core.id.toString(),row.get("playerid").getAsString());
}
}
}
@@ -0,0 +1,61 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
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 CampConfigurationEventsNativeTest {
@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.DAILY_COUNT,"3","4","next_catalog"),
new Setting(AdventureQuestSettings.RADIUS,"16","24","new_sites_and_future_legacy_geometry_attempts"),
new Setting(AdventureQuestSettings.RESET_SECONDS,"30","45","future_attempts"),
new Setting(AdventureQuestSettings.CAMP_ENEMIES,"8","12","future_uncaptured_catalog_day_rosters"),
new Setting(AdventureQuestSettings.CAMP_REWARD_XP,"100","125","future_acceptances"),
new Setting(AdventureQuestSettings.CAMP_REWARD_COINS,"20","35","future_acceptances"));
@Test void allSixRegisteredCampCatalogReadsAndSavedPublicationsExportExactHistoryWithoutRewritingCurrentQuestCatalogOrGeometry()throws Exception{
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;fixture.camp("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(v->v.startsWith("Accepted Same Camp;")));
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);var settings=(AdventureQuestSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestSettings");
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()});
}
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));
ReskillEventsNativeTest.await(f,()->{
try{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()))).count()==18;}
catch(Exception failure){throw new AssertionError(failure);}
});
f.plugin.setEnabled(false);f.awaitWorkers();
((java.util.concurrent.CompletionStage<?>)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"campAuditDrain"))
.toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
var rows=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();
assertEquals(18,rows.size(),"Every actual registered camp/catalog setting read and acknowledged publication must export its owning JSONL history");
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(List.of(setting.before(),setting.after()),reads.stream().map(row->row.getAsJsonObject("data").get("effective").getAsString()).toList());
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 dataValues=row.getAsJsonObject("data");assertEquals(setting.scope(),dataValues.get("application_scope").getAsString());
assertEquals(setting.before(),dataValues.get("default").getAsString());assertEquals(12,dataValues.getAsJsonObject("location").get("x").getAsDouble());
assertEquals(actor.core.id.toString(),row.get("playerid").getAsString());assertEquals(quest.unit().toString(),row.get("characterid").getAsString());}
}
}
}
}
@@ -0,0 +1,85 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
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;
class CampMetadataFaultNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void nativeCancelledMaterializationCannotBeClaimedAsSuccessfulCampSpawnOrEarnCompletion()throws Exception{
var config=new org.bukkit.configuration.file.YamlConfiguration();config.set("adventure-camp-enemies",4);config.save(data.resolve("config.yml").toFile());
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;fixture.camp("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Accepted Same Camp;")));
var quest=fixture.directory.active().getFirst();var before=CombatExperienceNativeTest.record(f,actor);
fixture.nativeSpawnOutcome=entity->{if(!(entity instanceof org.bukkit.entity.Villager))when(entity.isValid()).thenReturn(false);};
fixture.pulse();ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(entity->!(entity instanceof org.bukkit.entity.Villager)).count()==4);
assertEquals(4,fixture.directory.snapshot().rosters().get(quest.site()).size());assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());
assertEquals(before,CombatExperienceNativeTest.record(f,actor));ReskillEventsNativeTest.await(f,()->rows("adventure.camp.started").size()==1);
var values=rows("adventure.camp.started").getFirst().getAsJsonObject("data");
assertEquals("not_materialized",values.get("native_outcome").getAsString(),"Returned native spawn/configure calls are not accepted materialization when native addition was cancelled");
assertEquals("native_not_materialized",values.get("outcome").getAsString());assertEquals(4,values.get("roster_total").getAsInt());
assertTrue(rows("adventure.camp.cleared").isEmpty());assertTrue(rows("adventure.trophy_earned").isEmpty());
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"campAuditDrain");
}
}
@Test void unavailableOptionalActorNameRetainsKnownPlayerCharacterSessionLocationAndAcknowledgedCampRevision()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);
doThrow(new IllegalStateException("Unavailable optional actor name")).when(actor.player).getName();fixture.camp("individual-only");
var saved=fixture.directory.snapshot().sites().getFirst();assertNotNull(saved.registrationRevision());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
doReturn("Later restored name").when(actor.player).getName();
ReskillEventsNativeTest.await(f,()->rows("adventure.site.registered").size()==1);
var values=rows("adventure.site.registered").getFirst().getAsJsonObject("data");
assertEquals(actor.core.id.toString(),values.get("request_player_id").getAsString());assertEquals(before.id().toString(),values.get("request_character_id").getAsString());
assertTrue(values.has("request_session_id"));assertFalse(values.has("request_player_name"));assertEquals("unavailable",values.get("actor_capture_quality").getAsString());
assertEquals(12,values.getAsJsonObject("actor_location").get("x").getAsDouble());assertEquals(saved.registrationRevision().toString(),values.get("site_revision").getAsString());
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"campAuditDrain");assertEquals(1,rows("adventure.site.registered").size());
}
}
@Test void unloadedSavedGiverReportsUnavailableNativeMaterializationWithoutForceLoadingOrInventingEntityIdentity()throws Exception{
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;when(actor.world.isChunkLoaded(anyInt(),anyInt())).thenReturn(false);clearInvocations(actor.world);
fixture.giver();assertTrue(fixture.spawned.isEmpty());var saved=fixture.directory.snapshot().givers().getFirst();assertNotNull(saved.registrationRevision());
ReskillEventsNativeTest.await(f,()->rows("adventure.quest_giver.placed").size()==1);
var values=rows("adventure.quest_giver.placed").getFirst().getAsJsonObject("data");
assertEquals(saved.id().toString(),values.get("giver_id").getAsString());assertEquals(saved.registrationRevision().toString(),values.get("giver_revision").getAsString());
assertEquals("not_loaded",values.get("entity_outcome").getAsString());assertTrue(values.getAsJsonArray("entity_ids_after").isEmpty());
verify(actor.world,never()).getChunkAt(anyInt(),anyInt());
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"campAuditDrain");
}
}
@Test void nonDirectoryOwnerIntentPathCannotPreventRealSavedCampCatalogNativeClearOrExistingSpoils()throws Exception{
java.nio.file.Files.writeString(data.resolve("camp-event-intents"),"occupied");
var config=new org.bukkit.configuration.file.YamlConfiguration();config.set("adventure-camp-enemies",4);config.save(data.resolve("config.yml").toFile());
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;fixture.camp("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Accepted Same Camp;")));
var quest=fixture.directory.active().getFirst();var before=CombatExperienceNativeTest.record(f,actor);fixture.pulse();
ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(entity->!(entity instanceof org.bukkit.entity.Villager)).count()==4);
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);
for(var enemy:fixture.spawned.stream().filter(entity->!(entity instanceof org.bukkit.entity.Villager)).toList()){
var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);
when(enemy.getLastDamageCause()).thenReturn(hit);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(enemy,source,new java.util.ArrayList<>(),0));
}
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(before,CombatExperienceNativeTest.record(f,actor));assertEquals("occupied",java.nio.file.Files.readString(data.resolve("camp-event-intents")));
ReskillEventsNativeTest.await(f,()->rows("adventure.trophy_earned").size()==1);
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"campAuditDrain");
assertTrue(rows("adventure.camp.started").isEmpty());assertTrue(rows("adventure.camp.cleared").isEmpty());
assertEquals(1,rows("adventure.trophy_earned").size(),"Existing trophy owner remains independent of failed camp audit storage");
}
}
private java.util.List<com.google.gson.JsonObject> rows(String type){try{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)).toList();}
catch(Exception failure){throw new AssertionError(failure);}}
}
@@ -0,0 +1,57 @@
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.LocalEventLog;
import java.nio.file.Path;
import java.util.List;
import org.bukkit.entity.Villager;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class CampMultipartReplayNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void actualSavedSixtyFourEnemyNativeAttemptExportsEveryOriginalRosterEntityAndResetPartThenReplaysOfflineWithoutSpawning()throws Exception{
var config=new org.bukkit.configuration.file.YamlConfiguration();config.set("adventure-camp-enemies",64);config.save(data.resolve("config.yml").toFile());
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;fixture.camp("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Accepted Same Camp;")));
var quest=fixture.directory.active().getFirst();var before=CombatExperienceNativeTest.record(f,actor);fixture.pulse();
ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(entity->!(entity instanceof Villager)).count()==64);
var enemies=fixture.spawned.stream().filter(entity->!(entity instanceof Villager)).toList();var roster=fixture.directory.snapshot().rosters().get(quest.site());
assertEquals(64,roster.size());assertEquals(roster,enemies.stream().map(org.bukkit.entity.Entity::getType).toList());
ReskillEventsNativeTest.await(f,()->rows("adventure.camp.started").size()==4);
var starts=rows("adventure.camp.started");assertEquals(4,starts.stream().map(row->row.get("id").getAsString()).distinct().count());
var entries=new java.util.TreeMap<Integer,String>();var ids=new java.util.HashSet<String>();
for(var row:starts){var values=row.getAsJsonObject("data");assertEquals(4,values.get("part_count").getAsInt());assertEquals(64,values.get("roster_total").getAsInt());
for(var entry:values.getAsJsonArray("roster_entries")){var fact=entry.getAsJsonObject();assertNull(entries.put(fact.get("roster_index").getAsInt(),fact.get("entity_type").getAsString()));}
for(var entity:values.getAsJsonArray("native_entities"))assertTrue(ids.add(entity.getAsJsonObject().get("entity_id").getAsString()));}
assertEquals(roster.stream().map(Enum::name).toList(),List.copyOf(entries.values()));
assertEquals(enemies.stream().map(entity->entity.getUniqueId().toString()).collect(java.util.stream.Collectors.toSet()),ids);
((LocalEventLog)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"eventLog")).close();
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"campAuditDrain");
var originals=intents(data.resolve("camp-event-intents"));assertEquals(4,originals.size());
assertTrue(originals.stream().allMatch(intent->intent.getProperty("type").equals("adventure.camp.reset")));
var bytes=java.nio.file.Files.readAllBytes(data.resolve("adventure-quests.yml"));var inventory=actor.core.snapshot();var messages=List.copyOf(actor.messages);
f.players.clear();clearInvocations(actor.player);f.plugin.setEnabled(true);ReskillEventsNativeTest.await(f,()->rows("adventure.camp.reset").size()==4);
var resets=rows("adventure.camp.reset");for(var original:originals){
var decoded=new java.util.Properties();decoded.putAll(original);
// Existing legacy slot-list encoding also represents empty lists; normalize only that exact empty value for this reader.
for(String key:decoded.stringPropertyNames())if(key.startsWith("data.")&&decoded.getProperty(key).equals("l"))decoded.setProperty(key,"j[]");
assertOriginal(decoded,resets.stream().filter(row->row.get("id").getAsString().equals(original.getProperty("id"))).findFirst().orElseThrow());
}
assertArrayEquals(bytes,java.nio.file.Files.readAllBytes(data.resolve("adventure-quests.yml")));assertArrayEquals(inventory,actor.core.snapshot());assertEquals(messages,actor.messages);verifyNoInteractions(actor.player);
var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(before,storage.find(before.id()).get().orElseThrow());
assertFalse(((games.dmg.fantasystick.adventure.AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController")).hasActiveEncounter());
assertEquals(64,fixture.spawned.stream().filter(entity->!(entity instanceof Villager)).count());
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"campAuditDrain");assertEquals(4,rows("adventure.camp.reset").size());
assertTrue(rows("adventure.camp.cleared").isEmpty());assertTrue(rows("adventure.trophy_earned").isEmpty());
}
}
private List<com.google.gson.JsonObject> rows(String type){try{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)).toList();}
catch(Exception failure){throw new AssertionError(failure);}}
}
@@ -0,0 +1,148 @@
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 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;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
class CampPartyEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void mutedRegisteredPartyClearExcludesCancelledSoloAndAddedMemberThenRedeemsOfflineLeadersFrozenShare()throws Exception{
var config=new org.bukkit.configuration.file.YamlConfiguration();config.set("adventure-camp-enemies",4);config.save(data.resolve("config.yml").toFile());
try(var fixture=new CampAuditNativeFixture(data)){
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);
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()));
fixture.camp("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 addedBefore=CombatExperienceNativeTest.record(f,added);var soloBefore=CombatExperienceNativeTest.record(f,solo);
fixture.pulse();ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(entity->!(entity instanceof org.bukkit.entity.Villager)).count()==4);
var enemies=fixture.spawned.stream().filter(entity->!(entity instanceof org.bukkit.entity.Villager)).toList();
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,enemies.getFirst(),org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,cancelledSource,1);
cancelled.setCancelled(true);f.dispatcher.accept(cancelled);assertTrue(cancelled.isCancelled());
var rejectedSource=mock(org.bukkit.damage.DamageSource.class);when(rejectedSource.getCausingEntity()).thenReturn(added.player);when(rejectedSource.getDirectEntity()).thenReturn(added.player);
var rejected=new org.bukkit.event.entity.EntityDamageByEntityEvent(added.player,enemies.getFirst(),org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,rejectedSource,1);
f.dispatcher.accept(rejected);assertTrue(rejected.isCancelled());
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(member.player);when(source.getDirectEntity()).thenReturn(member.player);
doReturn(false).when(leader.player).isOnline();
for(var enemy:enemies){
var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(member.player,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);
when(enemy.getLastDamageCause()).thenReturn(hit);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());
var death=new org.bukkit.event.entity.EntityDeathEvent(enemy,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(quest.id(),trophy.quest());assertEquals(party.id(),quest.unit());
assertEquals(Set.of(memberBefore.id()),trophy.rewards().keySet());assertEquals(quest.rewardXp(),trophy.total().xp());assertEquals(quest.rewardCoins(),trophy.total().coins());
assertEquals(quest.snapshot(),Set.of(leaderBefore.id(),memberBefore.id()));assertEquals(soloQuest,fixture.directory.findQuest(soloQuest.id()).orElseThrow());
assertEquals(leaderBefore,CombatExperienceNativeTest.record(f,leader));assertEquals(memberBefore,CombatExperienceNativeTest.record(f,member));
assertEquals(addedBefore,CombatExperienceNativeTest.record(f,added));assertEquals(soloBefore,CombatExperienceNativeTest.record(f,solo));
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 Spoils of"),false);
ReskillEventsNativeTest.await(f,()->trophies.trophies(leaderBefore.id()).isEmpty()&&!trophies.unclaimed(memberBefore.id()).equals(games.dmg.fantasystick.adventure.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(games.dmg.fantasystick.adventure.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(addedBefore,CombatExperienceNativeTest.record(f,added));assertEquals(soloBefore,CombatExperienceNativeTest.record(f,solo));
ReskillEventsNativeTest.await(f,()->{
try{var rows=ReskillEventsNativeTest.records(data);return java.util.stream.Stream.of("adventure.camp.started","adventure.camp.cleared","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 cleared=rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.camp.cleared")).findFirst().orElseThrow().getAsJsonObject("data");
var represented=cleared.getAsJsonArray("represented_units");assertEquals(1,represented.size());var unit=represented.get(0).getAsJsonObject();
assertEquals(quest.id().toString(),unit.get("quest_id").getAsString());assertEquals(party.id().toString(),unit.get("unit_id").getAsString());assertEquals("party",unit.get("unit_kind").getAsString());
assertEquals(trophy.source().toString(),cleared.get("attempt_id").getAsString());assertFalse(preferences.enabled(member.core.id));
assertTrue(rows.stream().noneMatch(row->row.get("type").getAsString().endsWith("adventure.quest.expired")));
f.plugin.setEnabled(false);f.awaitWorkers();games.dmg.fantasystick.WitchAuditTestSupport.drain(f,"campAuditDrain");
}
}
@ParameterizedTest @ValueSource(strings={"individual-only","party-available","party-required"})
void registeredExplicitModesKeepCharacterOrPersistentPartyAuthorityFrozenAcrossTransferAdditionAndDisband(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()));
// Durable US-052 party is a completed prerequisite; camp administration and all acceptance choices use actual registered interfaces.
fixture.camp(mode);fixture.giver();fixture.resetCatalog();
boolean personal=mode.equals("individual-only");
var leaderBefore=CombatExperienceNativeTest.record(f,leader);var memberBefore=CombatExperienceNativeTest.record(f,member);
if(personal){fixture.click(leader,fixture.open(leader),true);
assertTrue(leader.messages.contains("Quest acceptance refused: this site is individual-only."));assertTrue(fixture.directory.active().isEmpty());
fixture.click(member,fixture.open(member),false);
}else{
fixture.click(member,fixture.open(member),true);assertTrue(member.messages.contains("Only the active party leader may accept a party quest."));
assertTrue(fixture.directory.active().isEmpty());
if(mode.equals("party-required")){fixture.click(leader,fixture.open(leader),false);
assertTrue(leader.messages.contains("Quest acceptance refused: this site is party-required."));assertTrue(fixture.directory.active().isEmpty());}
fixture.click(leader,fixture.open(leader),true);
}
var accepting=personal?member:leader;
ReskillEventsNativeTest.await(f,()->accepting.messages.stream().anyMatch(value->value.startsWith("Accepted Same Camp;")));
var quest=fixture.directory.active().getFirst();assertEquals(mode,quest.participationMode().value());
assertEquals(personal?AdventureQuestState.UnitType.SOLO:AdventureQuestState.UnitType.PARTY,quest.unitType());
assertEquals(personal?member.core.record.id():party.id(),quest.unit());
assertEquals(personal?Set.of(member.core.record.id()):Set.of(leader.core.record.id(),member.core.record.id()),quest.snapshot());
var durable=(games.dmg.fantasystick.party.CharacterPartyDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterParties");
assertEquals(games.dmg.fantasystick.party.CharacterPartyDirectory.Status.APPLIED,
durable.transfer(leader.core.record.id(),party.id(),member.core.record.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()));
assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());assertFalse(quest.snapshot().contains(added.core.record.id()));
assertTrue(parties.disband(member.core.id,party.id()));fixture.pulse();
if(personal)assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());
else ReskillEventsNativeTest.await(f,()->fixture.directory.findQuest(quest.id()).isEmpty());
assertEquals(leaderBefore,CombatExperienceNativeTest.record(f,leader));assertEquals(memberBefore,CombatExperienceNativeTest.record(f,member));
ReskillEventsNativeTest.await(f,()->{
try{var rows=ReskillEventsNativeTest.records(data);return rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.quest.accepted")).count()==1
&&(personal||rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.quest.expired")).count()==1);}
catch(Exception failure){throw new AssertionError(failure);}
});
f.plugin.setEnabled(false);f.awaitWorkers();
((java.util.concurrent.CompletionStage<?>)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"campAuditDrain"))
.toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
var rows=ReskillEventsNativeTest.records(data);var accepted=rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.quest.accepted")).toList();
assertEquals(1,accepted.size());var values=accepted.getFirst().getAsJsonObject("data");
assertEquals(quest.id().toString(),values.get("quest_id").getAsString());assertEquals(mode,values.get("captured_mode").getAsString());
assertEquals(quest.unit().toString(),values.get("unit_id").getAsString());assertEquals(personal?"solo":"party",values.get("unit_kind").getAsString());
assertEquals(quest.snapshot().stream().map(Object::toString).collect(java.util.stream.Collectors.toSet()),
java.util.stream.StreamSupport.stream(values.getAsJsonArray("accepted_roster").spliterator(),false).map(v->v.getAsString()).collect(java.util.stream.Collectors.toSet()));
assertEquals(accepting.core.id.toString(),accepted.getFirst().get("playerid").getAsString());
if(personal){assertFalse(values.has("party_id"));assertFalse(values.has("accepting_leader_character_id"));}
else{assertEquals(party.id().toString(),values.get("party_id").getAsString());
assertEquals(leader.core.record.id().toString(),values.get("accepting_leader_character_id").getAsString());}
var expired=rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.quest.expired")).toList();assertEquals(personal?0:1,expired.size());
if(!personal){var ended=expired.getFirst().getAsJsonObject("data");assertEquals("party_invalidated",ended.get("reason").getAsString());
assertEquals(quest.id().toString(),ended.get("quest_id").getAsString());assertEquals(mode,ended.get("captured_mode").getAsString());
assertEquals(values.getAsJsonArray("accepted_roster"),ended.getAsJsonArray("accepted_roster"));}
assertTrue(rows.stream().noneMatch(row->row.get("type").getAsString().endsWith("adventure.trophy_earned")||row.get("type").getAsString().endsWith("adventure.reward_claimed")));
}
}
}
@@ -0,0 +1,78 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
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 CampQuestEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void registeredFullRerollExpiresOnlyTheUnfinishedCampAuthorityWithoutRewritingCapturedModeOrRewarding()throws Exception{
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;fixture.camp("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,()->actor.messages.stream().anyMatch(v->v.startsWith("Accepted Same Camp;")));
var quest=fixture.directory.active().getFirst();
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","mode",quest.site().toString(),"party-required"});
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(v->v.startsWith("Updated adventure site")));
assertEquals("individual-only",fixture.directory.findQuest(quest.id()).orElseThrow().participationMode().value());
fixture.resetCatalog();assertTrue(fixture.directory.active().isEmpty());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
ReskillEventsNativeTest.await(f,()->{
try{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.quest.expired")).count()==1;}
catch(Exception failure){throw new AssertionError(failure);}
});
f.plugin.setEnabled(false);f.awaitWorkers();
((java.util.concurrent.CompletionStage<?>)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"campAuditDrain"))
.toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
var rows=ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.quest.expired")).toList();
assertEquals(1,rows.size(),"An actual registered full-day reset must export the acknowledged expiry of its original unfinished camp quest");
var values=rows.getFirst().getAsJsonObject("data");
assertEquals(quest.id().toString(),values.get("quest_id").getAsString());assertEquals(quest.catalog().toString(),values.get("catalog_id").getAsString());
assertEquals(quest.unit().toString(),values.get("unit_id").getAsString());assertEquals("individual-only",values.get("captured_mode").getAsString());
assertEquals("full_day_reroll",values.get("reason").getAsString());assertEquals(quest.expiresAt().toString(),values.get("deadline").getAsString());
assertTrue(ReskillEventsNativeTest.records(data).stream().noneMatch(row->row.get("type").getAsString().endsWith("adventure.reward_claimed")
||row.get("type").getAsString().endsWith("adventure.trophy_earned")));
}
}
@Test void registeredGiverPersonalAcceptanceAndAbandonmentExportTheCapturedCharacterSessionAndQuestAuthority()throws Exception{
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;fixture.camp("party-available");fixture.giver();fixture.resetCatalog();f.activate(actor);
var before=CombatExperienceNativeTest.record(f,actor);var menu=fixture.open(actor);
assertEquals(org.bukkit.Material.PAPER,menu.getItem(0).getType());
fixture.click(actor,menu,false);actor.core.location.setX(13);
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(v->v.startsWith("Accepted Same Camp;")));
var quest=fixture.directory.active().getFirst();assertEquals(actor.core.record.id(),quest.unit());
assertEquals(before,CombatExperienceNativeTest.record(f,actor));
var acceptedMenu=fixture.open(actor);fixture.click(actor,acceptedMenu,true);
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(v->v.equals("Adventure quest abandoned.")));
assertTrue(fixture.directory.active().isEmpty());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
ReskillEventsNativeTest.await(f,()->{
try{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.quest.accepted")
||row.get("type").getAsString().endsWith("adventure.quest.abandoned")).count()==2;}
catch(Exception failure){throw new AssertionError(failure);}
});
f.plugin.setEnabled(false);f.awaitWorkers();
((java.util.concurrent.CompletionStage<?>)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"campAuditDrain"))
.toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
var rows=ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.quest.accepted")
||row.get("type").getAsString().endsWith("adventure.quest.abandoned")).toList();
assertEquals(2,rows.size(),"Actual registered personal camp acceptance and acknowledged abandonment must reach their owning JSONL records");
for(var row:rows){var values=row.getAsJsonObject("data");
assertEquals(quest.id().toString(),values.get("quest_id").getAsString());assertEquals(quest.catalog().toString(),values.get("catalog_id").getAsString());
assertEquals(quest.site().toString(),values.get("site_id").getAsString());assertEquals("solo",values.get("unit_kind").getAsString());
assertEquals(quest.unit().toString(),values.get("unit_id").getAsString());assertEquals("party-available",values.get("captured_mode").getAsString());
assertEquals(quest.expiresAt().toString(),values.get("deadline").getAsString());assertEquals("acknowledged",values.get("outcome").getAsString());
assertEquals(actor.core.id.toString(),row.get("playerid").getAsString());assertEquals(quest.unit().toString(),row.get("characterid").getAsString());
assertEquals(java.util.Set.of(quest.unit().toString()),java.util.stream.StreamSupport.stream(values.getAsJsonArray("accepted_roster").spliterator(),false)
.map(v->v.getAsString()).collect(java.util.stream.Collectors.toSet()));
}
var accepted=rows.stream().filter(row->row.get("type").getAsString().endsWith("accepted")).findFirst().orElseThrow();
assertEquals(12,accepted.getAsJsonObject("data").getAsJsonObject("location").get("x").getAsDouble());
assertTrue(actor.messages.stream().filter(v->v.startsWith("Accepted Same Camp;")).noneMatch(v->v.contains("(12, 70, -8)")||v.contains("x=12")||v.contains(quest.site().toString())));
}
}
}
@@ -0,0 +1,91 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;
import games.dmg.fantasystick.adventure.AdventureQuestDirectory;
import games.dmg.fantasystick.persistence.YamlAdventureQuestRepository;
import java.nio.file.Path;
import java.util.Map;
import java.util.UUID;
import org.bukkit.configuration.file.YamlConfiguration;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class CampRegistrationRevisionNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void acknowledgedModeChangeAdvancesRevisionWhileUnchangedStaleAndFailedWritesRetainIt() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(actor.core.id, actor.player);
when(actor.world.getUID()).thenReturn(UUID.randomUUID());
when(actor.world.getName()).thenReturn("Revision Realm");
var directory = (AdventureQuestDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "adventureQuests");
f.adminCommand.execute(actor.player, "fstickadmin", new String[]{"adventure-site", "place", "camp", "Original"});
ReskillEventsNativeTest.await(f, () -> actor.messages.stream().anyMatch(v -> v.startsWith("Placed camp")));
var original = directory.snapshot().sites().getFirst();
f.adminCommand.execute(actor.player, "fstickadmin", new String[]{"adventure-site", "mode", original.id().toString(), "individual-only"});
ReskillEventsNativeTest.await(f, () -> actor.messages.stream().anyMatch(v -> v.startsWith("Updated adventure site")));
var changed = directory.findSite(original.id()).orElseThrow();
assertNotNull(changed.registrationRevision(), "Acknowledged camp mode publication must retain an independent revision");
assertNotEquals(original.registrationRevision(), changed.registrationRevision());
assertEquals(original.id(), changed.id());
assertEquals(changed, new YamlAdventureQuestRepository(data).load().sites().getFirst());
assertEquals(AdventureQuestDirectory.ModeStatus.UNCHANGED, directory.setSiteMode(changed.id(),
changed.participationMode(), changed.participationMode()).get(5, java.util.concurrent.TimeUnit.SECONDS).status());
assertEquals(AdventureQuestDirectory.ModeStatus.STALE, directory.setSiteMode(changed.id(),
original.participationMode(), games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.PARTY_REQUIRED)
.get(5, java.util.concurrent.TimeUnit.SECONDS).status());
assertEquals(changed, directory.findSite(changed.id()).orElseThrow());
var file = data.resolve("adventure-quests.yml"); var saved = java.nio.file.Files.readAllBytes(file);
java.nio.file.Files.delete(file); java.nio.file.Files.createDirectory(file);
assertThrows(java.util.concurrent.ExecutionException.class, () -> directory.setSiteMode(changed.id(),
changed.participationMode(), games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.PARTY_REQUIRED)
.get(5, java.util.concurrent.TimeUnit.SECONDS));
assertEquals(changed, directory.findSite(changed.id()).orElseThrow());
java.nio.file.Files.delete(file); java.nio.file.Files.write(file, saved);
var removed = directory.removeSite(changed.id()).get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
assertEquals(changed, removed); assertTrue(directory.findSite(changed.id()).isEmpty());
f.plugin.setEnabled(false); f.awaitWorkers();
}
}
@Test void registeredCampAndUnloadedGiverPersistIndependentRevisionsBeforeSuccessAndReload() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data); actor.allowed = true;
f.players.put(actor.core.id, actor.player);
UUID world = UUID.randomUUID(); when(actor.world.getUID()).thenReturn(world);
when(actor.world.getName()).thenReturn("Revision Realm");
var directory = (AdventureQuestDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "adventureQuests");
assertTrue(f.adminCommand.getExecutor().onCommand(actor.player, f.adminCommand, "fstickadmin",
new String[]{"adventure-site", "place", "camp", "--mode", "individual-only", "8", "16", "Same Name"}));
ReskillEventsNativeTest.await(f, () -> actor.messages.stream().anyMatch(v -> v.startsWith("Placed camp")));
var site = directory.snapshot().sites().getFirst();
Map<?, ?> row = YamlConfiguration.loadConfiguration(data.resolve("adventure-quests.yml").toFile())
.getMapList("sites").getFirst();
assertNotNull(row.get("registration-revision"),
"Actual registered camp save must persist its independent definition revision before success");
UUID revision = UUID.fromString(row.get("registration-revision").toString());
assertNotEquals(site.id(), revision);
assertEquals(site, new YamlAdventureQuestRepository(data).load().sites().getFirst());
assertTrue(f.adminCommand.getExecutor().onCommand(actor.player, f.adminCommand, "fstickadmin",
new String[]{"quest-giver", "place", "Same Name"}));
ReskillEventsNativeTest.await(f, () -> actor.messages.stream().anyMatch(v -> v.startsWith("Placed Quest Giver")));
var giver = directory.snapshot().givers().getFirst();
var yaml = YamlConfiguration.loadConfiguration(data.resolve("adventure-quests.yml").toFile());
var giverRow = yaml.getMapList("givers").getFirst();
assertNotNull(giverRow.get("registration-revision"));
UUID giverRevision = UUID.fromString(giverRow.get("registration-revision").toString());
assertNotEquals(giver.id(), giverRevision); assertNotEquals(revision, giverRevision);
assertEquals(giver, new YamlAdventureQuestRepository(data).load().givers().getFirst());
assertEquals(revision.toString(), yaml.getMapList("sites").getFirst().get("registration-revision"));
assertEquals(2, yaml.getInt("schema-version"));
f.plugin.setEnabled(false); f.awaitWorkers();
}
}
}
@@ -0,0 +1,111 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;
import games.dmg.fantasystick.adventure.AdventureQuestDirectory;
import java.nio.file.Path;
import java.util.UUID;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class CampSiteEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void sameNameCampModeAndRemovalExportOnlyTheirActualAcknowledgedIdentityAndRetainFailedState() throws Exception {
try (var f=new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(actor.core.id,actor.player);when(actor.world.getUID()).thenReturn(UUID.randomUUID());
when(actor.world.getName()).thenReturn("Camp Realm");
var directory=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
for(int i=0;i<2;i++){
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","place","camp","Same Name"});
final int expected=i+1;ReskillEventsNativeTest.await(f,()->directory.snapshot().sites().size()==expected);
}
var first=directory.snapshot().sites().getFirst();var other=directory.snapshot().sites().getLast();
var console=new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(console.port,"fstickadmin",new String[]{"adventure-site","mode",first.id().toString(),"individual-only"});
ReskillEventsNativeTest.await(f,()->console.messages.stream().anyMatch(v->v.startsWith("Updated adventure site")));
var changed=directory.findSite(first.id()).orElseThrow();
f.adminCommand.execute(console.port,"fstickadmin",new String[]{"adventure-site","mode",first.id().toString(),"individual-only"});
ReskillEventsNativeTest.await(f,()->console.messages.stream().anyMatch(v->v.contains("already individual-only")));
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(console.port,"fstickadmin",new String[]{"adventure-site","mode",first.id().toString(),"party-required"});
ReskillEventsNativeTest.await(f,()->console.messages.stream().anyMatch(v->v.startsWith("Adventure site mode save failed")));
assertEquals(changed,directory.findSite(first.id()).orElseThrow());
java.nio.file.Files.delete(file);java.nio.file.Files.write(file,bytes);
f.adminCommand.execute(console.port,"fstickadmin",new String[]{"adventure-site","remove",first.id().toString()});
ReskillEventsNativeTest.await(f,()->console.messages.stream().anyMatch(v->v.startsWith("Removed adventure site")));
assertTrue(directory.findSite(first.id()).isEmpty());assertEquals(other,directory.findSite(other.id()).orElseThrow());
ReskillEventsNativeTest.await(f,()->{
try{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.site.mode_changed")
||row.get("type").getAsString().endsWith("adventure.site.removed")).count()==2;}
catch(Exception failure){throw new AssertionError(failure);}
});
f.plugin.setEnabled(false);f.awaitWorkers();
((java.util.concurrent.CompletionStage<?>)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"campAuditDrain"))
.toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
var rows=ReskillEventsNativeTest.records(data).stream().filter(row->
row.get("type").getAsString().endsWith("adventure.site.mode_changed")||row.get("type").getAsString().endsWith("adventure.site.removed")).toList();
assertEquals(2,rows.size(),"Actual changed mode and removed camp must each export one owner outcome, never unchanged or failed copies");
var mode=rows.stream().filter(row->row.get("type").getAsString().endsWith("mode_changed")).findFirst().orElseThrow().getAsJsonObject("data");
assertEquals(first.id().toString(),mode.get("site_id").getAsString());
assertEquals(changed.registrationRevision().toString(),mode.get("site_revision").getAsString());
assertEquals("party-available",mode.get("mode_before").getAsString());assertEquals("individual-only",mode.get("mode_after").getAsString());
assertEquals("future_acceptances",mode.get("application_scope").getAsString());assertEquals("console",mode.get("actor_kind").getAsString());
var removed=rows.stream().filter(row->row.get("type").getAsString().endsWith("site.removed")).findFirst().orElseThrow().getAsJsonObject("data");
assertEquals(changed.id().toString(),removed.get("site_id").getAsString());
assertEquals(changed.registrationRevision().toString(),removed.get("site_revision").getAsString());
assertEquals("individual-only",removed.get("mode_before").getAsString());assertEquals("not_applicable",removed.get("mode_after").getAsString());
}
}
@Test void actualRegisteredSavedCampExportsOriginalAdministratorRevisionLocationAndGeometry() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(actor.core.id, actor.player);
UUID world = UUID.randomUUID(); when(actor.world.getUID()).thenReturn(world);
when(actor.world.getName()).thenReturn("Camp Realm");
f.activate(actor);
var directory = (AdventureQuestDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
assertTrue(f.adminCommand.getExecutor().onCommand(actor.player,f.adminCommand,"fstickadmin",
new String[]{"adventure-site","place","camp","--mode","party-required","20","60","Same Name"}));
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(v->v.startsWith("Placed camp")));
var site=directory.snapshot().sites().getFirst();
assertNotNull(site.registrationRevision());
actor.core.location.setX(900);
ReskillEventsNativeTest.await(f,()->{
try{return ReskillEventsNativeTest.records(data).stream().anyMatch(row->row.get("type").getAsString().endsWith("adventure.site.registered")
&&row.getAsJsonObject("data").get("site_id").getAsString().equals(site.id().toString()));}
catch(Exception failure){throw new AssertionError(failure);}
});
f.plugin.setEnabled(false); f.awaitWorkers();
((java.util.concurrent.CompletionStage<?>)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"campAuditDrain"))
.toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
var rows=ReskillEventsNativeTest.records(data).stream().filter(row->
row.get("type").getAsString().endsWith("adventure.site.registered")
&& row.getAsJsonObject("data").get("site_id").getAsString().equals(site.id().toString())).toList();
assertEquals(1,rows.size(),"Actual acknowledged registered camp administration must reach its feature-owned JSONL record");
var row=rows.getFirst(); var values=row.getAsJsonObject("data");
assertEquals(actor.core.id.toString(),row.get("playerid").getAsString());
assertEquals(actor.core.record.id().toString(),row.get("characterid").getAsString());
assertEquals("camp",values.get("site_kind").getAsString());
assertEquals("Same Name",values.get("site_name").getAsString());
assertEquals(site.registrationRevision().toString(),values.get("site_revision").getAsString());
assertEquals("persisted",values.get("revision_capture_quality").getAsString());
assertEquals(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("rectangle",values.getAsJsonObject("arena").get("shape").getAsString());
assertEquals(20,values.getAsJsonObject("arena").get("x_blocks").getAsInt());
assertEquals(60,values.getAsJsonObject("arena").get("z_blocks").getAsInt());
assertEquals("party-required",values.get("mode_after").getAsString());
assertEquals("acknowledged",values.get("outcome").getAsString());
assertEquals(12,values.getAsJsonObject("location").get("x").getAsDouble());
}
}
}
@@ -0,0 +1,81 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.adventure.AdventureQuestDirectory;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Chunk;
import org.bukkit.Location;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Villager;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class QuestGiverEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredLoadedGiverPlacementAndRemovalExportSavedFacingRevisionAndActualNativeIdentity() throws Exception {
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(actor.core.id,actor.player);UUID world=UUID.randomUUID();
when(actor.world.getUID()).thenReturn(world);when(actor.world.getName()).thenReturn("Giver Realm");
when(actor.world.isChunkLoaded(anyInt(),anyInt())).thenReturn(true);
var chunk=mock(Chunk.class);when(chunk.getWorld()).thenReturn(actor.world);
when(chunk.getX()).thenReturn(0);when(chunk.getZ()).thenReturn(-1);
var spawned=new ArrayList<Villager>();
when(chunk.getEntities()).thenAnswer(call->spawned.stream().filter(Entity::isValid).toArray(Entity[]::new));
when(actor.world.getEntities()).thenAnswer(call->new ArrayList<Entity>(spawned.stream().filter(Entity::isValid).toList()));
when(actor.world.getLoadedChunks()).thenReturn(new Chunk[]{chunk});
when(actor.world.getChunkAt(anyInt(),anyInt())).thenReturn(chunk);
doAnswer(call->{
var villager=mock(Villager.class);UUID id=UUID.randomUUID();
var pdc=new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());
when(villager.getUniqueId()).thenReturn(id);when(villager.getPersistentDataContainer()).thenReturn(pdc);
when(villager.getWorld()).thenReturn(actor.world);Location at=call.getArgument(0);when(villager.getLocation()).thenReturn(at.clone());
when(villager.isValid()).thenReturn(true);doAnswer(removal->{when(villager.isValid()).thenReturn(false);return null;}).when(villager).remove();
Consumer<? super Villager> configure=call.getArgument(2);configure.accept(villager);spawned.add(villager);return villager;
}).when(actor.world).spawn(any(Location.class),eq(Villager.class),org.mockito.ArgumentMatchers.<Consumer<? super Villager>>any());
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"quest-giver","place","Same Name"});
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(v->v.startsWith("Placed Quest Giver")));
var directory=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
var giver=directory.snapshot().givers().getFirst();assertEquals(1,spawned.size());var entity=spawned.getFirst();
verify(entity).setAI(false);verify(entity).setInvulnerable(true);verify(entity).setGravity(false);verify(entity).setCollidable(false);
UUID entityId=entity.getUniqueId();actor.core.location.setX(900);
var console=new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(console.port,"fstickadmin",new String[]{"quest-giver","remove",giver.id().toString()});
ReskillEventsNativeTest.await(f,()->console.messages.stream().anyMatch(v->v.startsWith("Removed Quest Giver")));
assertTrue(directory.findGiver(giver.id()).isEmpty());assertFalse(entity.isValid());
ReskillEventsNativeTest.await(f,()->{
try{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().contains("adventure.quest_giver.")).count()==2;}
catch(Exception failure){throw new AssertionError(failure);}
});
f.plugin.setEnabled(false);f.awaitWorkers();
((java.util.concurrent.CompletionStage<?>)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"campAuditDrain"))
.toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
var rows=ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().contains("adventure.quest_giver.")).toList();
assertEquals(2,rows.size(),"Actual saved Quest Giver placement/removal and native reconciliation must reach their owner JSONL records");
for(var row:rows){var values=row.getAsJsonObject("data");
assertEquals(giver.id().toString(),values.get("giver_id").getAsString());
assertEquals(giver.registrationRevision().toString(),values.get("giver_revision").getAsString());
assertEquals("Same Name",values.get("giver_name").getAsString());assertEquals(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(45,values.get("yaw").getAsDouble());assertEquals(10,values.get("pitch").getAsDouble());
assertEquals(entityId.toString(),values.get("entity_id").getAsString());assertEquals("loaded",values.get("chunk_status").getAsString());
assertEquals("native",values.get("entity_capture_quality").getAsString());assertEquals("acknowledged",values.get("outcome").getAsString());
assertEquals("unchanged",values.get("catalog_impact").getAsString());
}
var placed=rows.stream().filter(row->row.get("type").getAsString().endsWith("placed")).findFirst().orElseThrow();
assertEquals(actor.core.id.toString(),placed.get("playerid").getAsString());
assertEquals("materialized",placed.getAsJsonObject("data").get("entity_outcome").getAsString());
assertEquals("removed",rows.stream().filter(row->row.get("type").getAsString().endsWith("removed")).findFirst().orElseThrow()
.getAsJsonObject("data").get("entity_outcome").getAsString());
}
}
}
@@ -0,0 +1,86 @@
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.ZoneId;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Random;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class CampCatalogPublicationTest {
@TempDir Path data;
private static AdventureQuestSettings.Rules rules(int count){return new AdventureQuestSettings.Rules(count,16,30,8,BigInteger.valueOf(100),BigInteger.valueOf(20),100,8,BigInteger.valueOf(150),BigInteger.valueOf(40));}
private static final class MutableClock extends Clock {
private Instant now=Instant.now().truncatedTo(java.time.temporal.ChronoUnit.DAYS);
@Override public ZoneId getZone(){return ZoneOffset.UTC;}
@Override public Clock withZone(ZoneId zone){return Clock.fixed(now,zone);}
@Override public Instant instant(){return now;}
}
@Test void initialAndUtcRolloverPublishOnlySavedCompleteCatalogWithOriginalRollSettingsAndDefinitions()throws Exception{
var clock=new MutableClock();var settings=new AtomicReference<>(rules(3));
var publications=new CopyOnWriteArrayList<AdventureQuestDirectory.CatalogPublication>();
try(var directory=new AdventureQuestDirectory(new YamlAdventureQuestRepository(data),clock,settings::get,new Random(42))){
directory.attachCatalogEvents(publications::add);
var site=directory.placeSite(AdventureQuestState.SiteType.CAMP,"Same",UUID.randomUUID(),12,70,-8).get(5,TimeUnit.SECONDS);
var first=directory.ensureToday().get(5,TimeUnit.SECONDS);assertEquals(1,publications.size());assertEquals("initial_current_day",publications.getFirst().reason());
assertEquals(first,publications.getFirst().catalog());assertEquals(first,directory.ensureToday().get(5,TimeUnit.SECONDS));assertEquals(1,publications.size());
clock.now=clock.now.plusSeconds(86400);var next=directory.ensureToday().get(5,TimeUnit.SECONDS);var reset=directory.snapshot().reset();
assertNotEquals(first.revision(),next.revision());assertEquals(1,publications.size(),"A pending reset is not a complete catalog publication");
assertFalse(directory.completeReset(UUID.randomUUID()).get(5,TimeUnit.SECONDS));assertEquals(1,publications.size());
settings.set(rules(4));directory.setSiteMode(site.id(),site.participationMode(),AdventureQuestState.ParticipationMode.PARTY_REQUIRED).get(5,TimeUnit.SECONDS);
var file=data.resolve("adventure-quests.yml");var bytes=Files.readAllBytes(file);Files.delete(file);Files.createDirectory(file);
assertThrows(java.util.concurrent.ExecutionException.class,()->directory.completeReset(reset.operation()).get(5,TimeUnit.SECONDS));
assertEquals(reset,directory.snapshot().reset());assertEquals(1,publications.size());Files.delete(file);Files.write(file,bytes);
assertTrue(directory.completeReset(reset.operation()).get(5,TimeUnit.SECONDS));assertEquals(2,publications.size());
var published=publications.getLast();assertEquals(next,published.catalog());assertEquals("utc_day_rollover",published.reason());
assertEquals(3,published.settings().dailySiteCount());assertEquals(List.of(site),published.selected());assertTrue(published.originalCapture());
assertNull(directory.snapshot().reset());
}
}
@Test void restartPendingResetCannotBackfillOriginalRollSettingsOrActorMetadata()throws Exception{
var clock=new MutableClock();var repository=new YamlAdventureQuestRepository(data);UUID operation;
try(var directory=new AdventureQuestDirectory(repository,clock,()->rules(3),new Random(42))){
directory.placeSite(AdventureQuestState.SiteType.CAMP,"Legacy recovery",UUID.randomUUID(),12,70,-8).get(5,TimeUnit.SECONDS);
directory.ensureToday().get(5,TimeUnit.SECONDS);directory.rerollToday().get(5,TimeUnit.SECONDS);operation=directory.snapshot().reset().operation();
}
var publications=new CopyOnWriteArrayList<AdventureQuestDirectory.CatalogPublication>();
try(var restarted=new AdventureQuestDirectory(repository,clock,()->rules(4),new Random(8))){
restarted.attachCatalogEvents(publications::add);assertTrue(restarted.completeReset(operation).get(5,TimeUnit.SECONDS));assertEquals(1,publications.size());
var published=publications.getFirst();assertEquals(operation,published.operation());assertEquals("recovery_pending_publication",published.reason());
assertFalse(published.originalCapture());assertNull(published.settings());assertNull(published.actor());
assertFalse(restarted.completeReset(operation).get(5,TimeUnit.SECONDS));assertEquals(1,publications.size());
}
}
@Test void onlyAcknowledgedUnfinishedCampRemovalExpiresAllOriginalQuestsNotCompletionAbandonmentOrOtherOwners()throws Exception{
var clock=new MutableClock();var expiry=new CopyOnWriteArrayList<AdventureQuestDirectory.QuestExpiry>();
try(var directory=new AdventureQuestDirectory(new YamlAdventureQuestRepository(data),clock,()->rules(3),new Random(42))){
directory.attachQuestExpiryEvents(expiry::add);var site=directory.placeSite(AdventureQuestState.SiteType.CAMP,"Same",UUID.randomUUID(),12,70,-8).get(5,TimeUnit.SECONDS);
var champion=directory.placeSite(AdventureQuestState.SiteType.CHAMPION,"Same",site.world(),12,70,-8).get(5,TimeUnit.SECONDS);var catalog=directory.ensureToday().get(5,TimeUnit.SECONDS);
UUID a=UUID.randomUUID(),b=UUID.randomUUID();var completed=directory.accept(catalog.revision(),site.id(),AdventureQuestState.UnitType.SOLO,a,Set.of(a)).get(5,TimeUnit.SECONDS).quest().orElseThrow();
var abandoned=directory.accept(catalog.revision(),site.id(),AdventureQuestState.UnitType.SOLO,b,Set.of(b)).get(5,TimeUnit.SECONDS).quest().orElseThrow();
assertTrue(directory.complete(completed.id(),Set.of(a)).get(5,TimeUnit.SECONDS));assertTrue(directory.abandon(abandoned.id(),b).get(5,TimeUnit.SECONDS));assertTrue(expiry.isEmpty());
UUID c=UUID.randomUUID(),d=UUID.randomUUID(),e=UUID.randomUUID();var first=directory.accept(catalog.revision(),site.id(),AdventureQuestState.UnitType.SOLO,c,Set.of(c)).get(5,TimeUnit.SECONDS).quest().orElseThrow();
var second=directory.accept(catalog.revision(),site.id(),AdventureQuestState.UnitType.SOLO,d,Set.of(d)).get(5,TimeUnit.SECONDS).quest().orElseThrow();
directory.accept(catalog.revision(),champion.id(),AdventureQuestState.UnitType.SOLO,e,Set.of(e)).get(5,TimeUnit.SECONDS);
directory.removeSite(champion.id()).get(5,TimeUnit.SECONDS);assertTrue(expiry.isEmpty());
directory.setSiteMode(site.id(),site.participationMode(),AdventureQuestState.ParticipationMode.PARTY_REQUIRED).get(5,TimeUnit.SECONDS);
directory.removeSite(site.id()).get(5,TimeUnit.SECONDS);assertEquals(2,expiry.size());assertEquals(Set.of(first,second),expiry.stream().map(AdventureQuestDirectory.QuestExpiry::quest).collect(java.util.stream.Collectors.toSet()));
assertTrue(expiry.stream().allMatch(value->value.reason().equals("site_removed")&&value.quest().participationMode()==AdventureQuestState.ParticipationMode.PARTY_AVAILABLE));
assertEquals(expiry.getFirst().operation(),expiry.getLast().operation(),"One removal has two independent quest identities under its actual operation");
}
}
}
@@ -0,0 +1,72 @@
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.Random;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class CampRegistrationRevisionTest {
@TempDir Path data;
private final Clock clock = Clock.fixed(Instant.parse("2026-09-23T19:00:00Z"), ZoneOffset.UTC);
private final AdventureQuestSettings.Rules rules = new AdventureQuestSettings.Rules(3,16,30,8,
BigInteger.valueOf(100),BigInteger.valueOf(20),100,8,BigInteger.valueOf(150),BigInteger.valueOf(40));
@Test void legacySchemaOneAndTwoReadsRemainByteIdenticalWithExplicitlyUnavailableRevisions() throws Exception {
var site = new AdventureQuestState.Site(UUID.randomUUID(),AdventureQuestState.SiteType.CAMP,"Legacy",
UUID.randomUUID(),12,70,-8);
var giver = new AdventureQuestState.Giver(UUID.randomUUID(),"Legacy",site.world(),12,70,-8,45,10);
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(giver),catalog,List.of(),Set.of(),Set.of(),Map.of());
var repository = new YamlAdventureQuestRepository(data); repository.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 Random(1))) {
assertNull(directory.findSite(site.id()).orElseThrow().registrationRevision());
assertNull(directory.findGiver(giver.id()).orElseThrow().registrationRevision());
assertEquals(catalog,directory.ensureToday().get(5,TimeUnit.SECONDS));
assertArrayEquals(bytes,Files.readAllBytes(file),"Compatible current-day reads cannot backfill or rewrite registration history");
}
assertEquals(state,new YamlAdventureQuestRepository(data).load());
assertArrayEquals(bytes,Files.readAllBytes(file));
}
}
@Test void newCampAndGiverRevisionsSurviveUnrelatedOtherOwnerWritesAndRemovalCapture() throws Exception {
AdventureQuestState.Site camp; AdventureQuestState.Giver giver;
try (var directory = new AdventureQuestDirectory(new YamlAdventureQuestRepository(data),clock,()->rules,new Random(1))) {
camp=directory.placeSite(AdventureQuestState.SiteType.CAMP,"Same",UUID.randomUUID(),0,64,0).get(5,TimeUnit.SECONDS);
giver=directory.placeGiver("Same",camp.world(),0,64,0,45,10).get(5,TimeUnit.SECONDS);
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");
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());
}
try(var restarted=new AdventureQuestDirectory(new YamlAdventureQuestRepository(data),clock,()->rules,new Random(2))) {
assertEquals(camp,restarted.findSite(camp.id()).orElseThrow());
assertEquals(giver,restarted.findGiver(giver.id()).orElseThrow());
assertEquals(giver,restarted.removeGiver(giver.id()).get(5,TimeUnit.SECONDS).orElseThrow());
assertEquals(camp,restarted.removeSite(camp.id()).get(5,TimeUnit.SECONDS).orElseThrow());
}
}
}