This commit is contained in:
1 parent
f4db24492a
commit
a57b59f4f1
9 files changed
+207
-19
No files matched your search
@@ -752,8 +752,54 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getServer().getVersion(), getServer().getMinecraftVersion(), getDescription().getVersion()), 1024, 134_217_728L,
|
||||
message -> getLogger().warning(message));
|
||||
eventCapture = new games.dmg.fantasystick.events.EventCaptureListener(eventLog, sessions, Clock.systemUTC());
|
||||
var completionLog = eventLog;
|
||||
var committedDungeonRuns = dungeons;
|
||||
var completionLocation = getDataFolder().toPath();
|
||||
var inFlightCompletions = new java.util.concurrent.ConcurrentHashMap<java.util.UUID,
|
||||
java.util.concurrent.CompletableFuture<games.dmg.fantasystick.events.LocalEventLog.Delivery>>();
|
||||
java.util.function.Function<games.dmg.fantasystick.events.EventInput,
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.events.LocalEventLog.Delivery>> fishingSink = input -> {
|
||||
if (!"dungeon.completed".equals(input.type())) return completionLog.emit(input);
|
||||
var result = new java.util.concurrent.CompletableFuture<games.dmg.fantasystick.events.LocalEventLog.Delivery>();
|
||||
var prior = inFlightCompletions.putIfAbsent(input.id(),result);
|
||||
if (prior != null) return prior;
|
||||
try {
|
||||
var evidence = input.data();
|
||||
var dungeon = java.util.UUID.fromString((String)evidence.get("dungeon_id"));
|
||||
var runGeneration = java.util.UUID.fromString((String)evidence.get("generation_id"));
|
||||
var boss = java.util.UUID.fromString((String)evidence.get("boss_id"));
|
||||
var exported = games.dmg.fantasystick.events.DungeonEventBridge.exported(
|
||||
completionLocation,input.id(),dungeon,runGeneration,boss);
|
||||
var delivery = exported
|
||||
? java.util.concurrent.CompletableFuture.completedFuture(
|
||||
games.dmg.fantasystick.events.LocalEventLog.Delivery.EXPORTED)
|
||||
: completionLog.emit(input);
|
||||
delivery.thenCompose(status -> {
|
||||
if (status == games.dmg.fantasystick.events.LocalEventLog.Delivery.REJECTED)
|
||||
return java.util.concurrent.CompletableFuture.completedFuture(status);
|
||||
return committedDungeonRuns.acknowledgeCompletion(dungeon,runGeneration,boss)
|
||||
.handle((ack,failure) -> {
|
||||
if (failure != null) {
|
||||
getLogger().log(Level.WARNING,"Dungeon completion publication acknowledgement failed",failure);
|
||||
return games.dmg.fantasystick.events.LocalEventLog.Delivery.REJECTED;
|
||||
}
|
||||
return status;
|
||||
});
|
||||
}).whenComplete((status,failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING,"Dungeon completion event sink failed",failure);
|
||||
result.complete(failure == null ? status
|
||||
: games.dmg.fantasystick.events.LocalEventLog.Delivery.REJECTED);
|
||||
inFlightCompletions.remove(input.id(),result);
|
||||
});
|
||||
} catch (java.io.IOException | RuntimeException invalid) {
|
||||
getLogger().log(Level.WARNING,"Dungeon completion export evidence is unavailable",invalid);
|
||||
result.complete(games.dmg.fantasystick.events.LocalEventLog.Delivery.REJECTED);
|
||||
inFlightCompletions.remove(input.id(),result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
fishingAuditEvents = new games.dmg.fantasystick.events.FishingEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
|
||||
fishingSink, Clock.systemUTC(), getDataFolder().toPath(), sessions,
|
||||
failure -> getLogger().log(Level.WARNING, "Fishing event intent failed", failure));
|
||||
adventureQuestCommands.attachFishingEvents(fishingAuditEvents);
|
||||
adventureQuestController.attachFishingEvents(fishingAuditEvents);
|
||||
|
||||
@@ -205,12 +205,20 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
|
||||
return audit;
|
||||
}
|
||||
public void replayCompletions(){
|
||||
if(auditEvents==null)return;
|
||||
for(var run:dungeons.snapshot().runs())if(run.defeated()&&!run.completionAudit().isEmpty())
|
||||
try{auditEvents.completed(run);}catch(RuntimeException error){
|
||||
plugin.getLogger().log(java.util.logging.Level.WARNING,
|
||||
"Committed dungeon completion audit replay failed",error);
|
||||
}
|
||||
var events=auditEvents;if(events==null)return;
|
||||
dungeons.pendingCompletions(run->{try{return games.dmg.fantasystick.events.DungeonEventBridge.exported(
|
||||
plugin.getDataFolder().toPath(),
|
||||
games.dmg.fantasystick.events.DungeonEventBridge.completionEventId(run),
|
||||
run.dungeon(),run.generation(),UUID.fromString(run.completionAudit().get("boss_id")));}
|
||||
catch(java.io.IOException error){throw new java.io.UncheckedIOException(error);}})
|
||||
.whenComplete((pending,failure)->{
|
||||
if(failure!=null){plugin.getLogger().log(java.util.logging.Level.WARNING,
|
||||
"Committed dungeon completion export reconciliation failed",failure);return;}
|
||||
for(var run:pending)try{events.completed(run);}catch(RuntimeException error){
|
||||
plugin.getLogger().log(java.util.logging.Level.WARNING,
|
||||
"Committed dungeon completion audit replay failed",error);
|
||||
}
|
||||
});
|
||||
}
|
||||
private CompletableFuture<Void> awardQuest(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,AdventureQuestState.Quest quest,boolean completeQuest){UUID generation=attempt.generation;var run=dungeons.runView(dungeon.id(),generation).orElse(null);if(run==null||run.unitClaims().contains(new DungeonState.UnitClaim(room.id(),quest.id())))return CompletableFuture.completedFuture(null);var recipients=recipients(dungeon,room,quest,run);if(recipients.isEmpty())return CompletableFuture.completedFuture(null);if(!completeQuest)return dungeons.clearRoom(dungeon.id(),generation,room.id(),quest.id(),Set.copyOf(recipients)).thenApply(ignored->null);if(fishing==null)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon trophy persistence unavailable"));var unit=new RewardAllocator.Unit<UUID>(quest.unit(),recipients);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(UUID character:recipients)rewards.put(character,new FishingState.Reward(xp.get(character),coins.get(character)));UUID carrier=quest.unitType()==AdventureQuestState.UnitType.SOLO?quest.unit():recipients.stream().map(parties::partyOfCharacter).flatMap(Optional::stream).filter(value->value.id().equals(quest.unit())).map(PartyRoster.Party::leaderCharacter).findFirst().orElse(null);if(carrier==null)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon trophy carrier unavailable"));UUID trophy=UUID.nameUUIDFromBytes(("dungeon:"+quest.id()).getBytes(StandardCharsets.UTF_8));var audit=new LinkedHashMap<String,String>(attempt.defeatAudit);audit.put("unit_kind",quest.unitType().name().toLowerCase(Locale.ROOT));audit.put("unit_id",quest.unit().toString());audit.put("accepted_roster",quest.snapshot().stream().sorted().map(UUID::toString).collect(java.util.stream.Collectors.joining(",")));var plan=new FishingState.Trophy(trophy,FishingState.TrophyType.DUNGEON,carrier,quest.catalog(),dungeon.id(),quest.id(),generation,"Trophy of "+dungeon.name(),dungeon.name(),rewards,FishingState.TrophyPhase.PREPARED,audit);return fishing.earnTrophy(plan).thenCompose(earned->{if(!earned)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon trophy persistence refused"));return dungeons.clearRoom(dungeon.id(),generation,room.id(),quest.id(),Set.copyOf(recipients));}).thenApply(ignored->(Void)null).toCompletableFuture();}
|
||||
private List<UUID> recipients(DungeonState.Dungeon dungeon,DungeonState.Room room,AdventureQuestState.Quest quest,DungeonState.Run run){var result=new ArrayList<UUID>();for(UUID character:quest.snapshot()){if(run.characterClaims().contains(new DungeonState.CharacterClaim(room.id(),character)))continue;var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);if(active==null)continue;var player=plugin.getServer().getPlayer(active.player());if(player==null||!inside(player.getLocation(),dungeon,room))continue;if(quest.unitType()==AdventureQuestState.UnitType.PARTY){var party=parties.partyOf(player.getUniqueId()).orElse(null);if(party==null||!party.id().equals(quest.unit())||!party.characters().contains(character))continue;}result.add(character);}return result.stream().distinct().sorted().toList();}
|
||||
|
||||
@@ -50,11 +50,15 @@ public final class DungeonDirectory implements AutoCloseable,AdventureResetParti
|
||||
public CompletableFuture<Boolean> clearRun(UUID dungeon,UUID generation){return mutate(state->{var runs=state.runs().stream().filter(v->!(v.dungeon().equals(dungeon)&&v.generation().equals(generation))).toList();if(runs.size()==state.runs().size())return result(state,false);return result(new DungeonState(state.dungeons(),runs),true);});}
|
||||
public CompletableFuture<DungeonState.Run> run(UUID dungeon,UUID generation){return mutate(state->{requireGeneration(generation);var found=state.runs().stream().filter(v->v.dungeon().equals(dungeon)&&v.generation().equals(generation)).findFirst();if(found.isPresent())return result(state,found.orElseThrow());var value=new DungeonState.Run(dungeon,generation,Set.of(),Set.of(),Set.of(),Set.of(),false);var runs=new ArrayList<>(state.runs());runs.add(value);return result(new DungeonState(state.dungeons(),runs),value);});}
|
||||
public Optional<DungeonState.Run> runView(UUID dungeon,UUID generation){return snapshot.runs().stream().filter(v->v.dungeon().equals(dungeon)&&v.generation().equals(generation)).findFirst();}
|
||||
public CompletableFuture<DungeonState.Run> defeatSlot(UUID dungeon,UUID generation,UUID slot){return updateRun(dungeon,generation,run->{var defeated=new HashSet<>(run.defeatedSlots());defeated.add(slot);return new DungeonState.Run(run.dungeon(),run.generation(),defeated,run.clearedRooms(),run.unitClaims(),run.characterClaims(),run.defeated(),run.completionAudit());});}
|
||||
public CompletableFuture<DungeonState.Run> markCleared(UUID dungeon,UUID generation,UUID room){return updateRun(dungeon,generation,run->{var cleared=new HashSet<>(run.clearedRooms());cleared.add(room);return new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),cleared,run.unitClaims(),run.characterClaims(),run.defeated(),run.completionAudit());});}
|
||||
public CompletableFuture<DungeonState.Run> clearRoom(UUID dungeon,UUID generation,UUID room,UUID quest,Set<UUID> characters){return updateRun(dungeon,generation,run->{var cleared=new HashSet<>(run.clearedRooms());cleared.add(room);var units=new HashSet<>(run.unitClaims());units.add(new DungeonState.UnitClaim(room,quest));var claims=new HashSet<>(run.characterClaims());characters.forEach(value->claims.add(new DungeonState.CharacterClaim(room,value)));return new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),cleared,units,claims,run.defeated(),run.completionAudit());});}
|
||||
public CompletableFuture<DungeonState.Run> defeatSlot(UUID dungeon,UUID generation,UUID slot){return updateRun(dungeon,generation,run->{var defeated=new HashSet<>(run.defeatedSlots());defeated.add(slot);return new DungeonState.Run(run.dungeon(),run.generation(),defeated,run.clearedRooms(),run.unitClaims(),run.characterClaims(),run.defeated(),run.completionAudit(),run.completionPublished());});}
|
||||
public CompletableFuture<DungeonState.Run> markCleared(UUID dungeon,UUID generation,UUID room){return updateRun(dungeon,generation,run->{var cleared=new HashSet<>(run.clearedRooms());cleared.add(room);return new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),cleared,run.unitClaims(),run.characterClaims(),run.defeated(),run.completionAudit(),run.completionPublished());});}
|
||||
public CompletableFuture<DungeonState.Run> clearRoom(UUID dungeon,UUID generation,UUID room,UUID quest,Set<UUID> characters){return updateRun(dungeon,generation,run->{var cleared=new HashSet<>(run.clearedRooms());cleared.add(room);var units=new HashSet<>(run.unitClaims());units.add(new DungeonState.UnitClaim(room,quest));var claims=new HashSet<>(run.characterClaims());characters.forEach(value->claims.add(new DungeonState.CharacterClaim(room,value)));return new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),cleared,units,claims,run.defeated(),run.completionAudit(),run.completionPublished());});}
|
||||
public CompletableFuture<DungeonState.Run> defeat(UUID dungeon,UUID generation){return defeat(dungeon,generation,Map.of());}
|
||||
public CompletableFuture<DungeonState.Run> defeat(UUID dungeon,UUID generation,Map<String,String> audit){var frozen=Map.copyOf(audit);return updateRun(dungeon,generation,run->run.defeated()?run:new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),run.clearedRooms(),run.unitClaims(),run.characterClaims(),true,frozen));}
|
||||
/** Acknowledgement is serialized with the run and bound to its committed native boss. */
|
||||
public CompletableFuture<Boolean> acknowledgeCompletion(UUID dungeon,UUID generation,UUID boss){return mutate(state->{var found=state.runs().stream().filter(v->v.dungeon().equals(dungeon)&&v.generation().equals(generation)).findFirst();if(found.isEmpty())return result(state,false);var run=found.orElseThrow();if(!run.defeated()||!boss.toString().equals(run.completionAudit().get("boss_id")))return result(state,false);if(run.completionPublished())return result(state,true);var published=new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),run.clearedRooms(),run.unitClaims(),run.characterClaims(),true,run.completionAudit(),true);var runs=state.runs().stream().map(value->value==run?published:value).toList();return result(new DungeonState(state.dungeons(),runs),true);});}
|
||||
/** Reconcile prior exported proof before replaying a missing completion, on this I/O worker. */
|
||||
public CompletableFuture<List<DungeonState.Run>> pendingCompletions(java.util.function.Predicate<DungeonState.Run> exported){return mutate(state->{var runs=new ArrayList<DungeonState.Run>();var pending=new ArrayList<DungeonState.Run>();boolean changed=false;for(var run:state.runs()){if(!run.defeated()||run.completionPublished()||run.completionAudit().isEmpty()){runs.add(run);continue;}if(exported.test(run)){runs.add(new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),run.clearedRooms(),run.unitClaims(),run.characterClaims(),true,run.completionAudit(),true));changed=true;}else{runs.add(run);pending.add(run);}}return result(changed?new DungeonState(state.dungeons(),runs):state,List.copyOf(pending));});}
|
||||
@Override public CompletionStage<Void> reset(AdventureQuestState.Reset reset){currentGeneration=reset.generation();return mutate(state->result(new DungeonState(state.dungeons(),List.of()),null));}
|
||||
private <T>CompletableFuture<T> updateRoom(UUID id,UUID roomId,java.util.function.UnaryOperator<DungeonState.Room> operation,T result){return mutate(state->{var dungeon=editable(state,id);var found=dungeon.rooms().stream().filter(v->v.id().equals(roomId)).findFirst().orElseThrow(()->new IllegalArgumentException("Room not found"));var updated=operation.apply(found);var rooms=dungeon.rooms().stream().map(v->v.id().equals(roomId)?updated:v).toList();return result(replace(state,definition(dungeon,rooms,dungeon.entrance(),false)),result);});}
|
||||
private CompletableFuture<DungeonState.Run> updateRun(UUID dungeon,UUID generation,java.util.function.UnaryOperator<DungeonState.Run> operation){return mutate(state->{requireGeneration(generation);var existing=state.runs().stream().filter(v->v.dungeon().equals(dungeon)&&v.generation().equals(generation)).findFirst().orElse(new DungeonState.Run(dungeon,generation,Set.of(),Set.of(),Set.of(),Set.of(),false));var updated=operation.apply(existing);var runs=state.runs().stream().filter(v->!(v.dungeon().equals(dungeon)&&v.generation().equals(generation))).collect(java.util.stream.Collectors.toCollection(ArrayList::new));runs.add(updated);return result(new DungeonState(state.dungeons(),runs),updated);});}
|
||||
|
||||
@@ -56,9 +56,10 @@ public record DungeonState(List<Dungeon> dungeons,List<Run> runs) {
|
||||
}
|
||||
public record UnitClaim(UUID room,UUID quest) {public UnitClaim {Objects.requireNonNull(room);Objects.requireNonNull(quest);}}
|
||||
public record CharacterClaim(UUID room,UUID character) {public CharacterClaim {Objects.requireNonNull(room);Objects.requireNonNull(character);}}
|
||||
public record Run(UUID dungeon,UUID generation,Set<UUID> defeatedSlots,Set<UUID> clearedRooms,Set<UnitClaim> unitClaims,Set<CharacterClaim> characterClaims,boolean defeated,Map<String,String> completionAudit) {
|
||||
public Run {Objects.requireNonNull(dungeon);Objects.requireNonNull(generation);defeatedSlots=Set.copyOf(defeatedSlots);clearedRooms=Set.copyOf(clearedRooms);unitClaims=Set.copyOf(unitClaims);characterClaims=Set.copyOf(characterClaims);completionAudit=Map.copyOf(completionAudit);}
|
||||
public Run(UUID dungeon,UUID generation,Set<UUID> defeatedSlots,Set<UUID> clearedRooms,Set<UnitClaim> unitClaims,Set<CharacterClaim> characterClaims,boolean defeated){this(dungeon,generation,defeatedSlots,clearedRooms,unitClaims,characterClaims,defeated,Map.of());}
|
||||
public record Run(UUID dungeon,UUID generation,Set<UUID> defeatedSlots,Set<UUID> clearedRooms,Set<UnitClaim> unitClaims,Set<CharacterClaim> characterClaims,boolean defeated,Map<String,String> completionAudit,boolean completionPublished) {
|
||||
public Run {Objects.requireNonNull(dungeon);Objects.requireNonNull(generation);defeatedSlots=Set.copyOf(defeatedSlots);clearedRooms=Set.copyOf(clearedRooms);unitClaims=Set.copyOf(unitClaims);characterClaims=Set.copyOf(characterClaims);completionAudit=Map.copyOf(completionAudit);if(completionPublished&&(!defeated||completionAudit.isEmpty()))throw new IllegalArgumentException("Unproven dungeon completion publication");}
|
||||
public Run(UUID dungeon,UUID generation,Set<UUID> defeatedSlots,Set<UUID> clearedRooms,Set<UnitClaim> unitClaims,Set<CharacterClaim> characterClaims,boolean defeated,Map<String,String> completionAudit){this(dungeon,generation,defeatedSlots,clearedRooms,unitClaims,characterClaims,defeated,completionAudit,false);}
|
||||
public Run(UUID dungeon,UUID generation,Set<UUID> defeatedSlots,Set<UUID> clearedRooms,Set<UnitClaim> unitClaims,Set<CharacterClaim> characterClaims,boolean defeated){this(dungeon,generation,defeatedSlots,clearedRooms,unitClaims,characterClaims,defeated,Map.of(),false);}
|
||||
}
|
||||
public DungeonState {dungeons=List.copyOf(dungeons);runs=List.copyOf(runs);unique(dungeons.stream().map(Dungeon::id).toList());var keys=runs.stream().map(v->v.dungeon()+":"+v.generation()).toList();if(new HashSet<>(keys).size()!=keys.size())throw new IllegalArgumentException("Duplicate dungeon run");}
|
||||
public static DungeonState empty(){return new DungeonState(List.of(),List.of());}
|
||||
|
||||
@@ -37,10 +37,9 @@ public final class DungeonEventBridge {
|
||||
|
||||
/** Only an authoritative defeated run carrying captured native boss proof can attest closure. */
|
||||
public void completed(DungeonState.Run run) {
|
||||
if (!run.defeated() || run.completionAudit().isEmpty()) return; // Older runs have no defensible proof.
|
||||
if (!run.defeated() || run.completionPublished() || run.completionAudit().isEmpty()) return; // Older runs have no defensible proof.
|
||||
var audit = run.completionAudit();
|
||||
var operation = UUID.nameUUIDFromBytes(("dungeon:completed:" + run.dungeon() + ":" + run.generation())
|
||||
.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
var operation = completionOperation(run);
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("dungeon_id", run.dungeon().toString());
|
||||
data.put("dungeon_name", required(audit, "dungeon_name"));
|
||||
@@ -81,6 +80,43 @@ public final class DungeonEventBridge {
|
||||
new FishingEventBridge.Actor("system", null), data);
|
||||
}
|
||||
|
||||
public static UUID completionOperation(DungeonState.Run run) {
|
||||
return UUID.nameUUIDFromBytes(("dungeon:completed:" + run.dungeon() + ":" + run.generation())
|
||||
.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
public static UUID completionEventId(DungeonState.Run run) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:fishing:" + completionOperation(run)
|
||||
+ ":dungeon.completed").getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
/** Read only exported UTC-day segments, never an uncommitted pending intent. Call off the tick thread. */
|
||||
public static boolean exported(java.nio.file.Path data, UUID eventId, UUID dungeon,
|
||||
UUID generation, UUID boss) throws java.io.IOException {
|
||||
var events = data.toAbsolutePath().resolve("events");
|
||||
if (java.nio.file.Files.notExists(events)) return false;
|
||||
try (var files = java.nio.file.Files.walk(events)) {
|
||||
for (var path : files.filter(file -> java.nio.file.Files.isRegularFile(file,
|
||||
java.nio.file.LinkOption.NOFOLLOW_LINKS)
|
||||
&& file.getFileName().toString().endsWith(".jsonl")).sorted().toList()) {
|
||||
for (var line : java.nio.file.Files.readAllLines(path,java.nio.charset.StandardCharsets.UTF_8)) {
|
||||
if (line.isBlank()) continue;
|
||||
final com.google.gson.JsonObject record;
|
||||
try { record = com.google.gson.JsonParser.parseString(line).getAsJsonObject(); }
|
||||
catch (RuntimeException corrupt) { throw new java.io.IOException("Invalid exported event segment",corrupt); }
|
||||
if (!eventId.toString().equals(record.get("id").getAsString())) continue;
|
||||
var details = record.getAsJsonObject("data");
|
||||
if (record.get("type").getAsString().endsWith("dungeon.completed")
|
||||
&& dungeon.toString().equals(details.get("dungeon_id").getAsString())
|
||||
&& generation.toString().equals(details.get("generation_id").getAsString())
|
||||
&& boss.toString().equals(details.get("boss_id").getAsString())) return true;
|
||||
throw new java.io.IOException("Conflicting exported dungeon completion identity");
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException invalid) { throw new java.io.IOException("Invalid exported dungeon completion proof",invalid); }
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String required(Map<String, String> audit, String key) {
|
||||
var value = audit.get(key);
|
||||
if (value == null) throw new IllegalArgumentException("Missing committed dungeon completion " + key);
|
||||
|
||||
@@ -18,7 +18,7 @@ public final class YamlDungeonRepository {
|
||||
public DungeonState load()throws IOException{
|
||||
if(Files.notExists(file))return DungeonState.empty();
|
||||
try{var root=map(yaml().load(Files.readString(file,StandardCharsets.UTF_8)));int schema=integer(root.get("schema-version"));if(schema!=1&&schema!=2)throw new IllegalArgumentException();var dungeons=new ArrayList<DungeonState.Dungeon>();for(var item:list(root.get("dungeons"))){var row=map(item);var rooms=new ArrayList<DungeonState.Room>();for(var roomItem:list(row.get("rooms"))){var room=map(roomItem);var mobs=new ArrayList<DungeonState.Spawn>();for(var mobItem:list(room.get("mobs"))){var mob=map(mobItem);mobs.add(new DungeonState.Spawn(uuid(mob,"id"),EntityType.valueOf(text(mob.get("type"))),text(mob.get("name")),position(map(mob.get("position")))));}var effects=new ArrayList<DungeonState.Effect>();for(var effectItem:list(room.get("effects"))){var effect=map(effectItem);effects.add(new DungeonState.Effect(uuid(effect,"id"),DungeonState.EffectTarget.valueOf(text(effect.get("target"))),text(effect.get("effect")),integer(effect.get("level")),bool(effect.get("particles"))));}DungeonState.Boss boss=null;if(room.get("boss")!=null){var value=map(room.get("boss"));DungeonState.CharacterBoss character=null;if(value.get("character")!=null){var custody=map(value.get("character"));DungeonState.Recovery recovery=null;if(custody.get("recovery")!=null){var pending=map(custody.get("recovery"));recovery=new DungeonState.Recovery(uuid(pending,"operation"),uuid(pending,"administrator"));}character=new DungeonState.CharacterBoss(uuid(custody,"operation"),CharacterYaml.decode(text(custody.get("record"))),new java.math.BigDecimal(text(custody.get("multiplier"))),custody.containsKey("prior-type")?EntityType.valueOf(text(custody.get("prior-type"))):EntityType.HUSK,custody.containsKey("prior-name")?text(custody.get("prior-name")):"",recovery);}boss=new DungeonState.Boss(EntityType.valueOf(text(value.get("type"))),text(value.get("name")),position(map(value.get("position"))),character);}rooms.add(new DungeonState.Room(uuid(room,"id"),text(room.get("name")),bounds(map(room.get("bounds"))),mobs,effects,new DungeonState.Reward(new BigInteger(text(room.get("reward-xp"))),new BigInteger(text(room.get("reward-coins")))),boss));}var mode=row.containsKey("participation-mode")?games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.parse(text(row.get("participation-mode"))):games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;var completion=schema==2?new DungeonState.Reward(new BigInteger(text(row.get("completion-reward-xp"))),new BigInteger(text(row.get("completion-reward-coins")))):rooms.stream().filter(room->room.boss()!=null).map(DungeonState.Room::reward).findFirst().orElse(DungeonState.Reward.empty());if(schema==1)for(int index=0;index<rooms.size();index++){var room=rooms.get(index);rooms.set(index,new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects(),DungeonState.Reward.empty(),room.boss()));}dungeons.add(new DungeonState.Dungeon(uuid(row,"id"),text(row.get("name")),uuid(row,"world"),position(map(row.get("entrance"))),bool(row.get("enabled")),uuid(row,"revision"),rooms,mode,completion,schema==1||bool(row.get("completion-reward-configured"))));}
|
||||
var runs=new ArrayList<DungeonState.Run>();for(var item:list(root.get("runs"))){var row=map(item);var unitClaims=new HashSet<DungeonState.UnitClaim>();for(var value:list(row.get("unit-claims"))){var claim=map(value);unitClaims.add(new DungeonState.UnitClaim(uuid(claim,"room"),uuid(claim,"quest")));}var characterClaims=new HashSet<DungeonState.CharacterClaim>();for(var value:list(row.get("character-claims"))){var claim=map(value);characterClaims.add(new DungeonState.CharacterClaim(uuid(claim,"room"),uuid(claim,"character")));}var audit=new LinkedHashMap<String,String>();if(row.get("completion-audit")!=null)for(var entry:map(row.get("completion-audit")).entrySet())audit.put(text(entry.getKey()),text(entry.getValue()));runs.add(new DungeonState.Run(uuid(row,"dungeon"),uuid(row,"generation"),uuids(row.get("defeated-slots")),uuids(row.get("cleared-rooms")),unitClaims,characterClaims,bool(row.get("defeated")),audit));}
|
||||
var runs=new ArrayList<DungeonState.Run>();for(var item:list(root.get("runs"))){var row=map(item);var unitClaims=new HashSet<DungeonState.UnitClaim>();for(var value:list(row.get("unit-claims"))){var claim=map(value);unitClaims.add(new DungeonState.UnitClaim(uuid(claim,"room"),uuid(claim,"quest")));}var characterClaims=new HashSet<DungeonState.CharacterClaim>();for(var value:list(row.get("character-claims"))){var claim=map(value);characterClaims.add(new DungeonState.CharacterClaim(uuid(claim,"room"),uuid(claim,"character")));}var audit=new LinkedHashMap<String,String>();if(row.get("completion-audit")!=null)for(var entry:map(row.get("completion-audit")).entrySet())audit.put(text(entry.getKey()),text(entry.getValue()));runs.add(new DungeonState.Run(uuid(row,"dungeon"),uuid(row,"generation"),uuids(row.get("defeated-slots")),uuids(row.get("cleared-rooms")),unitClaims,characterClaims,bool(row.get("defeated")),audit,row.containsKey("completion-published")&&bool(row.get("completion-published"))));}
|
||||
return new DungeonState(dungeons,runs);
|
||||
}catch(RuntimeException invalid){throw new IOException("Invalid dungeon file",invalid);}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ public final class YamlDungeonRepository {
|
||||
private static Map<String,Object> dungeon(DungeonState.Dungeon value){return row("id",value.id(),"name",value.name(),"world",value.world(),"entrance",position(value.entrance()),"enabled",value.enabled(),"revision",value.revision(),"participation-mode",value.participationMode().value(),"completion-reward-xp",value.completionReward().xp(),"completion-reward-coins",value.completionReward().coins(),"completion-reward-configured",value.rewardConfigured(),"rooms",value.rooms().stream().sorted(Comparator.comparing(DungeonState.Room::id)).map(YamlDungeonRepository::room).toList());}
|
||||
private static Map<String,Object> room(DungeonState.Room value){return row("id",value.id(),"name",value.name(),"bounds",bounds(value.bounds()),"mobs",value.mobs().stream().sorted(Comparator.comparing(DungeonState.Spawn::id)).map(v->row("id",v.id(),"type",v.type(),"name",v.name(),"position",position(v.position()))).toList(),"effects",value.effects().stream().sorted(Comparator.comparing(DungeonState.Effect::id)).map(v->row("id",v.id(),"target",v.target(),"effect",v.effect(),"level",v.level(),"particles",v.particles())).toList(),"reward-xp",value.reward().xp(),"reward-coins",value.reward().coins(),"boss",value.boss()==null?null:row("type",value.boss().type(),"name",value.boss().name(),"position",position(value.boss().position()),"character",value.boss().character()==null?null:character(value.boss().character())));}
|
||||
private static Map<String,Object> character(DungeonState.CharacterBoss value){return row("operation",value.operation(),"record",CharacterYaml.encode(value.character()),"multiplier",value.multiplier().toPlainString(),"prior-type",value.priorType(),"prior-name",value.priorName(),"recovery",value.recovery()==null?null:row("operation",value.recovery().operation(),"administrator",value.recovery().administrator()));}
|
||||
private static Map<String,Object> run(DungeonState.Run value){return row("dungeon",value.dungeon(),"generation",value.generation(),"defeated-slots",value.defeatedSlots().stream().sorted().toList(),"cleared-rooms",value.clearedRooms().stream().sorted().toList(),"unit-claims",value.unitClaims().stream().sorted(Comparator.comparing(v->v.room().toString()+v.quest())).map(v->row("room",v.room(),"quest",v.quest())).toList(),"character-claims",value.characterClaims().stream().sorted(Comparator.comparing(v->v.room().toString()+v.character())).map(v->row("room",v.room(),"character",v.character())).toList(),"defeated",value.defeated(),"completion-audit",new java.util.TreeMap<>(value.completionAudit()));}
|
||||
private static Map<String,Object> run(DungeonState.Run value){return row("dungeon",value.dungeon(),"generation",value.generation(),"defeated-slots",value.defeatedSlots().stream().sorted().toList(),"cleared-rooms",value.clearedRooms().stream().sorted().toList(),"unit-claims",value.unitClaims().stream().sorted(Comparator.comparing(v->v.room().toString()+v.quest())).map(v->row("room",v.room(),"quest",v.quest())).toList(),"character-claims",value.characterClaims().stream().sorted(Comparator.comparing(v->v.room().toString()+v.character())).map(v->row("room",v.room(),"character",v.character())).toList(),"defeated",value.defeated(),"completion-audit",new java.util.TreeMap<>(value.completionAudit()),"completion-published",value.completionPublished());}
|
||||
private static Map<String,Object> position(DungeonState.Position value){return row("x",value.x(),"y",value.y(),"z",value.z(),"yaw",value.yaw(),"pitch",value.pitch());}
|
||||
private static DungeonState.Position position(Map<?,?> value){return new DungeonState.Position(decimal(value.get("x")),decimal(value.get("y")),decimal(value.get("z")),(float)decimal(value.get("yaw")),(float)decimal(value.get("pitch")));}
|
||||
private static Map<String,Object> bounds(DungeonState.Bounds value){return row("min-x",value.minX(),"min-y",value.minY(),"min-z",value.minZ(),"max-x",value.maxX(),"max-y",value.maxY(),"max-z",value.maxZ());}
|
||||
|
||||
@@ -226,6 +226,7 @@ class DungeonNativeTest {
|
||||
Files.writeString(root.resolve("config.yml"),
|
||||
"adventure-daily-site-count: 0\nmaximum-level: 1\nhostile-coin-drop-min: 1\nhostile-coin-drop-max: 1\n");
|
||||
var defeatedBoss=new UUID[1];var absentLeader=new UUID[1];var presentMember=new UUID[1];
|
||||
var completionOperation=new UUID[1];
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(root)){
|
||||
f.plugin.setEnabled(true);
|
||||
var actor=new FantasyStickLifecycleNativeTest.Actor(root);
|
||||
@@ -260,6 +261,8 @@ class DungeonNativeTest {
|
||||
f.plugin,"adventureQuests");
|
||||
f.activate(actor);
|
||||
var catalog=adventures.snapshot().catalog();
|
||||
completionOperation[0]=UUID.nameUUIDFromBytes(("dungeon:completed:"+dungeon.id()+":"+
|
||||
catalog.revision()).getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(
|
||||
catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.SOLO,
|
||||
actor.core.record.id(),Set.of(actor.core.record.id())).get(5,TimeUnit.SECONDS).status());
|
||||
@@ -317,10 +320,41 @@ class DungeonNativeTest {
|
||||
await(restarted,()->{try{return exportedDungeonEvents(root).stream().anyMatch(event->
|
||||
event.get("type").getAsString().endsWith("dungeon.completed"));}
|
||||
catch(Exception transientExport){return false;}},List.of());
|
||||
var runs=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(restarted.plugin,"dungeons");
|
||||
await(restarted,()->runs.snapshot().runs().stream().anyMatch(run->run.defeated()
|
||||
&&defeatedBoss[0].toString().equals(run.completionAudit().get("boss_id"))
|
||||
&&run.completionPublished()),List.of());
|
||||
}
|
||||
var repository=new games.dmg.fantasystick.persistence.YamlDungeonRepository(root);
|
||||
var exportedOnce=repository.load();
|
||||
assertTrue(exportedOnce.runs().stream().anyMatch(run->run.defeated()
|
||||
&&defeatedBoss[0].toString().equals(run.completionAudit().get("boss_id"))
|
||||
&&run.completionPublished()),
|
||||
"the first exported recovery must durably acknowledge this exact boss");
|
||||
repository.save(new DungeonState(exportedOnce.dungeons(),exportedOnce.runs().stream()
|
||||
.map(run->new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),
|
||||
run.clearedRooms(),run.unitClaims(),run.characterClaims(),run.defeated(),
|
||||
run.completionAudit(),false)).toList()));
|
||||
// Simulate a crash after JSONL append but before the separate acknowledgement save.
|
||||
try(var replayAgain=new FantasyStickLifecycleNativeTest.Fixture(root)){
|
||||
replayAgain.plugin.setEnabled(true);
|
||||
var owner=(games.dmg.fantasystick.dungeon.DungeonController)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(replayAgain.plugin,"dungeonController");
|
||||
owner.replayCompletions(); // Force the normal startup replay even on fast test machines.
|
||||
var events=(games.dmg.fantasystick.events.FishingEventBridge)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(replayAgain.plugin,"fishingAuditEvents");
|
||||
replayAgain.plugin.setEnabled(false);
|
||||
events.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
|
||||
}
|
||||
var exportedId=UUID.nameUUIDFromBytes(("fantasystick:fishing:"+
|
||||
completionOperation[0]+":dungeon.completed")
|
||||
.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
assertFalse(Files.exists(root.resolve("fishing-event-intents").resolve(exportedId+".pending")),
|
||||
"a previously exported completion must not queue a fresh intent on a later restart");
|
||||
assertTrue(repository.load().runs().stream().anyMatch(run->run.defeated()
|
||||
&&defeatedBoss[0].toString().equals(run.completionAudit().get("boss_id"))
|
||||
&&run.completionPublished()),
|
||||
"the exported record repairs a lost acknowledgement before any new publication");
|
||||
var completed=exportedDungeonEvents(root).stream().filter(event->event.get("type")
|
||||
.getAsString().endsWith("dungeon.completed")).toList();
|
||||
assertEquals(1,completed.size(),"a later restart must not repeat an acknowledged completion");
|
||||
@@ -361,7 +395,7 @@ class DungeonNativeTest {
|
||||
when(admin.world.getEntities()).thenAnswer(ignored->new ArrayList<Entity>(spawned));
|
||||
var dungeons=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var dungeon=dungeons.create("Mirror",worldId,0,64,0,0,0).get(5,TimeUnit.SECONDS);var room=dungeons.addRadiusRoom(dungeon.id(),"Boss",0,64,0,3,3).get(5,TimeUnit.SECONDS);dungeons.setBoss(dungeon.id(),room.id(),EntityType.HUSK,"Temporary",0,65,0,0,0).get(5,TimeUnit.SECONDS);dungeons.setCompletionReward(dungeon.id(),BigInteger.ZERO,BigInteger.ZERO).get(5,TimeUnit.SECONDS);command(f,admin,"dungeon","edit","start",dungeon.id().toString());var controller=(games.dmg.fantasystick.dungeon.DungeonController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeonController");await(f,()->controller.editing(admin.core.id).isPresent(),admin.messages);admin.core.location.setX(.5);admin.core.location.setY(65);admin.core.location.setZ(.5);var sheet=admin.core.inventory.getItemInOffHand();admin.core.inventory.setItemInOffHand(null);admin.core.inventory.setItemInMainHand(sheet);command(f,admin,"dungeon","room","boss","assign-sheet","2");await(f,()->dungeons.custody(admin.core.record.id()).isPresent(),admin.messages);command(f,admin,"dungeon","edit","done");command(f,admin,"dungeon","enable",dungeon.id().toString());command(f,admin,"dungeon","open",dungeon.id().toString());var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");await(f,()->adventures.snapshot().catalog()!=null&&adventures.snapshot().catalog().sites().contains(dungeon.id()),admin.messages);
|
||||
var fighter=new FantasyStickLifecycleNativeTest.Actor(root);fighter.core.location.setWorld(admin.world);fighter.core.location.setX(.5);fighter.core.location.setY(65);fighter.core.location.setZ(.5);f.players.put(fighter.core.id,fighter.player);doReturn(false).when(fighter.player).isDead();doReturn(true).when(fighter.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());var abilityHits=new ArrayList<Double>();fighter.damage=(amount,source)->abilityHits.add(amount);f.activate(fighter);var catalog=adventures.snapshot().catalog();assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.SOLO,fighter.core.record.id(),Set.of(fighter.core.record.id())).get(5,TimeUnit.SECONDS).status());var challenger=new FantasyStickLifecycleNativeTest.Actor(root);challenger.core.location.setWorld(admin.world);challenger.core.location.setX(1.5);challenger.core.location.setY(65);challenger.core.location.setZ(.5);f.players.put(challenger.core.id,challenger.player);doReturn(false).when(challenger.player).isDead();doReturn(true).when(challenger.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());challenger.damage=(amount,sourceEntity)->{};f.activate(challenger);assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.SOLO,challenger.core.record.id(),Set.of(challenger.core.record.id())).get(5,TimeUnit.SECONDS).status());pulse(f);drain(f);pulse(f);drain(f);assertFalse(spawned.isEmpty());var boss=spawned.getLast();assertEquals(70d,maximumBases.get(boss)[0]);assertEquals(26d,attackBases.get(boss)[0]);var equipment=bossEquipment.get(boss);verify(equipment).setChestplate(argThat(item->item!=null&&item.getType()==Material.COPPER_CHESTPLATE));verify(equipment).setLeggings(argThat(item->item!=null&&item.getType()==Material.GOLDEN_LEGGINGS));verify(equipment).setBoots(argThat(item->item!=null&&item.getType()==Material.IRON_BOOTS));assertTrue(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.SPEED)),bossEffects.get(boss)::toString);assertTrue(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.NIGHT_VISION)),bossEffects.get(boss)::toString);boss.setHealth(50);var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(fighter.player);when(source.getDirectEntity()).thenReturn(fighter.player);f.dispatcher.accept(new EntityDamageByEntityEvent(fighter.player,boss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3));pulse(f);verify(boss,atLeastOnce()).setTarget(fighter.player);assertTrue(abilityHits.size()>=2,"Every ready offensive slot casts in the same pulse");int firstCasts=abilityHits.size();pulse(f);assertEquals(firstCasts,abilityHits.size(),"Boss-only cooldowns suppress immediate recasts");assertFalse(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.WEAKNESS)),"Purify removes a real harmful effect");var challengerSource=mock(org.bukkit.damage.DamageSource.class);when(challengerSource.getCausingEntity()).thenReturn(challenger.player);when(challengerSource.getDirectEntity()).thenReturn(challenger.player);f.dispatcher.accept(new EntityDamageByEntityEvent(challenger.player,boss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,challengerSource,10));pulse(f);var targets=org.mockito.ArgumentCaptor.forClass(LivingEntity.class);verify(boss,atLeastOnce()).setTarget(targets.capture());assertSame(fighter.player,targets.getAllValues().getLast(),"Higher later threat cannot break fixation");fighter.core.location.setX(100);pulse(f);targets=org.mockito.ArgumentCaptor.forClass(LivingEntity.class);verify(boss,atLeastOnce()).setTarget(targets.capture());assertSame(challenger.player,targets.getAllValues().getLast(),"Ineligible fixation retargets highest remaining threat");
|
||||
challenger.core.location.setX(100);pulse(f);verify(boss,atLeastOnce()).remove();challenger.core.location.setX(.5);pulse(f);drain(f);var freshBoss=spawned.getLast();killedBossId[0]=freshBoss.getUniqueId();assertNotSame(boss,freshBoss);assertEquals(70d,maximumBases.get(freshBoss)[0]);f.dispatcher.accept(new EntityDamageByEntityEvent(challenger.player,freshBoss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,challengerSource,2));f.dispatcher.accept(new EntityDeathEvent(freshBoss,challengerSource,new ArrayList<>(),0));await(f,()->dungeons.runView(dungeon.id(),catalog.revision()).map(DungeonState.Run::defeated).orElse(false),admin.messages);assertTrue(dungeons.custody(admin.core.record.id()).isPresent(),"Boss defeat keeps character custody for later days");challenger.core.location.setX(100);command(f,admin,"dungeon","disable",dungeon.id().toString(),"confirm");admin.core.location.setX(100);command(f,admin,"dungeon","boss","recover-sheet",dungeon.id().toString());await(f,()->dungeons.custody(admin.core.record.id()).isEmpty(),admin.messages);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(configuredBoss,storage.find(configuredBoss.id()).get(5,TimeUnit.SECONDS).orElseThrow());assertTrue(Arrays.stream(admin.core.inventory.getContents()).filter(Objects::nonNull).anyMatch(item->new games.dmg.fantasystick.items.CharacterSheets().identify(item).filter(configuredBoss.id()::equals).isPresent()));
|
||||
challenger.core.location.setX(100);pulse(f);verify(boss,atLeastOnce()).remove();challenger.core.location.setX(.5);pulse(f);drain(f);var freshBoss=spawned.getLast();killedBossId[0]=freshBoss.getUniqueId();assertNotSame(boss,freshBoss);assertEquals(70d,maximumBases.get(freshBoss)[0]);f.dispatcher.accept(new EntityDamageByEntityEvent(challenger.player,freshBoss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,challengerSource,2));f.dispatcher.accept(new EntityDeathEvent(freshBoss,challengerSource,new ArrayList<>(),0));await(f,()->dungeons.runView(dungeon.id(),catalog.revision()).map(DungeonState.Run::defeated).orElse(false),admin.messages);assertTrue(dungeons.custody(admin.core.record.id()).isPresent(),"Boss defeat keeps character custody for later days");await(f,()->{try{var published=exportedDungeonEvents(root);return published.stream().anyMatch(event->event.get("type").getAsString().endsWith("dungeon.completed"))&&published.stream().anyMatch(event->event.get("type").getAsString().endsWith("adventure.trophy_earned")&&event.getAsJsonObject("data").get("trophy_type").getAsString().equals("dungeon_trophy"));}catch(Exception transientExport){return false;}},admin.messages);challenger.core.location.setX(100);command(f,admin,"dungeon","disable",dungeon.id().toString(),"confirm");admin.core.location.setX(100);command(f,admin,"dungeon","boss","recover-sheet",dungeon.id().toString());await(f,()->dungeons.custody(admin.core.record.id()).isEmpty(),admin.messages);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(configuredBoss,storage.find(configuredBoss.id()).get(5,TimeUnit.SECONDS).orElseThrow());assertTrue(Arrays.stream(admin.core.inventory.getContents()).filter(Objects::nonNull).anyMatch(item->new games.dmg.fantasystick.items.CharacterSheets().identify(item).filter(configuredBoss.id()::equals).isPresent()));
|
||||
await(f,()->{try{var published=exportedDungeonEvents(root);return published.stream().anyMatch(event->event.get("type").getAsString().endsWith("dungeon.completed"))&&published.stream().anyMatch(event->event.get("type").getAsString().endsWith("adventure.trophy_earned")&&event.getAsJsonObject("data").get("trophy_type").getAsString().equals("dungeon_trophy"));}catch(Exception transientExport){return false;}},admin.messages);
|
||||
}
|
||||
var exported=exportedDungeonEvents(root);
|
||||
|
||||
@@ -32,6 +32,13 @@ final class DungeonDirectoryTest {
|
||||
var defeated=directory.defeat(dungeonId,generation,proof).get(5,TimeUnit.SECONDS);
|
||||
assertTrue(defeated.defeated());
|
||||
assertEquals(proof,defeated.completionAudit());
|
||||
assertFalse(defeated.completionPublished());
|
||||
assertFalse(directory.acknowledgeCompletion(dungeonId,generation,UUID.randomUUID())
|
||||
.get(5,TimeUnit.SECONDS),"another boss cannot acknowledge the committed defeat");
|
||||
assertTrue(directory.acknowledgeCompletion(dungeonId,generation,boss)
|
||||
.get(5,TimeUnit.SECONDS));
|
||||
assertTrue(directory.acknowledgeCompletion(dungeonId,generation,boss)
|
||||
.get(5,TimeUnit.SECONDS),"acknowledgement remains idempotent");
|
||||
assertEquals(proof,directory.markCleared(dungeonId,generation,roomId)
|
||||
.get(5,TimeUnit.SECONDS).completionAudit());
|
||||
}
|
||||
@@ -39,6 +46,7 @@ final class DungeonDirectoryTest {
|
||||
.findFirst().orElseThrow();
|
||||
assertEquals(boss.toString(),loaded.completionAudit().get("boss_id"));
|
||||
assertEquals(roomId.toString(),loaded.completionAudit().get("room_id"));
|
||||
assertTrue(loaded.completionPublished(),"acknowledged export survives room updates and reload");
|
||||
}
|
||||
|
||||
@Test void persistsConnectedDefinitionAndRejectsOverlapOrDisconnectedRemoval() throws Exception {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.dungeon.DungeonState;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
final class DungeonCompletionExportEvidenceTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void onlyMatchingExportedJsonlCanAcknowledgeTheExactCommittedBoss() throws Exception {
|
||||
var dungeon=UUID.randomUUID();var generation=UUID.randomUUID();var boss=UUID.randomUUID();
|
||||
var run=new DungeonState.Run(dungeon,generation,Set.of(),Set.of(),Set.of(),Set.of(),
|
||||
true,Map.of("boss_id",boss.toString()));
|
||||
var id=DungeonEventBridge.completionEventId(run);
|
||||
var pending=data.resolve("events/pending");Files.createDirectories(pending);
|
||||
Files.writeString(pending.resolve(id+".pending"),record(id,dungeon,generation,boss));
|
||||
assertFalse(DungeonEventBridge.exported(data,id,dungeon,generation,boss),
|
||||
"a transient pending intent is not an exported acknowledgement");
|
||||
|
||||
var day=data.resolve("events/2026-09-27");Files.createDirectories(day);
|
||||
var segment=day.resolve("segment.jsonl");
|
||||
Files.writeString(segment,record(id,dungeon,generation,boss)+"\n");
|
||||
assertTrue(DungeonEventBridge.exported(data,id,dungeon,generation,boss));
|
||||
assertFalse(DungeonEventBridge.exported(data,UUID.randomUUID(),dungeon,generation,boss));
|
||||
var externalId=UUID.randomUUID();
|
||||
var outside=data.resolve("outside.jsonl");
|
||||
Files.writeString(outside,record(externalId,dungeon,generation,boss)+"\n");
|
||||
Files.createSymbolicLink(day.resolve("linked.jsonl"),outside);
|
||||
assertFalse(DungeonEventBridge.exported(data,externalId,dungeon,generation,boss),
|
||||
"a linked external file is not an exported event segment");
|
||||
assertThrows(IOException.class,()->DungeonEventBridge.exported(data,id,dungeon,
|
||||
generation,UUID.randomUUID()),"an identity collision cannot acknowledge another boss");
|
||||
Files.writeString(segment,record(id,dungeon,generation,boss)+"\n{" );
|
||||
assertThrows(IOException.class,()->DungeonEventBridge.exported(data,UUID.randomUUID(),
|
||||
dungeon,generation,boss),"corrupt exported segments cannot attest absence safely");
|
||||
}
|
||||
|
||||
private static String record(UUID id,UUID dungeon,UUID generation,UUID boss){
|
||||
return "{\"id\":\""+id+"\",\"type\":\"dungeon.completed\",\"data\":{"
|
||||
+"\"dungeon_id\":\""+dungeon+"\",\"generation_id\":\""+generation
|
||||
+"\",\"boss_id\":\""+boss+"\"}}";
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user