feat(adventure): export acknowledged day-reset owner events
Release / release (push) Failing after 12m53s

This commit is contained in:
dmg committed 2026-10-03 04:42:58 -04:00
1 parent e83c6fbabc
commit c6252e8805
10 files changed
+473 -3

No files matched your search

@@ -191,6 +191,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
private java.util.concurrent.CompletionStage<Void> stickKnockbackAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.CampEventBridge campAuditEvents;
private java.util.concurrent.CompletionStage<Void> campAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.AdventureDayResetEventBridge adventureDayResetEvents;
private java.util.concurrent.CompletionStage<Void> adventureDayResetDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.ChampionEventBridge championAuditEvents;
private java.util.concurrent.CompletionStage<Void> championAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.HardMobXpEventBridge hardMobXpAuditEvents;
@@ -933,6 +935,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
adventureQuestController.attachChampionEvents(championAuditEvents);
adventureQuests.attachQuestExpiryEvents(campAuditEvents::questExpired);
adventureQuests.attachChampionQuestExpiryEvents(championAuditEvents::questExpired);
adventureDayResetEvents = new games.dmg.fantasystick.events.AdventureDayResetEventBridge(eventLog::emit, getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING, "Adventure-day reset event export failed", failure), adventureDayResetDrain);
adventureDayResetEvents.recover(adventureQuests.snapshot());
adventureQuests.attachDayResetEvents(adventureDayResetEvents::prepared);
dungeons.attachResetCleanupEvents(adventureDayResetEvents::participantCleanup);
adventureQuestController.attachDayResetEvents(adventureDayResetEvents);
trainingAuditEvents = new games.dmg.fantasystick.events.TrainingEventBridge(eventLog::emit, getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING, "Training event intent failed", failure));
admin.attachTrainingEvents(trainingAuditEvents);
@@ -1138,6 +1146,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (failure != null) getLogger().log(Level.WARNING, "Previous champion event drain failed", failure);
if (replayChampionEvents != null) replayChampionEvents.replay();
});
var replayDayResetEvents = adventureDayResetEvents;
adventureDayResetDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous adventure-day reset event drain failed", failure);
if (replayDayResetEvents != null) replayDayResetEvents.replay();
});
var replayTrainingEvents = trainingAuditEvents;
trainingAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous training event drain failed", failure);
@@ -1255,6 +1268,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingHardMobXpEvents = hardMobXpAuditEvents; hardMobXpAuditEvents = null;
var closingCampEvents = campAuditEvents; campAuditEvents = null;
var closingChampionEvents = championAuditEvents; championAuditEvents = null;
var closingDayResetEvents = adventureDayResetEvents; adventureDayResetEvents = null;
var closingTrainingEvents = trainingAuditEvents; trainingAuditEvents = null;
var closingArmorEvents = armorAuditEvents; armorAuditEvents = null;
var closingCoinEvents = coinEvents;
@@ -1652,6 +1666,14 @@ public final class FantasyStickPlugin extends JavaPlugin {
closingChampionEvents.close(); return null;
}).thenCompose(ignored -> closingChampionEvents.drained());
});
if (closingDayResetEvents != null) cleanup("adventure-day reset event intent drain", () -> {
var authorityDrained = closingAdventureQuests == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingAdventureQuests.drained();
adventureDayResetDrain = authorityDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Adventure-day reset authority drain failed", failure);
closingDayResetEvents.close(); return null;
}).thenCompose(ignored -> closingDayResetEvents.drained());
});
if (closingTrainingEvents != null) cleanup("training event intent drain", () -> {
var registrationsDrained = closingTrainingDummies == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingTrainingDummies.drained();
@@ -105,9 +105,11 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
public void attachCampEvents(games.dmg.fantasystick.events.CampEventBridge value){campEvents=Objects.requireNonNull(value);}
private games.dmg.fantasystick.events.ChampionEventBridge championEvents;
public void attachChampionEvents(games.dmg.fantasystick.events.ChampionEventBridge value){championEvents=Objects.requireNonNull(value);}
private games.dmg.fantasystick.events.AdventureDayResetEventBridge dayResetEvents;
public void attachDayResetEvents(games.dmg.fantasystick.events.AdventureDayResetEventBridge value){dayResetEvents=Objects.requireNonNull(value);}
public void registerResetParticipant(AdventureResetParticipant participant){if(closed)throw new IllegalStateException("Adventure controller closed");resetParticipants.add(Objects.requireNonNull(participant));}
public void registerDungeonRoomPresence(java.util.function.BiPredicate<UUID,Location> presence){if(closed)throw new IllegalStateException("Adventure controller closed");dungeonRoomPresence=Objects.requireNonNull(presence);}
public CompletionStage<Boolean> publishPendingReset(){var reset=directory.snapshot().reset();if(reset==null)return CompletableFuture.completedFuture(true);if(resetWork!=null)return resetWork;for(var attempt:new ArrayList<>(attempts.values()))cleanup(attempt,"catalog_reset");attempts.clear();starting.clear();readyAt.clear();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(managed(entity))entity.remove();refreshTrackingPlayers();var work=resetParticipants.stream().map(participant->{try{return participant.reset(reset).toCompletableFuture();}catch(RuntimeException failure){return CompletableFuture.<Void>failedFuture(failure);}}).toArray(CompletableFuture[]::new);resetWork=CompletableFuture.allOf(work).thenCompose(ignored->directory.completeReset(reset.operation())).whenComplete((ignored,failure)->resetWork=null);return resetWork;}
public CompletionStage<Boolean> publishPendingReset(){var reset=directory.snapshot().reset();if(reset==null)return CompletableFuture.completedFuture(true);if(resetWork!=null)return resetWork;var audit=dayResetEvents;if(audit!=null)audit.cleanupObserved(reset,attempts.size(),resetParticipants.size());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->{if(audit!=null)audit.cleanupAcknowledged(reset,resetParticipants.size());return directory.completeReset(reset.operation()).whenComplete((published,failure)->{if(failure==null&&Boolean.TRUE.equals(published)&&audit!=null)audit.completed(reset);});}).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);}
@@ -25,6 +25,11 @@ public final class AdventureQuestDirectory implements AutoCloseable {
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 ResetPublication(AdventureQuestState.Reset reset,AdventureQuestState.Catalog previous,
AdventureQuestState.Catalog catalog,String reason,int expiredQuests,
games.dmg.fantasystick.events.CampEventBridge.Actor actor){}
private volatile java.util.function.Consumer<ResetPublication> resetObserver=ignored->{};
public void attachDayResetEvents(java.util.function.Consumer<ResetPublication> observer){resetObserver=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->{};
@@ -127,6 +132,14 @@ public final class AdventureQuestDirectory implements AutoCloseable {
}
}catch(RuntimeException ignored){/* Metadata observation cannot change durable catalog authority. */}
}
private void observeResetCommit(AdventureQuestState before,Result<?> result){
try{
var after=result.state();var publication=result.publication();
if(before.reset()==null&&after.reset()!=null&&publication!=null)
resetObserver.accept(new ResetPublication(after.reset(),before.catalog(),after.catalog(),
publication.reason().equals("admin_full_reroll")?"manual":"utc_rollover",before.quests().size(),publication.actor()));
}catch(RuntimeException ignored){/* Observing reset installation cannot change its durable authority. */}
}
private void observeQuestCommit(AdventureQuestState before,Result<?> result,String reason,UUID operation){
try{
if(reason.equals("player_abandoned"))return;
@@ -148,7 +161,7 @@ public final class AdventureQuestDirectory implements AutoCloseable {
}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 <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();observeResetCommit(before,result);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);}
@@ -59,7 +59,10 @@ public final class DungeonDirectory implements AutoCloseable,AdventureResetParti
public CompletableFuture<Boolean> acknowledgeCompletion(UUID dungeon,UUID generation,UUID boss){return mutate(state->{var found=state.runs().stream().filter(v->v.dungeon().equals(dungeon)&&v.generation().equals(generation)).findFirst();if(found.isEmpty())return result(state,false);var run=found.orElseThrow();if(!run.defeated()||!boss.toString().equals(run.completionAudit().get("boss_id")))return result(state,false);if(run.completionPublished())return result(state,true);var published=new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),run.clearedRooms(),run.unitClaims(),run.characterClaims(),true,run.completionAudit(),true);var runs=state.runs().stream().map(value->value==run?published:value).toList();return result(new DungeonState(state.dungeons(),runs),true);});}
/** Reconcile prior exported proof before replaying a missing completion, on this I/O worker. */
public CompletableFuture<List<DungeonState.Run>> pendingCompletions(java.util.function.Predicate<DungeonState.Run> exported){return mutate(state->{var runs=new ArrayList<DungeonState.Run>();var pending=new ArrayList<DungeonState.Run>();boolean changed=false;for(var run:state.runs()){if(!run.defeated()||run.completionPublished()||run.completionAudit().isEmpty()){runs.add(run);continue;}if(exported.test(run)){runs.add(new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),run.clearedRooms(),run.unitClaims(),run.characterClaims(),true,run.completionAudit(),true));changed=true;}else{runs.add(run);pending.add(run);}}return result(changed?new DungeonState(state.dungeons(),runs):state,List.copyOf(pending));});}
@Override public CompletionStage<Void> reset(AdventureQuestState.Reset reset){currentGeneration=reset.generation();return mutate(state->result(new DungeonState(state.dungeons(),List.of()),null));}
public record ResetCleanup(AdventureQuestState.Reset reset,int runs){}
private volatile java.util.function.Consumer<ResetCleanup> resetCleanupObserver=ignored->{};
public void attachResetCleanupEvents(java.util.function.Consumer<ResetCleanup> observer){resetCleanupObserver=Objects.requireNonNull(observer);}
@Override public CompletionStage<Void> reset(AdventureQuestState.Reset reset){currentGeneration=reset.generation();return mutate(state->result(new DungeonState(state.dungeons(),List.of()),state.runs().size())).thenAccept(count->{try{resetCleanupObserver.accept(new ResetCleanup(reset,count));}catch(RuntimeException ignored){/* Successful source cleanup does not depend on audit. */}});}
private <T>CompletableFuture<T> updateRoom(UUID id,UUID roomId,java.util.function.UnaryOperator<DungeonState.Room> operation,T result){return mutate(state->{var dungeon=editable(state,id);var found=dungeon.rooms().stream().filter(v->v.id().equals(roomId)).findFirst().orElseThrow(()->new IllegalArgumentException("Room not found"));var updated=operation.apply(found);var rooms=dungeon.rooms().stream().map(v->v.id().equals(roomId)?updated:v).toList();return result(replace(state,definition(dungeon,rooms,dungeon.entrance(),false)),result);});}
private CompletableFuture<DungeonState.Run> updateRun(UUID dungeon,UUID generation,java.util.function.UnaryOperator<DungeonState.Run> operation){return mutate(state->{requireGeneration(generation);var existing=state.runs().stream().filter(v->v.dungeon().equals(dungeon)&&v.generation().equals(generation)).findFirst().orElse(new DungeonState.Run(dungeon,generation,Set.of(),Set.of(),Set.of(),Set.of(),false));var updated=operation.apply(existing);var runs=state.runs().stream().filter(v->!(v.dungeon().equals(dungeon)&&v.generation().equals(generation))).collect(java.util.stream.Collectors.toCollection(ArrayList::new));runs.add(updated);return result(new DungeonState(state.dungeons(),runs),updated);});}
private CompletableFuture<DungeonState.Dungeon> change(UUID id,java.util.function.UnaryOperator<DungeonState.Dungeon> operation){return mutate(state->{var updated=operation.apply(editable(state,id));return result(replace(state,updated),updated);});}
@@ -0,0 +1,114 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.adventure.AdventureQuestDirectory;
import games.dmg.fantasystick.adventure.AdventureQuestState;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Consumer;
import java.util.function.Function;
/** Independent observations; no audit fact is reset authority and no replay calls gameplay. */
public final class AdventureDayResetEventBridge implements AutoCloseable {
private final FeatureEventOutbox outbox;
private final AdventureDayResetFactsStore store;
private final Consumer<Throwable> failures;
private final Clock clock=Clock.systemUTC();
private final ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-adventure-day-reset-facts"));
private final Map<UUID,AdventureDayResetFactsStore.Facts> facts=new LinkedHashMap<>();
private final Map<UUID,Map<String,Object>> cleanup=new LinkedHashMap<>();
private final Set<UUID> handedOff=new java.util.HashSet<>();
private final Set<UUID> recoveredPending=new java.util.HashSet<>();
private final Map<UUID,Integer> participantRuns=new LinkedHashMap<>();
private final CompletableFuture<Void> drained=new CompletableFuture<>();
private boolean closed;
public AdventureDayResetEventBridge(Function<EventInput,CompletionStage<LocalEventLog.Delivery>> sink,Path data,Consumer<Throwable> failures){
this(sink,data,failures,CompletableFuture.completedFuture(null));
}
public AdventureDayResetEventBridge(Function<EventInput,CompletionStage<LocalEventLog.Delivery>> sink,Path data,Consumer<Throwable> failures,CompletionStage<Void> previousDrain){
this.failures=failures;store=new AdventureDayResetFactsStore(data);
outbox=new FeatureEventOutbox(data,"adventure-day-reset-event-intents",Set.of("adventure.day.reset"),event->sink.apply(event).whenComplete((delivery,failure)->{
if(failure==null&&delivery!=LocalEventLog.Delivery.REJECTED)submit(()->{store.remove(event.actionId());facts.remove(event.actionId());cleanup.remove(event.actionId());handedOff.remove(event.actionId());recoveredPending.remove(event.actionId());participantRuns.remove(event.actionId());});
}),failures);
submit(()->{previousDrain.toCompletableFuture().join();load();});
}
public void prepared(AdventureQuestDirectory.ResetPublication publication){submit(()->{
var reset=publication.reset();if(facts.containsKey(reset.operation()))return;var values=publicationData(publication);var work=cleanup.get(reset.operation());if(work!=null)values.putAll(work);if(participantRuns.containsKey(reset.operation())){values.put("cleaned_run_count",participantRuns.get(reset.operation()));values.put("run_cleanup_capture_quality","acknowledged_dungeon_source_save");}
put(new AdventureDayResetFactsStore.Facts(reset.operation(),reset.generation(),reset.startedAt(),publication.actor()==null?null:publication.actor().context(),values,null));
});}
public void cleanupObserved(AdventureQuestState.Reset reset,int encounters,int participants){submit(()->{
var prior=facts.get(reset.operation());boolean unknown=recoveredPending.contains(reset.operation())&&(prior==null||!prior.data().containsKey("cleaned_encounter_count"));
var original=cleanup.computeIfAbsent(reset.operation(),ignored->Map.of(unknown?"recovery_cleaned_encounter_count":"cleaned_encounter_count",encounters,"participant_count",participants,"cleanup_capture_quality",unknown?"recovery_current_work_only":"native_work_observed_before_cleanup"));
if(prior!=null&&prior.completedAt()==null){var values=new LinkedHashMap<>(prior.data());values.putAll(original);put(withData(prior,values));}
});}
public void participantCleanup(games.dmg.fantasystick.dungeon.DungeonDirectory.ResetCleanup observed){submit(()->{
var operation=observed.reset().operation();participantRuns.putIfAbsent(operation,observed.runs());var prior=facts.get(operation);if(prior==null||prior.completedAt()!=null)return;
var values=new LinkedHashMap<>(prior.data());boolean unknown=recoveredPending.contains(operation)&&!values.containsKey("cleaned_run_count");
values.putIfAbsent(unknown?"recovery_cleaned_run_count":"cleaned_run_count",participantRuns.get(operation));values.putIfAbsent("run_cleanup_capture_quality",unknown?"recovery_current_work_only":"acknowledged_dungeon_source_save");put(withData(prior,values));
});}
public void cleanupAcknowledged(AdventureQuestState.Reset reset,int participants){submit(()->{
var prior=facts.get(reset.operation());if(prior==null||prior.completedAt()!=null)return;var values=new LinkedHashMap<>(prior.data());
values.put("participant_count",participants);values.put("participant_outcomes",participantOutcomes(participants));put(withData(prior,values));
});}
/** Only called following the final successful source save, not initial installation or cleanup alone. */
public void completed(AdventureQuestState.Reset reset){submit(()->complete(reset.operation(),"original_final_source_acknowledgment"));}
/** A cleared source reset marker at the same captured generation proves final publication, not a new reset. */
public void recover(AdventureQuestState state){submit(()->{
load();
if(state.reset()!=null&&!facts.containsKey(state.reset().operation())){
var reset=state.reset();var values=new LinkedHashMap<String,Object>();values.put("operation_id",reset.operation().toString());values.put("catalog_id",reset.generation().toString());values.put("generation_id",reset.generation().toString());values.put("reset_started_at",reset.startedAt().toString());
if(state.catalog()!=null)catalogData(values,state.catalog());values.put("reason","unavailable");values.put("actor_kind","unavailable");values.put("original_capture_quality","unavailable");values.put("previous_capture_quality","unavailable");
recoveredPending.add(reset.operation());put(new AdventureDayResetFactsStore.Facts(reset.operation(),reset.generation(),reset.startedAt(),null,values,null));
}
for(var value:java.util.List.copyOf(facts.values())){
if(value.completedAt()!=null)handoff(value);
else if(state.reset()==null&&state.catalog()!=null&&state.catalog().revision().equals(value.generation()))complete(value.operation(),"recovered_final_source_marker");
}
});}
private void complete(UUID operation,String quality){
var prior=facts.get(operation);if(prior==null)return;if(prior.completedAt()!=null){handoff(prior);return;}
var values=new LinkedHashMap<>(prior.data());preservationPolicy(values);values.put("outcome","acknowledged");values.put("completion_capture_quality",quality);
values.put("all_current_participants_acknowledged",true);
if(!values.containsKey("participant_outcomes")){
if(quality.equals("original_final_source_acknowledgment")&&values.get("participant_count") instanceof Integer count)values.put("participant_outcomes",participantOutcomes(count));
else values.put("participant_outcome_capture_quality","source_final_marker_original_details_unavailable");
}
var completed=new AdventureDayResetFactsStore.Facts(prior.operation(),prior.generation(),prior.started(),prior.context(),values,clock.instant());put(completed);handoff(completed);
}
private void handoff(AdventureDayResetFactsStore.Facts value){
if(value.completedAt()==null||!handedOff.add(value.operation()))return;
UUID id=UUID.nameUUIDFromBytes(("fantasystick:adventure-day-reset:"+value.operation()+":completed").getBytes(StandardCharsets.UTF_8));
outbox.accepted(new EventInput(id,value.operation(),value.completedAt(),"adventure.day.reset",value.context(),value.data()));
}
private void load()throws java.io.IOException{
for(var value:store.load()){
if(facts.putIfAbsent(value.operation(),value)==null&&value.completedAt()==null)recoveredPending.add(value.operation());
if(value.data().get("cleaned_run_count") instanceof Integer count)participantRuns.putIfAbsent(value.operation(),count);
if(value.data().containsKey("cleaned_encounter_count"))cleanup.putIfAbsent(value.operation(),Map.of("cleaned_encounter_count",value.data().get("cleaned_encounter_count"),"participant_count",value.data().get("participant_count"),"cleanup_capture_quality",value.data().get("cleanup_capture_quality")));
}
}
private void put(AdventureDayResetFactsStore.Facts value){facts.put(value.operation(),value);try{store.save(value);}catch(java.io.IOException|RuntimeException failure){report(failure);}}
private static AdventureDayResetFactsStore.Facts withData(AdventureDayResetFactsStore.Facts value,Map<String,Object> data){return new AdventureDayResetFactsStore.Facts(value.operation(),value.generation(),value.started(),value.context(),data,value.completedAt());}
private static LinkedHashMap<String,Object> publicationData(AdventureQuestDirectory.ResetPublication publication){
var reset=publication.reset();var values=new LinkedHashMap<String,Object>();values.put("reason",publication.reason());values.put("operation_id",reset.operation().toString());values.put("generation_id",reset.generation().toString());values.put("reset_started_at",reset.startedAt().toString());values.put("original_capture_quality","original_source_publication");catalogData(values,publication.catalog());
var before=publication.previous();if(before!=null){values.put("previous_catalog_id",before.revision().toString());values.put("previous_generation_id",before.revision().toString());values.put("previous_day",before.day().toString());values.put("previous_created_at",before.createdAt().toString());values.put("previous_expires_at",before.expiresAt().toString());}else values.put("previous_capture_quality","unavailable");
values.put("expired_quest_count",publication.expiredQuests());if(publication.actor()!=null)values.putAll(publication.actor().captured());else{values.put("actor_kind","system");values.put("location_status","not_applicable");}return values;
}
private static void catalogData(Map<String,Object> values,AdventureQuestState.Catalog catalog){values.put("catalog_id",catalog.revision().toString());values.put("day",catalog.day().toString());values.put("created_at",catalog.createdAt().toString());values.put("expires_at",catalog.expiresAt().toString());values.put("selected_site_count",catalog.sites().size());values.put("selected_site_ids",catalog.sites().stream().map(UUID::toString).toList());}
private static Object participantOutcomes(int count){return java.util.stream.IntStream.range(0,count).mapToObj(index->Map.<String,Object>of("participant_index",(double)index,"outcome","acknowledged")).toList();}
private static void preservationPolicy(Map<String,Object> values){for(String key:java.util.List.of("earned_rewards_preserved","earned_trophies_preserved","unlocked_shares_preserved","acknowledged_kill_rewards_preserved","pending_proof_preserved","fishing_stock_preserved","inventory_preserved","active_work_expired","current_generation_gates_reset"))values.put(key,true);}
public void replay(){submit(()->{load();for(var value:facts.values())if(value.completedAt()!=null)handoff(value);outbox.replay();});}
@FunctionalInterface private interface Work{void run()throws java.io.IOException;}
private synchronized void submit(Work action){if(closed)return;try{worker.execute(()->{try{action.run();}catch(java.io.IOException|RuntimeException failure){report(failure);}});}catch(RuntimeException failure){report(failure);}}
private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){/* Failure reporting is not gameplay authority. */}}
public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}
@Override public synchronized void close(){if(closed)return;closed=true;worker.execute(()->{outbox.close();outbox.drained().whenComplete((ignored,failure)->{if(failure==null)drained.complete(null);else drained.completeExceptionally(failure);});});worker.shutdown();}
}
@@ -0,0 +1,74 @@
package games.dmg.fantasystick.events;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/** Owner-local observation facts, never a gameplay phase, permission or reset receipt. Worker-only I/O. */
final class AdventureDayResetFactsStore {
record Facts(UUID operation,UUID generation,Instant started,EventInput.Context context,Map<String,Object> data,Instant completedAt){
Facts{data=new EventInput(operation,operation,started,"adventure.day.reset",context,data).data();}
}
private static final int MAX_BYTES=65_536;
private final Path directory;
AdventureDayResetFactsStore(Path data){directory=data.toAbsolutePath().resolve("adventure-day-reset-facts");}
List<Facts> load()throws IOException{
if(!Files.exists(directory,LinkOption.NOFOLLOW_LINKS))return List.of();safeDirectory();var result=new ArrayList<Facts>();
try(var paths=Files.list(directory)){for(var path:paths.filter(value->value.getFileName().toString().matches("[0-9a-f-]{36}\\.facts")).sorted().toList()){
if(!Files.isRegularFile(path,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe reset facts file");
byte[] bytes;try(var input=Files.newInputStream(path,LinkOption.NOFOLLOW_LINKS)){bytes=input.readNBytes(MAX_BYTES+1);}if(bytes.length>MAX_BYTES)throw new IOException("Oversized reset facts");
try{
var root=JsonParser.parseString(new String(bytes,StandardCharsets.UTF_8)).getAsJsonObject();if(!text(root,"format").equals("adventure_day_reset_facts_v1"))throw new IOException("Unknown reset facts format");
UUID operation=UUID.fromString(text(root,"operation"));if(!path.getFileName().toString().equals(operation+".facts"))throw new IOException("Reset facts identity mismatch");
EventInput.Context context=null;if(root.has("context")){
var value=root.getAsJsonObject("context");EventInput.Location location=null;
if(value.has("world_id"))location=new EventInput.Location(UUID.fromString(text(value,"world_id")),text(value,"world_name"),value.get("x").getAsDouble(),value.get("y").getAsDouble(),value.get("z").getAsDouble(),Instant.parse(text(value,"captured_at")),text(value,"quality"));
context=new EventInput.Context(UUID.fromString(text(value,"player_id")),text(value,"player_name"),uuid(value,"character_id"),value.has("character_name")?text(value,"character_name"):null,uuid(value,"session_id"),location);
}
result.add(new Facts(operation,UUID.fromString(text(root,"generation")),Instant.parse(text(root,"started_at")),context,object(root.getAsJsonObject("data"),0),root.has("completed_at")?Instant.parse(text(root,"completed_at")):null));
}catch(RuntimeException failure){throw new IOException("Invalid reset observation facts",failure);}
}}return List.copyOf(result);
}
void save(Facts facts)throws IOException{
if(Files.exists(directory,LinkOption.NOFOLLOW_LINKS))safeDirectory();else Files.createDirectories(directory);
var root=new JsonObject();root.addProperty("format","adventure_day_reset_facts_v1");root.addProperty("operation",facts.operation().toString());root.addProperty("generation",facts.generation().toString());root.addProperty("started_at",facts.started().toString());
if(facts.completedAt()!=null)root.addProperty("completed_at",facts.completedAt().toString());
if(facts.context()!=null){var c=facts.context();var value=new JsonObject();value.addProperty("player_id",c.playerId().toString());value.addProperty("player_name",c.playerName());
if(c.characterId()!=null){value.addProperty("character_id",c.characterId().toString());value.addProperty("character_name",c.characterName());value.addProperty("session_id",c.sessionId().toString());}
if(c.location()!=null){var l=c.location();value.addProperty("world_id",l.worldId().toString());value.addProperty("world_name",l.worldName());value.addProperty("x",l.x());value.addProperty("y",l.y());value.addProperty("z",l.z());value.addProperty("captured_at",l.capturedAt().toString());value.addProperty("quality",l.quality());}root.add("context",value);
}
root.add("data",new Gson().toJsonTree(facts.data()));byte[] bytes=root.toString().getBytes(StandardCharsets.UTF_8);if(bytes.length>MAX_BYTES)throw new IOException("Oversized reset facts");
var target=directory.resolve(facts.operation()+".facts");if(Files.exists(target,LinkOption.NOFOLLOW_LINKS)&&!Files.isRegularFile(target,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe reset facts target");
var temporary=Files.createTempFile(directory,".reset-facts-",".tmp");try{
try(var channel=FileChannel.open(temporary,StandardOpenOption.WRITE)){var buffer=java.nio.ByteBuffer.wrap(bytes);while(buffer.hasRemaining())channel.write(buffer);channel.force(true);}
Files.move(temporary,target,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);forceDirectory();
}finally{Files.deleteIfExists(temporary);}
}
void remove(UUID operation)throws IOException{if(!Files.exists(directory,LinkOption.NOFOLLOW_LINKS))return;safeDirectory();if(Files.deleteIfExists(directory.resolve(operation+".facts")))forceDirectory();}
private void safeDirectory()throws IOException{if(!Files.isDirectory(directory,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe reset facts directory");}
private void forceDirectory()throws IOException{try(var channel=FileChannel.open(directory,StandardOpenOption.READ)){channel.force(true);}}
private static String text(JsonObject value,String key){return value.get(key).getAsString();}
private static UUID uuid(JsonObject value,String key){return value.has(key)?UUID.fromString(text(value,key)):null;}
private static Map<String,Object> object(JsonObject value,int depth)throws IOException{var map=new LinkedHashMap<String,Object>();for(var entry:value.entrySet())map.put(entry.getKey(),decode(entry.getValue(),depth+1));return Map.copyOf(map);}
private static Object decode(JsonElement value,int depth)throws IOException{
if(depth>8||value.isJsonNull())throw new IOException("Unsafe reset facts value");if(value.isJsonObject())return object(value.getAsJsonObject(),depth);if(value.isJsonArray()){var result=new ArrayList<>();for(var item:value.getAsJsonArray())result.add(decode(item,depth+1));return List.copyOf(result);}
var primitive=value.getAsJsonPrimitive();if(primitive.isBoolean())return primitive.getAsBoolean();if(primitive.isString())return primitive.getAsString();
if(primitive.isNumber()){String text=primitive.getAsString();if(text.matches("-?[0-9]+"))return Integer.valueOf(text);double number=primitive.getAsDouble();if(!Double.isFinite(number))throw new IOException("Non-finite reset facts number");return number;}
throw new IOException("Unsupported reset facts value");
}
}
@@ -0,0 +1,50 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.adventure.AdventureQuestState;
import games.dmg.fantasystick.persistence.YamlAdventureQuestRepository;
import java.nio.file.Path;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class AdventureDayResetCountsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void actualAutomaticUtcRolloverPublishesOneSystemResetWithOriginalExpiredWorkAndDurableOfferData()throws Exception{
var today=LocalDate.now(ZoneOffset.UTC);var day=today.minusDays(1);var started=day.atTime(12,0).toInstant(ZoneOffset.UTC);var site=new AdventureQuestState.Site(UUID.randomUUID(),AdventureQuestState.SiteType.CAMP,"Unloaded saved camp",UUID.randomUUID(),12,70,-8);
var before=new AdventureQuestState.Catalog(day,UUID.randomUUID(),started,List.of(site.id()));var character=UUID.randomUUID();
var quest=new AdventureQuestState.Quest(UUID.randomUUID(),before.revision(),site.id(),AdventureQuestState.UnitType.SOLO,character,Set.of(character),before.expiresAt(),java.math.BigInteger.valueOf(100),java.math.BigInteger.valueOf(20));
new YamlAdventureQuestRepository(data).save(new AdventureQuestState(List.of(site),List.of(),before,List.of(quest),Set.of(),Set.of(),Map.of(site.id(),List.of(org.bukkit.entity.EntityType.HUSK))));
try(var fixture=new CampAuditNativeFixture(data)){
fixture.pulse();ReskillEventsNativeTest.await(fixture.f,()->fixture.directory.snapshot().catalog().day().equals(today)&&fixture.directory.snapshot().reset()==null);
awaitRows(fixture.f,1);var row=rows().getFirst();var values=row.getAsJsonObject("data");var fresh=fixture.directory.snapshot().catalog();
assertEquals("system",values.get("actor_kind").getAsString());assertEquals("not_applicable",values.get("location_status").getAsString());assertFalse(row.has("playerid"));assertFalse(row.has("characterid"));assertEquals("utc_rollover",values.get("reason").getAsString());
assertEquals(before.day().toString(),values.get("previous_day").getAsString());assertEquals(today.toString(),values.get("day").getAsString());assertEquals(before.revision().toString(),values.get("previous_catalog_id").getAsString());assertEquals(fresh.revision().toString(),values.get("generation_id").getAsString());assertNotEquals(before.revision(),fresh.revision());
assertEquals(fresh.createdAt().toString(),values.get("created_at").getAsString());assertEquals(today.plusDays(1).atStartOfDay().toInstant(ZoneOffset.UTC).toString(),values.get("expires_at").getAsString());assertEquals(1,values.get("expired_quest_count").getAsInt());assertEquals(0,values.get("cleaned_encounter_count").getAsInt());
assertTrue(values.has("cleaned_run_count"),"Automatic reset must capture the actual registered dungeon participant's durable run cleanup count, including zero");assertEquals(0,values.get("cleaned_run_count").getAsInt());
assertEquals(List.of(site),fixture.directory.snapshot().sites());assertTrue(fixture.directory.active().isEmpty());assertTrue(fixture.directory.snapshot().rosters().isEmpty());assertTrue(fixture.spawned.isEmpty(),"An unloaded definition is not permission to force-load or spawn");assertEquals(fresh,new YamlAdventureQuestRepository(data).load().catalog());
fixture.pulse();fixture.pulse();fixture.f.plugin.setEnabled(false);fixture.f.awaitWorkers();WitchAuditTestSupport.drain(fixture.f,"adventureDayResetDrain");assertEquals(1,rows().size());assertTrue(ReskillEventsNativeTest.records(data).stream().noneMatch(value->value.get("type").getAsString().endsWith("adventure.catalog_replaced")));
}
}
@Test void confirmedRegisteredResetCapturesOneActualPersistedDungeonRunWithoutChangingItsDefinitionOrCharacter()throws Exception{
try(var fixture=new CampAuditNativeFixture(data)){
fixture.resetCatalog();awaitRows(fixture.f,1);var old=fixture.directory.snapshot().catalog();var dungeonDirectory=(games.dmg.fantasystick.dungeon.DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(fixture.f.plugin,"dungeons");
var dungeon=dungeonDirectory.create("Saved progress",fixture.actor.world.getUID(),12,70,-8,0,0).get(5,TimeUnit.SECONDS);var run=dungeonDirectory.run(dungeon.id(),old.revision()).get(5,TimeUnit.SECONDS);assertEquals(List.of(run),dungeonDirectory.snapshot().runs());
fixture.f.activate(fixture.actor);var before=CombatExperienceNativeTest.record(fixture.f,fixture.actor);fixture.resetCatalog();awaitRows(fixture.f,2);assertTrue(dungeonDirectory.snapshot().runs().isEmpty());assertEquals(List.of(dungeon),dungeonDirectory.snapshot().dungeons());assertEquals(before,CombatExperienceNativeTest.record(fixture.f,fixture.actor));
var row=rows().stream().filter(value->value.getAsJsonObject("data").has("previous_catalog_id")&&value.getAsJsonObject("data").get("previous_catalog_id").getAsString().equals(old.revision().toString())).findFirst().orElseThrow();var values=row.getAsJsonObject("data");
assertTrue(values.has("cleaned_run_count"),"Actual dungeon progress cleared by the registered participant must be observed, not guessed from camp attempts");assertEquals(1,values.get("cleaned_run_count").getAsInt());assertEquals(0,values.get("cleaned_encounter_count").getAsInt());assertEquals(2,values.get("participant_count").getAsInt());
}
}
private List<com.google.gson.JsonObject> rows()throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.day.reset")).toList();}
private void awaitRows(FantasyStickLifecycleNativeTest.Fixture f,int count)throws Exception{ReskillEventsNativeTest.await(f,()->{try{return rows().size()==count;}catch(Exception failure){throw new AssertionError(failure);}});}
}
@@ -0,0 +1,42 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Path;
import java.util.concurrent.atomic.AtomicReference;
import games.dmg.fantasystick.adventure.AdventureQuestState;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class AdventureDayResetEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void actualRegisteredConfirmedResetExportsOriginalAdministratorAndGenerationOnlyAfterActiveCleanupAndParticipantAcknowledgment()throws Exception{
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;fixture.camp("individual-only");fixture.giver();fixture.resetCatalog();ReskillEventsNativeTest.await(f,()->{try{return rows().size()==1;}catch(Exception failure){throw new AssertionError(failure);}});f.activate(actor);
var before=CombatExperienceNativeTest.record(f,actor);var session=((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(actor.core.id).orElseThrow().session();
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();fixture.pulse();
ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().anyMatch(entity->fixture.controller.encounterOrigin(entity).isPresent()));
var old=fixture.directory.snapshot().catalog();assertTrue(fixture.controller.hasActiveEncounter());var originalSites=fixture.directory.snapshot().sites();var originalGivers=fixture.directory.snapshot().givers();int prior=rows().size();
var observed=new AtomicReference<AdventureQuestState.Reset>();fixture.controller.registerResetParticipant(reset->{observed.set(reset);return java.util.concurrent.CompletableFuture.completedFuture(null);});
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure","reroll","confirm"});
ReskillEventsNativeTest.await(f,()->!old.revision().equals(fixture.directory.snapshot().catalog().revision())&&fixture.directory.snapshot().reset()==null&&actor.messages.stream().filter(value->value.startsWith("Reset daily adventures:")).count()==2);
var fresh=fixture.directory.snapshot().catalog();assertNotNull(observed.get());assertEquals(fresh.revision(),observed.get().generation());assertEquals(old.day(),fresh.day());assertEquals(old.expiresAt(),fresh.expiresAt());
assertTrue(fixture.directory.active().isEmpty());assertFalse(fixture.controller.hasActiveEncounter());assertTrue(fixture.directory.snapshot().rosters().isEmpty());assertEquals(originalSites,fixture.directory.snapshot().sites());assertEquals(originalGivers,fixture.directory.snapshot().givers());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
ReskillEventsNativeTest.await(f,()->{try{return rows().size()==prior+1;}catch(Exception failure){throw new AssertionError(failure);}});
f.plugin.setEnabled(false);f.awaitWorkers();
var exported=rows();assertEquals(prior+1,exported.size(),"A durably completed registered reset must export exactly one day-reset owner success after every participant acknowledges");
var row=exported.stream().filter(value->value.getAsJsonObject("data").get("operation_id").getAsString().equals(observed.get().operation().toString())).findFirst().orElseThrow();var values=row.getAsJsonObject("data");
assertEquals(actor.core.id.toString(),row.get("playerid").getAsString());assertEquals(before.id().toString(),row.get("characterid").getAsString());assertEquals(session.toString(),values.get("session_id").getAsString());assertEquals("manual",values.get("reason").getAsString());
assertEquals(old.revision().toString(),values.get("previous_catalog_id").getAsString());assertEquals(fresh.revision().toString(),values.get("catalog_id").getAsString());assertEquals(old.day().toString(),values.get("previous_day").getAsString());assertEquals(fresh.day().toString(),values.get("day").getAsString());
assertEquals(fresh.createdAt().toString(),values.get("created_at").getAsString());assertEquals(fresh.expiresAt().toString(),values.get("expires_at").getAsString());assertEquals(1,values.get("expired_quest_count").getAsInt());assertEquals(1,values.get("cleaned_encounter_count").getAsInt());
assertEquals(1,values.get("selected_site_count").getAsInt());assertEquals(quest.site().toString(),values.getAsJsonArray("selected_site_ids").get(0).getAsString());assertEquals("acknowledged",values.get("outcome").getAsString());
assertTrue(values.get("earned_rewards_preserved").getAsBoolean());assertTrue(values.get("inventory_preserved").getAsBoolean());assertTrue(values.get("current_generation_gates_reset").getAsBoolean());
assertEquals(12,values.getAsJsonObject("actor_location").get("x").getAsDouble());assertEquals(70,values.getAsJsonObject("actor_location").get("y").getAsDouble());assertEquals(-8,values.getAsJsonObject("actor_location").get("z").getAsDouble());
}
}
private java.util.List<com.google.gson.JsonObject> rows()throws Exception{
return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.day.reset")).toList();
}
}
@@ -0,0 +1,97 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static games.dmg.fantasystick.WitchAuditTestSupport.*;
import games.dmg.fantasystick.adventure.AdventureQuestDirectory;
import games.dmg.fantasystick.adventure.FishingDirectory;
import games.dmg.fantasystick.events.AdventureDayResetEventBridge;
import games.dmg.fantasystick.events.LocalEventLog;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import org.bukkit.entity.Entity;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class AdventureDayResetRecoveryNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void failedParticipantRetainsOriginalOperationActorCountsAndEarnedSpoilsUntilSuccessfulRetry()throws Exception{
try(var fixture=fixture()){
prepare(fixture);earn(fixture);var trophies=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(fixture.f.plugin,"fishing");var earned=trophies.snapshot();assertEquals(1,earned.trophies().size());
fixture.resetCatalog();awaitRows(fixture.f,2);accept(fixture);var before=CombatExperienceNativeTest.record(fixture.f,fixture.actor);var prior=fixture.directory.snapshot().catalog();var counter=new AtomicInteger();
fixture.controller.registerResetParticipant(reset->counter.getAndIncrement()==0?CompletableFuture.failedFuture(new java.io.IOException("Injected participant failure")):CompletableFuture.completedFuture(null));
command(fixture);ReskillEventsNativeTest.await(fixture.f,()->fixture.actor.messages.contains("Adventure reset is saved but pending recovery; the new generation is not yet available."));
var pending=fixture.directory.snapshot().reset();assertNotNull(pending);assertEquals(2,rows().size());assertTrue(fixture.directory.active().isEmpty());assertFalse(fixture.controller.hasActiveEncounter());assertEquals(earned,trophies.snapshot());assertEquals(before,CombatExperienceNativeTest.record(fixture.f,fixture.actor));
fixture.actor.core.location.setX(77);assertTrue(fixture.controller.publishPendingReset().toCompletableFuture().get(5,TimeUnit.SECONDS));awaitRows(fixture.f,3);
assertEquals(pending.generation(),fixture.directory.snapshot().catalog().revision());assertNull(fixture.directory.snapshot().reset());assertEquals(earned,trophies.snapshot());assertEquals(before,CombatExperienceNativeTest.record(fixture.f,fixture.actor));
var row=operation(pending.operation());var values=row.getAsJsonObject("data");assertEquals(prior.revision().toString(),values.get("previous_catalog_id").getAsString());assertEquals(12,values.getAsJsonObject("actor_location").get("x").getAsDouble());assertEquals(1,values.get("expired_quest_count").getAsInt());assertEquals(1,values.get("cleaned_encounter_count").getAsInt());assertEquals(3,values.get("participant_outcomes").getAsJsonArray().size());
for(var outcome:values.getAsJsonArray("participant_outcomes"))assertEquals("acknowledged",outcome.getAsJsonObject().get("outcome").getAsString());assertEquals(2,counter.get());
}
}
@Test void deniedMalformedStaleAndInitialFinalSaveFailuresCannotPublishResetSuccessAndRecoveryKeepsTheSavedGeneration()throws Exception{
try(var fixture=fixture()){
prepare(fixture);accept(fixture);var actor=fixture.actor;var f=fixture.f;var original=fixture.directory.snapshot();var character=CombatExperienceNativeTest.record(f,actor);var file=data.resolve("adventure-quests.yml");var bytes=Files.readAllBytes(file);
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure","reroll"});f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure","reroll","confirm","extra"});doReturn(false).when(actor.player).hasPermission("fantasystick.admin");command(fixture);doReturn(true).when(actor.player).hasPermission("fantasystick.admin");assertEquals(original,fixture.directory.snapshot());assertArrayEquals(bytes,Files.readAllBytes(file));assertEquals(1,rows().size());
block(file);command(fixture);ReskillEventsNativeTest.await(f,()->actor.messages.contains("Adventure reset failed; the current generation is unchanged."));assertEquals(original,fixture.directory.snapshot());assertEquals(character,CombatExperienceNativeTest.record(f,actor));assertEquals(1,rows().size());restore(file);
var injected=new AtomicBoolean();fixture.controller.registerResetParticipant(reset->{if(!injected.compareAndSet(false,true))return CompletableFuture.completedFuture(null);return CompletableFuture.runAsync(()->{try{block(file);}catch(Exception failure){throw new java.util.concurrent.CompletionException(failure);}});});
command(fixture);command(fixture);ReskillEventsNativeTest.await(f,()->actor.messages.contains("Adventure reset is saved but pending recovery; the new generation is not yet available."));ReskillEventsNativeTest.await(f,()->actor.messages.contains("Adventure reset refused: the generation changed or is still being published; inspect it and try again."));
var pending=fixture.directory.snapshot().reset();assertNotNull(pending);assertEquals(1,rows().size());assertEquals(character,CombatExperienceNativeTest.record(f,actor));restore(file);assertTrue(fixture.controller.publishPendingReset().toCompletableFuture().get(5,TimeUnit.SECONDS));awaitRows(f,2);
assertEquals(pending.generation(),fixture.directory.snapshot().catalog().revision());assertEquals(pending.operation().toString(),operation(pending.operation()).getAsJsonObject("data").get("operation_id").getAsString());assertEquals(character,CombatExperienceNativeTest.record(f,actor));
assertEquals(AdventureQuestDirectory.RerollStatus.STALE,fixture.directory.rerollToday(original.catalog().revision()).get(5,TimeUnit.SECONDS).status());assertEquals(2,rows().size());
}
}
@Test void shutdownBetweenParticipantAndFinalSourcePhasesRecoversOriginalActorAndCountsOfflineWithoutAnotherGenerationOrReward()throws Exception{
try(var fixture=fixture()){
prepare(fixture);earn(fixture);var oldTrophies=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(fixture.f.plugin,"fishing");var earned=oldTrophies.snapshot();fixture.resetCatalog();awaitRows(fixture.f,2);accept(fixture);
var f=fixture.f;var actor=fixture.actor;var character=CombatExperienceNativeTest.record(f,actor);var session=((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(actor.core.id).orElseThrow().session();
var held=new CompletableFuture<Void>();var reached=new AtomicBoolean();fixture.controller.registerResetParticipant(reset->{reached.set(true);return held;});command(fixture);ReskillEventsNativeTest.await(f,()->reached.get()&&fixture.directory.snapshot().reset()!=null);var pending=fixture.directory.snapshot().reset();assertEquals(2,rows().size());
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"adventureDayResetDrain");var inventory=actor.core.snapshot();var messages=List.copyOf(actor.messages);assertEquals(2,rows().size());assertNotNull(new games.dmg.fantasystick.persistence.YamlAdventureQuestRepository(data).load().reset());f.players.clear();clearInvocations(actor.player);f.plugin.setEnabled(true);
var recovered=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");ReskillEventsNativeTest.await(f,()->recovered.snapshot().reset()==null);awaitRows(f,3);var values=operation(pending.operation()).getAsJsonObject("data");
assertEquals(pending.generation(),recovered.snapshot().catalog().revision());assertEquals(session.toString(),values.get("session_id").getAsString());assertEquals(actor.core.id.toString(),operation(pending.operation()).get("playerid").getAsString());assertEquals(12,values.getAsJsonObject("actor_location").get("x").getAsDouble());assertEquals(1,values.get("cleaned_encounter_count").getAsInt());assertEquals(1,values.get("expired_quest_count").getAsInt());assertEquals(2,values.get("participant_count").getAsInt());assertEquals(earned,((FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing")).snapshot());
var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(character,storage.find(character.id()).get().orElseThrow());assertArrayEquals(inventory,actor.core.snapshot());assertEquals(messages,actor.messages);verifyNoInteractions(actor.player);
assertTrue(held.complete(null));((AdventureDayResetEventBridge)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureDayResetEvents")).replay();f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"adventureDayResetDrain");assertEquals(3,rows().size());assertEquals(pending.generation(),new games.dmg.fantasystick.persistence.YamlAdventureQuestRepository(data).load().catalog().revision());
}
}
@Test void rejectedAcknowledgedResetReplaysEveryOriginalPropertyAndStableIdOfflineWithoutSourceOrInventoryChanges()throws Exception{
try(var fixture=fixture()){
prepare(fixture);accept(fixture);var f=fixture.f;var actor=fixture.actor;var character=CombatExperienceNativeTest.record(f,actor);((LocalEventLog)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"eventLog")).close();fixture.resetCatalog();var fresh=fixture.directory.snapshot().catalog();
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"adventureDayResetDrain");var original=intents(data.resolve("adventure-day-reset-event-intents"));assertEquals(1,original.size());assertEquals(1,rows().size());var bytes=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);awaitRows(f,2);
var row=rows().stream().filter(value->value.get("id").getAsString().equals(original.getFirst().getProperty("id"))).findFirst().orElseThrow();assertOriginal(original.getFirst(),row);assertArrayEquals(bytes,Files.readAllBytes(data.resolve("adventure-quests.yml")));assertArrayEquals(inventory,actor.core.snapshot());assertEquals(messages,actor.messages);verifyNoInteractions(actor.player);assertEquals(fresh,new games.dmg.fantasystick.persistence.YamlAdventureQuestRepository(data).load().catalog());
var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(character,storage.find(character.id()).get().orElseThrow());var bridge=(AdventureDayResetEventBridge)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureDayResetEvents");bridge.replay();bridge.replay();f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"adventureDayResetDrain");assertEquals(2,rows().size());
}
}
@Test void unavailableObservationFactsJournalCannotAuthorizeRefuseOrUndoActualRegisteredGameplayReset()throws Exception{
try(var fixture=fixture()){
prepare(fixture);accept(fixture);var before=CombatExperienceNativeTest.record(fixture.f,fixture.actor);var old=fixture.directory.snapshot().catalog();
var folder=data.resolve("adventure-day-reset-facts");
if(Files.exists(folder)){try(var files=Files.list(folder)){for(var file:files.toList())Files.delete(file);}Files.delete(folder);}Files.writeString(folder,"unavailable observation storage");
fixture.resetCatalog();awaitRows(fixture.f,2);assertNotEquals(old.revision(),fixture.directory.snapshot().catalog().revision());assertNull(fixture.directory.snapshot().reset());assertTrue(fixture.directory.active().isEmpty());assertFalse(fixture.controller.hasActiveEncounter());assertEquals(before,CombatExperienceNativeTest.record(fixture.f,fixture.actor));
var row=rows().stream().filter(value->value.getAsJsonObject("data").has("previous_catalog_id")&&value.getAsJsonObject("data").get("previous_catalog_id").getAsString().equals(old.revision().toString())).findFirst().orElseThrow();assertEquals(1,row.getAsJsonObject("data").get("cleaned_encounter_count").getAsInt());assertEquals("acknowledged",row.getAsJsonObject("data").get("outcome").getAsString());
}
}
private CampAuditNativeFixture fixture()throws Exception{var config=new org.bukkit.configuration.file.YamlConfiguration();config.set("adventure-camp-enemies",4);config.save(data.resolve("config.yml").toFile());return new CampAuditNativeFixture(data);}
private void prepare(CampAuditNativeFixture fixture)throws Exception{fixture.camp("individual-only");fixture.giver();fixture.resetCatalog();awaitRows(fixture.f,1);fixture.f.activate(fixture.actor);}
private void accept(CampAuditNativeFixture fixture)throws Exception{fixture.click(fixture.actor,fixture.open(fixture.actor),false);ReskillEventsNativeTest.await(fixture.f,()->fixture.directory.active().size()==1);fixture.pulse();ReskillEventsNativeTest.await(fixture.f,()->fixture.spawned.stream().filter(Entity::isValid).filter(entity->fixture.controller.encounterOrigin(entity).isPresent()).count()==4);}
private void earn(CampAuditNativeFixture fixture)throws Exception{
accept(fixture);var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(fixture.actor.player);when(source.getDirectEntity()).thenReturn(fixture.actor.player);
for(var entity:fixture.spawned.stream().filter(Entity::isValid).filter(value->fixture.controller.encounterOrigin(value).isPresent()).toList()){
var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(fixture.actor.player,entity,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);when(entity.getLastDamageCause()).thenReturn(hit);fixture.f.dispatcher.accept(hit);assertFalse(hit.isCancelled());fixture.f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(entity,source,new ArrayList<>(),0));
}
var trophies=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(fixture.f.plugin,"fishing");ReskillEventsNativeTest.await(fixture.f,()->fixture.directory.active().isEmpty()&&trophies.trophies(fixture.actor.core.record.id()).size()==1);
}
private static void command(CampAuditNativeFixture fixture){fixture.f.adminCommand.execute(fixture.actor.player,"fstickadmin",new String[]{"adventure","reroll","confirm"});}
private void block(Path file)throws Exception{Files.move(file,file.resolveSibling(file.getFileName()+".test-backup"));Files.createDirectory(file);Files.writeString(file.resolve("block"),"retain");}
private void restore(Path file)throws Exception{Files.delete(file.resolve("block"));Files.delete(file);Files.move(file.resolveSibling(file.getFileName()+".test-backup"),file);}
private List<com.google.gson.JsonObject> rows()throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.day.reset")).toList();}
private com.google.gson.JsonObject operation(java.util.UUID operation)throws Exception{return rows().stream().filter(row->row.getAsJsonObject("data").get("operation_id").getAsString().equals(operation.toString())).findFirst().orElseThrow();}
private void awaitRows(FantasyStickLifecycleNativeTest.Fixture f,int count)throws Exception{ReskillEventsNativeTest.await(f,()->{try{return rows().size()==count;}catch(Exception failure){throw new AssertionError(failure);}});}
}
@@ -0,0 +1,53 @@
package games.dmg.fantasystick.events;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.adventure.AdventureQuestDirectory;
import games.dmg.fantasystick.adventure.AdventureQuestState;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class AdventureDayResetEventBridgeTest {
@TempDir Path data;
@Test void interruptedPublishedSourceResetRetainsOriginalContextAndCleanupFactsWithoutPublishingBeforeFinalAcknowledgment()throws Exception{
var original=Instant.now();var site=UUID.randomUUID();var before=new AdventureQuestState.Catalog(LocalDate.ofInstant(original,ZoneOffset.UTC),UUID.randomUUID(),original,List.of(site),Set.of());
var fresh=new AdventureQuestState.Catalog(before.day(),UUID.randomUUID(),original.plusSeconds(1),List.of(site),Set.of());var reset=new AdventureQuestState.Reset(UUID.randomUUID(),fresh.revision(),original.plusSeconds(1));
var context=new EventInput.Context(UUID.randomUUID(),"Original administrator",UUID.randomUUID(),"Original character",UUID.randomUUID(),new EventInput.Location(UUID.randomUUID(),"Original realm",12,70,-8,original,"native_original"));
var actor=new CampEventBridge.Actor(context,Map.of("session_id",context.sessionId().toString(),"actor_kind","player","actor_location",Map.of("x",12.0,"y",70.0,"z",-8.0)));
var exported=new CopyOnWriteArrayList<EventInput>();var failures=new CopyOnWriteArrayList<Throwable>();
var first=new AdventureDayResetEventBridge(event->{exported.add(event);return CompletableFuture.completedFuture(LocalEventLog.Delivery.EXPORTED);},data,failures::add);
first.prepared(new AdventureQuestDirectory.ResetPublication(reset,before,fresh,"manual",3,actor));first.cleanupObserved(reset,1,2);first.close();first.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
assertTrue(exported.isEmpty(),"Installed pending reset facts are not a positive success intent");assertTrue(failures.isEmpty());
var reopened=new AdventureDayResetEventBridge(event->{exported.add(event);return CompletableFuture.completedFuture(LocalEventLog.Delivery.EXPORTED);},data,failures::add);
reopened.cleanupObserved(reset,0,2);reopened.completed(reset);reopened.completed(reset);reopened.close();reopened.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
assertEquals(1,exported.size(),"Acknowledged completion after restart must retain original pending reset facts rather than silently lose its owner event");
var event=exported.getFirst();assertEquals(context,event.context());assertEquals(reset.operation(),event.actionId());assertEquals(UUID.nameUUIDFromBytes(("fantasystick:adventure-day-reset:"+reset.operation()+":completed").getBytes(StandardCharsets.UTF_8)),event.id());
assertEquals(before.revision().toString(),event.data().get("previous_catalog_id"));assertEquals(fresh.revision().toString(),event.data().get("catalog_id"));assertEquals("manual",event.data().get("reason"));assertEquals(3,event.data().get("expired_quest_count"));assertEquals(1,event.data().get("cleaned_encounter_count"));assertEquals(context.sessionId().toString(),event.data().get("session_id"));assertTrue(failures.isEmpty(),failures::toString);
}
@Test void finalSourceMarkerRecoveryPreservesCapturedOffersAndThenReplaysTheExactCompletedEventWithoutResetAuthority()throws Exception{
var now=Instant.now();var sites=java.util.stream.IntStream.range(0,20).mapToObj(index->UUID.randomUUID()).toList();var old=new AdventureQuestState.Catalog(LocalDate.ofInstant(now,ZoneOffset.UTC),UUID.randomUUID(),now,sites);var fresh=new AdventureQuestState.Catalog(old.day(),UUID.randomUUID(),now.plusSeconds(1),sites);var reset=new AdventureQuestState.Reset(UUID.randomUUID(),fresh.revision(),now.plusSeconds(1));
var context=new EventInput.Context(UUID.randomUUID(),"Original console-side player",null,null,null,new EventInput.Location(UUID.randomUUID(),"Original realm",12,70,-8,now,"native"));var actor=new CampEventBridge.Actor(context,Map.of("actor_kind","player"));var rows=new CopyOnWriteArrayList<EventInput>();var failures=new CopyOnWriteArrayList<Throwable>();
var first=new AdventureDayResetEventBridge(event->{rows.add(event);return CompletableFuture.completedFuture(LocalEventLog.Delivery.REJECTED);},data,failures::add);first.prepared(new AdventureQuestDirectory.ResetPublication(reset,old,fresh,"manual",2,actor));first.cleanupObserved(reset,1,2);first.cleanupAcknowledged(reset,2);first.close();first.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);assertTrue(rows.isEmpty());
var published=new AdventureQuestState(List.of(),List.of(),fresh,List.of(),Set.of(),Set.of(),Map.of());var second=new AdventureDayResetEventBridge(event->{rows.add(event);return CompletableFuture.completedFuture(LocalEventLog.Delivery.REJECTED);},data,failures::add);second.recover(published);second.close();second.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);assertEquals(1,rows.size());var original=rows.getFirst();assertEquals(context,original.context());assertEquals("recovered_final_source_marker",original.data().get("completion_capture_quality"));assertEquals(20,original.data().get("selected_site_count"));assertEquals(sites.stream().map(UUID::toString).toList(),original.data().get("selected_site_ids"));
var third=new AdventureDayResetEventBridge(event->{rows.add(event);return CompletableFuture.completedFuture(LocalEventLog.Delivery.EXPORTED);},data,failures::add);third.replay();third.replay();third.close();third.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);assertEquals(2,rows.size());assertEquals(original,rows.getLast(),"Replay retains the exact immutable ID, timestamp, original context and all payload properties");assertTrue(failures.isEmpty(),failures::toString);
}
@Test void legacyPendingResetDoesNotInventOriginalActorReasonOrTotalCleanupFromCurrentRecoveryWork()throws Exception{
var now=Instant.now();var catalog=new AdventureQuestState.Catalog(LocalDate.ofInstant(now,ZoneOffset.UTC),UUID.randomUUID(),now,List.of());var reset=new AdventureQuestState.Reset(UUID.randomUUID(),catalog.revision(),now);
var state=new AdventureQuestState(List.of(),List.of(),catalog,List.of(),Set.of(),Set.of(),Map.of(),reset);var rows=new CopyOnWriteArrayList<EventInput>();var failures=new CopyOnWriteArrayList<Throwable>();
var bridge=new AdventureDayResetEventBridge(event->{rows.add(event);return CompletableFuture.completedFuture(LocalEventLog.Delivery.EXPORTED);},data,failures::add);bridge.recover(state);bridge.cleanupObserved(reset,0,2);bridge.completed(reset);bridge.close();bridge.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
assertEquals(1,rows.size());var values=rows.getFirst().data();assertNull(rows.getFirst().context());assertEquals("unavailable",values.get("original_capture_quality"));assertEquals("unavailable",values.get("actor_kind"));assertEquals("unavailable",values.get("reason"));assertFalse(values.containsKey("previous_catalog_id"));assertFalse(values.containsKey("expired_quest_count"));
assertFalse(values.containsKey("cleaned_encounter_count"),"Zero currently loaded recovery attempts does not prove zero original operation cleanup");assertEquals(0,values.get("recovery_cleaned_encounter_count"));assertTrue(failures.isEmpty(),failures::toString);
}
}