Compare commits

...
28 Commits
Author SHA1 Message Date
dmg 9f0c18bcf2 fix(test): tie preference writer hold to shutdown phase
Release / release (push) Failing after 30m13s
2026-10-04 17:24:40 -04:00
dmg e54896cb38 fix(test): bound native fixture and acknowledgement lifetimes
Release / release (push) Failing after 15m44s
2026-10-04 16:20:29 -04:00
dmg 8b14eddf43 feat(dungeon): export original character boss audit events
Release / release (push) Failing after 16m7s
2026-10-03 16:43:19 -04:00
dmg de5391e8e4 feat(dungeon): export original authoring and runtime audit events
Release / release (push) Failing after 13m39s
2026-10-03 09:02:32 -04:00
dmg c6252e8805 feat(adventure): export acknowledged day-reset owner events
Release / release (push) Failing after 12m53s
2026-10-03 04:42:58 -04:00
dmg e83c6fbabc feat(arena): export original geometry and activation context
Release / release (push) Failing after 12m31s
2026-10-03 02:11:31 -04:00
dmg 12cdce1845 feat(champion): export feature-owned quest and native events
Release / release (push) Failing after 12m35s
2026-10-03 01:21:42 -04:00
dmg 6552a90a93 feat(events): audit registered daily camp lifecycles
Release / release (push) Failing after 12m15s
2026-10-02 23:07:48 -04:00
dmg 11d85a0a69 feat(events): audit accepted native stick Knockback
Release / release (push) Failing after 11m26s
2026-10-02 19:28:55 -04:00
dmg 0486de9b30 feat(events): audit Witch rewards and hard-mob XP policy
Release / release (push) Failing after 11m55s
2026-10-02 17:01:44 -04:00
dmg 6f8e83a2e8 feat(events): audit training owners and drain preference intents
Release / release (push) Failing after 11m16s
2026-10-02 15:46:56 -04:00
dmg 65a76b7fc4 feat(events): audit acknowledged armor owner outcomes
Release / release (push) Failing after 11m27s
2026-10-02 12:09:23 -04:00
dmg 4984ad26e7 feat(events): retain acknowledged coin owner audit intents
Release / release (push) Failing after 11m8s
2026-10-02 09:02:02 -04:00
dmg 6fc8c5d9eb feat(events): retain shared combat reward audit intents
Release / release (push) Failing after 10m57s
2026-10-02 08:03:15 -04:00
dmg f1b4479e83 feat(events): audit Low-Light Vision native lifecycle
Release / release (push) Failing after 10m47s
2026-10-02 07:21:11 -04:00
dmg d20c38a216 feat(events): audit Speed I owned native layer lifecycle
Release / release (push) Failing after 11m20s
2026-10-01 22:52:49 -04:00
dmg 03c121287c feat(events): audit Health Steal final-blow journeys
Release / release (push) Failing after 10m36s
2026-10-01 01:11:10 -04:00
dmg 908b0b02aa feat(events): audit Quicker Regeneration II native journeys
Release / release (push) Failing after 10m29s
2026-09-30 22:25:16 -04:00
dmg 92d3afc2f0 feat(events): audit stored Magic Core credit journeys
Release / release (push) Failing after 10m9s
2026-09-30 21:45:16 -04:00
dmg 42cf9e160f feat(events): audit confirmed Reskill journeys
Release / release (push) Successful in 21m3s
2026-09-30 21:28:08 -04:00
dmg cee4690be9 fix(great-sword): stabilize transferred recovery test
Release / release (push) Successful in 19m38s
2026-09-27 14:16:58 -04:00
dmg 9ed5dea830 fix(events): avoid transient pending files in JSONL tests
Release / release (push) Failing after 10m47s
2026-09-27 13:55:31 -04:00
dmg 43734d2634 feat(events): audit rare catalog and potion journeys
Release / release (push) Failing after 19m48s
2026-09-27 13:15:33 -04:00
dmg 103a3e44b7 test(sessions): accept exported switch intent on shutdown
Release / release (push) Successful in 11m26s
2026-09-27 11:42:00 -04:00
dmg e04a3152c1 fix(events): coalesce in-flight feature intent replay
Release / release (push) Failing after 19m41s
2026-09-27 10:53:50 -04:00
dmg 9bb56f3a1c test(adventure): await exported camp receipt before shutdown
Release / release (push) Failing after 10m14s
2026-09-27 09:51:34 -04:00
dmg a57b59f4f1 fix(dungeon): acknowledge exported completion once
Release / release (push) Failing after 10m29s
2026-09-27 01:24:54 -04:00
dmg f4db24492a feat(dungeon): audit native rewards and recover completion
Release / release (push) Failing after 10m13s
2026-09-26 21:09:22 -04:00
164 changed files with 14090 additions and 281 deletions

No files matched your search

@@ -159,6 +159,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.passive.QuickerRegenerationII quickerRegenerationII;
private games.dmg.fantasystick.persistence.HealthStealSettings healthStealSettings;
private games.dmg.fantasystick.passive.HealthSteal healthSteal;
private games.dmg.fantasystick.events.HealthStealEventBridge healthStealEvents;
private java.util.concurrent.CompletionStage<Void> healthStealAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.PassivePotionEventBridge passivePotionEvents;
private java.util.concurrent.CompletionStage<Void> passivePotionAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.passive.SpeedI speedI;
private games.dmg.fantasystick.passive.LowLightVision lowLightVision;
private games.dmg.fantasystick.passive.PassiveRegistry passiveRegistry;
@@ -166,6 +170,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.events.LocalEventLog eventLog;
private games.dmg.fantasystick.events.FishingEventBridge fishingAuditEvents;
private java.util.concurrent.CompletionStage<Void> fishingAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private java.util.concurrent.CompletionStage<Void> rareAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.PreferenceEventBridge preferenceEvents;
private games.dmg.fantasystick.events.EventCaptureListener eventCapture;
private games.dmg.fantasystick.events.DownedEventBridge downedEvents;
@@ -181,7 +186,27 @@ public final class FantasyStickPlugin extends JavaPlugin {
private java.util.concurrent.CompletionStage<Void> adminRevivalAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.PartyEventBridge partyAuditEvents;
private games.dmg.fantasystick.events.CombatRewardEventBridge combatRewardEvents;
private java.util.concurrent.CompletionStage<Void> combatRewardAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.StickKnockbackEventBridge stickKnockbackEvents;
private java.util.concurrent.CompletionStage<Void> stickKnockbackAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.CharacterBossEventBridge characterBossEvents;
private java.util.concurrent.CompletionStage<Void> characterBossDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.DungeonAuditEventBridge dungeonOwnerEvents;
private java.util.concurrent.CompletionStage<Void> dungeonOwnerDrain = 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;
private java.util.concurrent.CompletionStage<Void> hardMobXpAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.TrainingEventBridge trainingAuditEvents;
private java.util.concurrent.CompletionStage<Void> trainingAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.ArmorEventBridge armorAuditEvents;
private java.util.concurrent.CompletionStage<Void> armorAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.CoinEventBridge coinEvents;
private java.util.concurrent.CompletionStage<Void> coinAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.SessionEventBridge sessionAuditEvents;
private games.dmg.fantasystick.events.NutritionEventBridge nutritionAuditEvents;
private java.util.concurrent.CompletionStage<Void> nutritionAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
@@ -644,7 +669,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
armorerController=new games.dmg.fantasystick.shop.FantasyArmorerController(this,armorers,armorShopMenus::open);
rareShop=new games.dmg.fantasystick.shop.RareShop(rareMerchants,sessions,rareMarket,rareInventory,nextTick,failure->getLogger().log(Level.WARNING,"Rare Items purchase failed",failure));
rareShopMenus=new games.dmg.fantasystick.shop.RareShopMenus(this,rareShop);var localRareMenus=rareShopMenus;
rareMarket.attachListener(catalog->{if(lifetime!=generation)return;if(rareEvents!=null)rareEvents.catalogRefreshed(catalog);try{nextTick.accept(()->{if(lifetime==generation)localRareMenus.refreshOpen();});}catch(RuntimeException failure){if(lifetime==generation)getLogger().log(Level.WARNING,"Rare Items menu refresh failed",failure);}});
var pendingRareCatalogs=new java.util.concurrent.ConcurrentLinkedQueue<games.dmg.fantasystick.shop.RareCatalog>();
rareMarket.attachListener(catalog->{if(lifetime!=generation)return;var events=rareEvents;
if(events==null)pendingRareCatalogs.add(catalog);else events.catalogRefreshed(catalog);
if(catalog.reason()!=games.dmg.fantasystick.shop.RareCatalog.RefreshReason.STARTUP)
try{nextTick.accept(()->{if(lifetime==generation)localRareMenus.refreshOpen();});}
catch(RuntimeException failure){if(lifetime==generation)getLogger().log(Level.WARNING,"Rare Items menu refresh failed",failure);}});
rareMerchantController=new games.dmg.fantasystick.shop.RareMerchantController(this,rareMerchants,rareShopMenus::open);
rarePotions=new games.dmg.fantasystick.shop.RarePotionService(rareInventory,rareSettings,characters,sessions,this::dispatchOwnedHealing,nextTick,failure->getLogger().log(Level.WARNING,"Rare potion use failed",failure));
rareReskills=new games.dmg.fantasystick.shop.RareReskillService(rareInventory,characters,sessions,nextTick,record->{for(String ability:record.learned())abilityEngine.remove(record.id(),ability);speedI.sweep();lowLightVision.sweep();},failure->getLogger().log(Level.WARNING,"Rare Reskill use failed",failure));
@@ -676,6 +706,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
adventureQuestController.registerResetParticipant(dungeonController);
adventureQuestController.registerDungeonRoomPresence((dungeon,location)->location.getWorld()!=null&&dungeons.find(dungeon).filter(value->value.world().equals(location.getWorld().getUID())).isPresent()&&dungeons.roomAt(dungeon,location.getBlockX(),location.getBlockY(),location.getBlockZ()).isPresent());
combatExperience.attachManagedOrigins(entity->adventureQuestController.encounterOrigin(entity).or(()->dungeonController.encounterOrigin(entity)));
combatExperience.attachManagedRewardEvidence(dungeonController::killEvidence);
combatExperience.attachManagedSettlement(dungeonController::settleKill);
combatExperience.attachExcluded(entity->adventureQuestController.encounterOrigin(entity).isPresent());
sessionEvents.attachManagedInteractionTarget(entity -> partyRegistrarController.tagged(entity)
@@ -751,11 +782,60 @@ public final class FantasyStickPlugin extends JavaPlugin {
getServer().getVersion(), getServer().getMinecraftVersion(), getDescription().getVersion()), 1024, 134_217_728L,
message -> getLogger().warning(message));
eventCapture = new games.dmg.fantasystick.events.EventCaptureListener(eventLog, sessions, Clock.systemUTC());
var completionLog = eventLog;
var committedDungeonRuns = dungeons;
var completionLocation = getDataFolder().toPath();
var inFlightCompletions = new java.util.concurrent.ConcurrentHashMap<java.util.UUID,
java.util.concurrent.CompletableFuture<games.dmg.fantasystick.events.LocalEventLog.Delivery>>();
java.util.function.Function<games.dmg.fantasystick.events.EventInput,
java.util.concurrent.CompletionStage<games.dmg.fantasystick.events.LocalEventLog.Delivery>> fishingSink = input -> {
if (!"dungeon.completed".equals(input.type())) return completionLog.emit(input);
var result = new java.util.concurrent.CompletableFuture<games.dmg.fantasystick.events.LocalEventLog.Delivery>();
var prior = inFlightCompletions.putIfAbsent(input.id(),result);
if (prior != null) return prior;
try {
var evidence = input.data();
var dungeon = java.util.UUID.fromString((String)evidence.get("dungeon_id"));
var runGeneration = java.util.UUID.fromString((String)evidence.get("generation_id"));
var boss = java.util.UUID.fromString((String)evidence.get("boss_id"));
var exported = games.dmg.fantasystick.events.DungeonEventBridge.exported(
completionLocation,input.id(),dungeon,runGeneration,boss);
var delivery = exported
? java.util.concurrent.CompletableFuture.completedFuture(
games.dmg.fantasystick.events.LocalEventLog.Delivery.EXPORTED)
: completionLog.emit(input);
delivery.thenCompose(status -> {
if (status == games.dmg.fantasystick.events.LocalEventLog.Delivery.REJECTED)
return java.util.concurrent.CompletableFuture.completedFuture(status);
return committedDungeonRuns.acknowledgeCompletion(dungeon,runGeneration,boss)
.handle((ack,failure) -> {
if (failure != null) {
getLogger().log(Level.WARNING,"Dungeon completion publication acknowledgement failed",failure);
return games.dmg.fantasystick.events.LocalEventLog.Delivery.REJECTED;
}
return status;
});
}).whenComplete((status,failure) -> {
if (failure != null) getLogger().log(Level.WARNING,"Dungeon completion event sink failed",failure);
result.complete(failure == null ? status
: games.dmg.fantasystick.events.LocalEventLog.Delivery.REJECTED);
inFlightCompletions.remove(input.id(),result);
});
} catch (java.io.IOException | RuntimeException invalid) {
getLogger().log(Level.WARNING,"Dungeon completion export evidence is unavailable",invalid);
result.complete(games.dmg.fantasystick.events.LocalEventLog.Delivery.REJECTED);
inFlightCompletions.remove(input.id(),result);
}
return result;
};
fishingAuditEvents = new games.dmg.fantasystick.events.FishingEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
fishingSink, Clock.systemUTC(), getDataFolder().toPath(), sessions,
failure -> getLogger().log(Level.WARNING, "Fishing event intent failed", failure));
adventureQuestCommands.attachFishingEvents(fishingAuditEvents);
adventureQuestController.attachFishingEvents(fishingAuditEvents);
var dungeonAuditEvents=new games.dmg.fantasystick.events.DungeonEventBridge(fishingAuditEvents);
dungeonCommands.attachEvents(dungeonAuditEvents);
dungeonController.attachEvents(dungeonAuditEvents);
fishingController.attachFishingEvents(fishingAuditEvents);
admin.attachFishingEvents(fishingAuditEvents);
preferenceEvents = new games.dmg.fantasystick.events.PreferenceEventBridge(
@@ -776,7 +856,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
localBasicAttack.attachEvents(new games.dmg.fantasystick.events.BasicAttackEventBridge(
eventLog::emit, Clock.systemUTC()));
downedEvents = new games.dmg.fantasystick.events.DownedEventBridge(eventLog::emit, Clock.systemUTC());
rareEvents = new games.dmg.fantasystick.events.RareEventBridge(eventLog::emit, Clock.systemUTC());rareShop.attachEvents(rareEvents);
rareEvents = new games.dmg.fantasystick.events.RareEventBridge(eventLog::emit, Clock.systemUTC(),
getDataFolder().toPath(), failure->getLogger().log(Level.WARNING,"Rare event intent failed",failure));
rareShop.attachEvents(rareEvents);rarePotions.attachEvents(rareEvents);rareReskills.attachEvents(rareEvents);rareMagicCores.attachEvents(rareEvents);admin.attachRareEvents(rareEvents);
rareEvents.replay();
games.dmg.fantasystick.shop.RareCatalog committedRareCatalog;
while((committedRareCatalog=pendingRareCatalogs.poll())!=null)
rareEvents.catalogRefreshed(committedRareCatalog);
bookEvents = new games.dmg.fantasystick.events.BookEventBridge(eventLog::emit, Clock.systemUTC());books.attachEvents(bookEvents);
progressionEvents = new games.dmg.fantasystick.events.ProgressionEventBridge(eventLog::emit, Clock.systemUTC());books.attachProgressionEvents(progressionEvents);
abilityEvents = new games.dmg.fantasystick.events.AbilityEventBridge(eventLog::emit, Clock.systemUTC());books.attachAbilityEvents(abilityEvents);
@@ -801,7 +887,26 @@ public final class FantasyStickPlugin extends JavaPlugin {
abilityEngine.enableCastEventsFor("war_banner");
abilityEngine.enableCastEventsFor("meteor");
admin.attachAbilityConfigEvents(new games.dmg.fantasystick.events.AbilityConfigEventBridge(eventLog::emit, Clock.systemUTC()));
passiveEvents = new games.dmg.fantasystick.events.PassiveEventBridge(eventLog::emit, Clock.systemUTC());books.attachPassiveEvents(passiveEvents);admin.attachPassiveEvents(passiveEvents);
var knockbackAudit = new games.dmg.fantasystick.events.StickKnockbackEventBridge(eventLog::emit,
getDataFolder().toPath(), failure -> getLogger().log(Level.WARNING, "Knockback event intent failed", failure));
stickKnockbackEvents = knockbackAudit;
basicAttack.attachKnockbackEvents(knockbackAudit);
getServer().getPluginManager().registerEvents(knockbackAudit, this);
var passiveWriter = eventLog;
passiveEvents = new games.dmg.fantasystick.events.PassiveEventBridge(input -> {
if (games.dmg.fantasystick.passive.StickKnockback.ID.equals(input.data().get("passive_id"))
&& input.type().startsWith("passive.")) knockbackAudit.acknowledgedPassive(input);
else passiveWriter.emit(input);
}, Clock.systemUTC());books.attachPassiveEvents(passiveEvents);admin.attachPassiveEvents(passiveEvents);
healthStealEvents = new games.dmg.fantasystick.events.HealthStealEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING, "Health Steal event intent failed", failure));
healthSteal.attachEvents(healthStealEvents);
passivePotionEvents = new games.dmg.fantasystick.events.PassivePotionEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING, "Passive potion event intent failed", failure));
speedI.attachEvents(passivePotionEvents);
lowLightVision.attachEvents(passivePotionEvents);
recoveryEvents = new games.dmg.fantasystick.events.RecoveryEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
failure -> getLogger().log(Level.WARNING, "Recovery event intent failed", failure));
@@ -821,8 +926,56 @@ public final class FantasyStickPlugin extends JavaPlugin {
partyAuditEvents = new games.dmg.fantasystick.events.PartyEventBridge(eventLog::emit, Clock.systemUTC());partyManagement.attachEvents(partyAuditEvents);admin.attachPartyEvents(partyAuditEvents);
partyCombat.attachProtectionEvents(new games.dmg.fantasystick.events.PartyProtectionEventBridge(
eventLog::emit, Clock.systemUTC()));
combatRewardEvents = new games.dmg.fantasystick.events.CombatRewardEventBridge(eventLog::emit,Clock.systemUTC());combatExperience.attachEvents(combatRewardEvents);
coinEvents = new games.dmg.fantasystick.events.CoinEventBridge(eventLog::emit,Clock.systemUTC());characters.attachCoinEvents(coinEvents);admin.attachCoinEvents(coinEvents);
campAuditEvents = new games.dmg.fantasystick.events.CampEventBridge(eventLog::emit, getDataFolder().toPath(), sessions,
failure -> getLogger().log(Level.WARNING, "Camp event intent failed", failure));
var dungeonActorCapture = campAuditEvents;
dungeonOwnerEvents = new games.dmg.fantasystick.events.DungeonAuditEventBridge(eventLog::emit, getDataFolder().toPath(),
sender -> dungeonActorCapture.actor(sender, null), failure -> getLogger().log(Level.WARNING, "Dungeon owner event intent failed", failure));
admin.attachDungeonEvents(dungeonOwnerEvents);
dungeonCommands.attachOwnerEvents(dungeonOwnerEvents);
var characterBossActorCapture = dungeonOwnerEvents;
characterBossEvents = new games.dmg.fantasystick.events.CharacterBossEventBridge(eventLog::emit, getDataFolder().toPath(),
characterBossActorCapture::actor, failure -> getLogger().log(Level.WARNING, "Character boss event intent failed", failure));
dungeonCommands.attachCharacterBossEvents(characterBossEvents);
dungeonController.attachCharacterBossEvents(characterBossEvents);
dungeonController.attachOwnerEvents(dungeonOwnerEvents);
adventureQuestController.attachDungeonEvents(dungeonOwnerEvents, dungeons::find);
var observedDungeonDirectory = dungeons;
adventureQuests.attachDungeonQuestExpiryEvents(dungeonOwnerEvents::questExpired, id -> observedDungeonDirectory.find(id).isPresent());
dungeons.attachRunRemovalEvents(dungeonOwnerEvents::runsRemoved);
admin.attachCampEvents(campAuditEvents);
adventureQuestCommands.attachCampEvents(campAuditEvents);
adventureQuestController.attachCampEvents(campAuditEvents);
adventureQuests.attachCatalogEvents(campAuditEvents::catalogPublished);
championAuditEvents = new games.dmg.fantasystick.events.ChampionEventBridge(eventLog::emit, getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING, "Champion event intent failed", failure));
admin.attachChampionEvents(championAuditEvents);
adventureQuestCommands.attachChampionEvents(championAuditEvents);
adventureQuestController.attachChampionEvents(championAuditEvents);
adventureQuests.attachQuestExpiryEvents(campAuditEvents::questExpired);
adventureQuests.attachChampionQuestExpiryEvents(championAuditEvents::questExpired);
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);
trainingDummyController.attachEvents(trainingAuditEvents);
armorAuditEvents = new games.dmg.fantasystick.events.ArmorEventBridge(eventLog::emit, getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING, "Armor event intent failed", failure));
if (armorMarket != null) armorMarket.attachEvents(armorAuditEvents);
admin.attachArmorEvents(armorAuditEvents);
hardMobXpAuditEvents = new games.dmg.fantasystick.events.HardMobXpEventBridge(eventLog::emit, getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING, "Hard-mob XP event intent failed", failure));
admin.attachHardMobXpEvents(hardMobXpAuditEvents);
if (armorShop != null) armorShop.attachEvents(armorAuditEvents);
combatRewardEvents = new games.dmg.fantasystick.events.CombatRewardEventBridge(eventLog::emit,Clock.systemUTC(),getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING,"Combat reward event intent failed",failure));combatExperience.attachEvents(combatRewardEvents);
coinEvents = new games.dmg.fantasystick.events.CoinEventBridge(eventLog::emit,Clock.systemUTC(),getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING,"Coin event intent failed",failure));characters.attachCoinEvents(coinEvents);admin.attachCoinEvents(coinEvents);
sessionAuditEvents = new games.dmg.fantasystick.events.SessionEventBridge(eventLog::emit,Clock.systemUTC());sessions.attachSessionEvents(sessionAuditEvents);
nutritionAuditEvents = new games.dmg.fantasystick.events.NutritionEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
@@ -987,6 +1140,71 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (failure != null) getLogger().log(Level.WARNING, "Previous admin experience intent drain failed", failure);
if (replayGrantEvents != null) replayGrantEvents.replay();
});
var replayPassivePotionEvents = passivePotionEvents;
passivePotionAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous passive potion event drain failed", failure);
if (replayPassivePotionEvents != null) replayPassivePotionEvents.replay();
});
var replayKnockbackEvents = stickKnockbackEvents;
stickKnockbackAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous Knockback event drain failed", failure);
if (replayKnockbackEvents != null) replayKnockbackEvents.replay();
});
var replayHardMobXpEvents = hardMobXpAuditEvents;
hardMobXpAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous hard-mob XP event drain failed", failure);
if (replayHardMobXpEvents != null) replayHardMobXpEvents.replay();
});
var replayCharacterBossEvents = characterBossEvents;
characterBossDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous character boss event drain failed", failure);
if (replayCharacterBossEvents != null) replayCharacterBossEvents.replay();
});
var replayDungeonOwnerEvents = dungeonOwnerEvents;
dungeonOwnerDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous dungeon owner event drain failed", failure);
if (replayDungeonOwnerEvents != null) replayDungeonOwnerEvents.replay();
});
var replayCampEvents = campAuditEvents;
campAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous camp event drain failed", failure);
if (replayCampEvents != null) replayCampEvents.replay();
});
var replayChampionEvents = championAuditEvents;
championAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous champion event drain failed", failure);
if (replayChampionEvents != null) replayChampionEvents.replay();
});
var 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);
if (replayTrainingEvents != null) replayTrainingEvents.replay();
});
var replayArmorEvents = armorAuditEvents;
armorAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous armor event drain failed", failure);
if (replayArmorEvents != null) replayArmorEvents.replay();
});
var replayCoinEvents = coinEvents;
coinAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous coin event drain failed", failure);
if (replayCoinEvents != null) replayCoinEvents.replay();
});
var replayCombatRewardEvents = combatRewardEvents;
combatRewardAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous combat reward event drain failed", failure);
if (replayCombatRewardEvents != null) replayCombatRewardEvents.replay();
});
var replayHealthStealEvents = healthStealEvents;
healthStealAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous Health Steal event drain failed", failure);
if (replayHealthStealEvents != null) replayHealthStealEvents.replay();
});
var replayRecoveryEvents = recoveryEvents;
recoveryAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous recovery event intent drain failed", failure);
@@ -1026,6 +1244,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
fishingAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous fishing event intent drain failed", failure);
if (replayFishingEvents != null) replayFishingEvents.replay();
if (dungeonController != null) dungeonController.replayCompletions();
});
recipeEvents.replay(); // Serial worker replays old intents before new discoveries are captured.
}
@@ -1066,6 +1285,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingEvents = sessionEvents; sessionEvents = null;
var closingEventLog = eventLog; eventLog = null; preferenceEvents = null;
var closingFishingAuditEvents = fishingAuditEvents; fishingAuditEvents = null;
var closingRareEvents = rareEvents; rareEvents = null;
var closingNutritionAuditEvents = nutritionAuditEvents; nutritionAuditEvents = null;
var closingSwitchAuditEvents = switchAuditEvents; switchAuditEvents = null;
var closingSwitchConfigEvents = switchConfigEvents; switchConfigEvents = null;
@@ -1073,12 +1293,25 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingPartyCapacityEvents = partyCapacityEvents; partyCapacityEvents = null;
var closingSheetPackEvents = sheetResourcePackEvents; sheetResourcePackEvents = null;
var closingGrantEvents = adminExperienceEvents; adminExperienceEvents = null;
var closingKnockbackEvents = stickKnockbackEvents; stickKnockbackEvents = null;
var closingHardMobXpEvents = hardMobXpAuditEvents; hardMobXpAuditEvents = null;
var closingCharacterBossEvents = characterBossEvents; characterBossEvents = null;
var closingDungeonOwnerEvents = dungeonOwnerEvents; dungeonOwnerEvents = 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;
var closingCombatRewardEvents = combatRewardEvents;
var closingHealthStealEvents = healthStealEvents; healthStealEvents = null;
var closingPassivePotionEvents = passivePotionEvents; passivePotionEvents = null;
var closingRecoveryEvents = recoveryEvents; recoveryEvents = null;
var closingIdentityAuditEvents = identityAuditEvents; identityAuditEvents = null;
var closingAdminRevivalAuditEvents = adminRevivalEvents; adminRevivalEvents = null;
var closingEquipmentAuditEvents = equipmentAuditEvents; equipmentAuditEvents = null;
var closingSessionRecoveryAuditEvents = sessionRecoveryAuditEvents; sessionRecoveryAuditEvents = null;
var closingEventCapture = eventCapture; eventCapture = null; var closingMessageAuditEvents = messageAuditEvents; messageAuditEvents = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; abilityEvents = null; passiveEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null;
var closingEventCapture = eventCapture; eventCapture = null; var closingMessageAuditEvents = messageAuditEvents; messageAuditEvents = null; downedEvents = null; bookEvents = null; progressionEvents = null; abilityEvents = null; passiveEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null;
var closingBooks = books; books = null;
var closingPartyEvents = partyEvents; partyEvents = null;
var closingPartyCombat = partyCombat; partyCombat = null;
@@ -1191,7 +1424,6 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingAdmin != null) cleanup("admin command", closingAdmin::close);
if (closingFeedbackCommand != null) cleanup("feedback command", closingFeedbackCommand::close);
if (closingParticlesCommand != null) cleanup("particles command", closingParticlesCommand::close);
if (closingFeedbackPreferences != null) cleanup("feedback preferences", closingFeedbackPreferences::close);
if (closingSettings != null) cleanup("configuration", closingSettings::close);
if (closingFireballSettings != null) cleanup("Fireball configuration", closingFireballSettings::close);
if (closingGreatSwordSettings != null) cleanup("Great Sword configuration", closingGreatSwordSettings::close);
@@ -1435,6 +1667,118 @@ public final class FantasyStickPlugin extends JavaPlugin {
return null;
}).thenCompose(ignored -> closingGrantEvents.drained());
});
if (closingPassivePotionEvents != null) cleanup("passive potion event intent drain", () -> {
closingPassivePotionEvents.close();
passivePotionAuditDrain = closingPassivePotionEvents.drained();
});
if (closingHardMobXpEvents != null) cleanup("hard-mob XP event intent drain", () -> {
var settingsDrained = closingHardMobXpSettings == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingHardMobXpSettings.drained();
hardMobXpAuditDrain = settingsDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Hard-mob XP settings drain failed", failure);
closingHardMobXpEvents.close(); return null;
}).thenCompose(ignored -> closingHardMobXpEvents.drained());
});
if (closingCharacterBossEvents != null) cleanup("character boss event intent drain", () -> {
var dungeonDrained = closingDungeons == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingDungeons.drained();
var charactersDrained = closingCharacters == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
characterBossDrain = java.util.concurrent.CompletableFuture.allOf(dungeonDrained.toCompletableFuture(), charactersDrained.toCompletableFuture())
.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Character boss authority drain failed", failure);
closingCharacterBossEvents.close(); return null;
}).thenCompose(ignored -> closingCharacterBossEvents.drained());
});
if (closingDungeonOwnerEvents != null) cleanup("dungeon owner event intent drain", () -> {
var dungeonDrained = closingDungeons == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingDungeons.drained();
var questsDrained = closingAdventureQuests == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingAdventureQuests.drained();
var authorityDrained = java.util.concurrent.CompletableFuture.allOf(dungeonDrained.toCompletableFuture(),
questsDrained.toCompletableFuture(), configurationDrain.toCompletableFuture());
dungeonOwnerDrain = authorityDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Dungeon owner authority drain failed", failure);
closingDungeonOwnerEvents.close(); return null;
}).thenCompose(ignored -> closingDungeonOwnerEvents.drained());
});
if (closingCampEvents != null) cleanup("camp event intent drain", () -> {
var authorityDrained = closingAdventureQuests == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingAdventureQuests.drained();
campAuditDrain = java.util.concurrent.CompletableFuture.allOf(authorityDrained.toCompletableFuture(), configurationDrain.toCompletableFuture())
.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Camp authority drain failed", failure);
closingCampEvents.close(); return null;
}).thenCompose(ignored -> closingCampEvents.drained());
});
if (closingChampionEvents != null) cleanup("champion event intent drain", () -> {
var authorityDrained = closingAdventureQuests == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingAdventureQuests.drained();
championAuditDrain = java.util.concurrent.CompletableFuture.allOf(authorityDrained.toCompletableFuture(), configurationDrain.toCompletableFuture())
.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Champion authority drain failed", failure);
closingChampionEvents.close(); return null;
}).thenCompose(ignored -> closingChampionEvents.drained());
});
if (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();
trainingAuditDrain = registrationsDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Training registration drain failed", failure);
closingTrainingEvents.close(); return null;
}).thenCompose(ignored -> closingTrainingEvents.drained());
});
if (closingArmorEvents != null) cleanup("armor event intent drain", () -> {
var marketDrained = closingArmorMarket == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingArmorMarket.drained();
var registrationsDrained = closingArmorers == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingArmorers.drained();
armorAuditDrain = java.util.concurrent.CompletableFuture.allOf(marketDrained.toCompletableFuture(),
registrationsDrained.toCompletableFuture()).handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Armor authority drain failed", failure);
closingArmorEvents.close(); return null;
}).thenCompose(ignored -> closingArmorEvents.drained());
});
if (closingCoinEvents != null) cleanup("coin event intent drain", () -> {
var storageDrained = closingCharacters == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
coinAuditDrain = storageDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Character storage drain failed", failure);
closingCoinEvents.close(); return null;
}).thenCompose(ignored -> closingCoinEvents.drained());
});
if (closingCombatRewardEvents != null) cleanup("combat reward event intent drain", () -> {
var storageDrained = closingCharacters == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
combatRewardAuditDrain = storageDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Character storage drain failed", failure);
closingCombatRewardEvents.close(); return null;
}).thenCompose(ignored -> closingCombatRewardEvents.drained());
});
if (closingKnockbackEvents != null) cleanup("Knockback event intent drain", () -> {
var storageDrained = closingCharacters == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
stickKnockbackAuditDrain = storageDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Character storage drain failed", failure);
closingKnockbackEvents.close(); return null;
}).thenCompose(ignored -> closingKnockbackEvents.drained());
});
if (closingHealthStealEvents != null) cleanup("Health Steal event intent drain", () -> {
var storageDrained = closingCharacters == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
healthStealAuditDrain = storageDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Character storage drain failed", failure);
closingHealthStealEvents.close(); return null;
}).thenCompose(ignored -> closingHealthStealEvents.drained());
});
if (closingRecoveryEvents != null) cleanup("recovery event intent drain", () -> {
var settingsDrained = closingOutOfCombatRecoverySettings == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
@@ -1499,7 +1843,21 @@ public final class FantasyStickPlugin extends JavaPlugin {
return null;
}).thenCompose(ignored -> closingFishingAuditEvents.drained());
});
if (closingRareEvents != null) cleanup("rare event intent drain", () -> {
var marketDrained = closingRareMarket == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingRareMarket.drained();
var inventoryDrained = closingRareInventory == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingRareInventory.drained();
rareAuditDrain = java.util.concurrent.CompletableFuture.allOf(marketDrained.toCompletableFuture(),
inventoryDrained.toCompletableFuture(), configurationDrain.toCompletableFuture())
.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Rare owner drain failed", failure);
closingRareEvents.close();return null;
}).thenCompose(ignored -> closingRareEvents.drained());
});
if (closingEventLog != null) cleanup("local events", closingEventLog::close);
// Writer shutdown can still acknowledge already accepted preference exports.
if (closingFeedbackPreferences != null) cleanup("feedback preferences", closingFeedbackPreferences::close);
if (closingVitals != null) cleanup("native health hooks", closingVitals::close);
unregister(closingModes);
unregister(closingChat);
@@ -1531,6 +1889,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
unregister(closingAdminRevival);
unregister(closingCharacterRecovery);
unregister(closingBasicAttack);
unregister(closingKnockbackEvents);
unregister(closingNutrition);
unregister(closingOutOfCombatRecovery);
unregister(closingBooks);
@@ -435,7 +435,14 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
@Override public void damage(org.bukkit.entity.LivingEntity target, double amount,
org.bukkit.damage.DamageSource source) {
Objects.requireNonNull(target); Objects.requireNonNull(source);
var measured = trainingDamage.apply(input.player(), target, () -> target.damage(amount, source));
games.dmg.fantasystick.training.TrainingDamage.Source metadata = null;
try {
var record = sessions.currentRecord(input.session()).orElse(null);
if (record != null) metadata = new games.dmg.fantasystick.training.TrainingDamage.Source(
audit == null ? UUID.randomUUID() : audit.actionId(), input.session().player(), record.id(), record.name(),
input.session().session(), definition.name(), definition.id());
} catch (RuntimeException ignored) { /* Unavailable audit metadata cannot change the existing effect. */ }
var measured = trainingDamage.apply(input.player(), target, metadata, () -> target.damage(amount, source));
measured.ifPresent(value -> report(AbilityReport.Type.DAMAGE, target, value));
}
@Override public void report(AbilityReport.Type type, Entity target, java.math.BigDecimal amount) {
@@ -14,9 +14,13 @@ public final class AdventureQuestCommands {
public AdventureQuestCommands(Plugin plugin,AdventureQuestDirectory directory,AdventureQuestController controller,Consumer<Runnable> nextTick){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.controller=Objects.requireNonNull(controller);this.nextTick=Objects.requireNonNull(nextTick);locations=new AdventureLocationPresentation(plugin.getServer());teleports=new AdventureAdminTeleport(plugin,nextTick);}
private games.dmg.fantasystick.events.FishingEventBridge fishingEvents;
public void attachFishingEvents(games.dmg.fantasystick.events.FishingEventBridge value){fishingEvents=Objects.requireNonNull(value);}
public void adventure(CommandSender sender,String[] args){if(args.length==1){if(sender instanceof Player player)controller.inspect(player);else sender.sendMessage("Usage: /fstick adventure");return;}if(args.length==2&&args[1].equals("tracking")){if(sender instanceof Player player)controller.tracking(player,null);else sender.sendMessage("Quest tracking requires a player.");return;}if(args.length==3&&args[1].equals("tracking")&&(args[2].equals("on")||args[2].equals("off"))){if(sender instanceof Player player)controller.tracking(player,args[2].equals("on"));else sender.sendMessage("Quest tracking requires a player.");return;}if(args.length==2&&args[1].equals("reroll")){if(!authorized(sender))return;sender.sendMessage("Reroll expires every current adventure quest, encounter and reward claim. Run /fstickadmin adventure reroll confirm to reset the day.");return;}if(args.length!=3||!args[1].equals("reroll")||!args[2].equals("confirm")){sender.sendMessage("Usage: /fstick adventure [tracking [on|off]] or /fstickadmin adventure reroll [confirm]");return;}if(!authorized(sender))return;var current=directory.snapshot().catalog();UUID expected=current==null?null:current.revision();directory.rerollToday(expected).whenComplete((result,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Adventure reset failed; the current generation is unchanged.");return;}switch(result.status()){case STALE->sender.sendMessage("Adventure reset refused: the generation changed or is still being published; inspect it and try again.");case APPLIED->{var catalog=result.catalog().orElseThrow();controller.publishPendingReset().whenComplete((published,publishFailure)->respond(sender,()->{if(publishFailure!=null||!published){sender.sendMessage("Adventure reset is saved but pending recovery; the new generation is not yet available.");return;}sender.sendMessage("Reset daily adventures: generation="+catalog.revision()+", offers="+catalog.sites().size()+", expires="+catalog.expiresAt()+".");}));}}}));}
public void site(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usageSite(sender);return;}switch(args[1]){case"list"->{if(args.length!=2){usageSite(sender);return;}if(directory.snapshot().sites().stream().noneMatch(v->v.type()!=AdventureQuestState.SiteType.DUNGEON))sender.sendMessage("No adventure sites registered.");else directory.snapshot().sites().stream().filter(v->v.type()!=AdventureQuestState.SiteType.DUNGEON).sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.type().name().toLowerCase(Locale.ROOT)+" "+v.displayName()+" at "+locations.format(v.world(),v.x(),v.y(),v.z())+"; "+controller.arena(v).display()+"; mode="+v.participationMode().value()+siteDetails(v)));}case"place"->{if(args.length<3||!(sender instanceof Player player)){usageSite(sender);return;}if(args[2].equals("fishing")){placeFishing(sender,player,args);return;}if(!(args[2].equals("camp")||args[2].equals("champion"))){usageSite(sender);return;}int start=3;var mode=AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;if(args.length>3&&args[3].equals("--mode")){if(args.length<5){usageSite(sender);return;}try{mode=AdventureQuestState.ParticipationMode.parse(args[4]);}catch(IllegalArgumentException invalid){usageSite(sender);return;}start=5;}var placement=placement(args,start);if(placement==null){usageSite(sender);return;}var location=player.getLocation();var type=args[2].equals("camp")?AdventureQuestState.SiteType.CAMP:AdventureQuestState.SiteType.CHAMPION;directory.placeSite(type,placement.name(),Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),placement.arena(),mode).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Placed "+value.type().name().toLowerCase(Locale.ROOT)+" "+value.id()+" ("+value.displayName()+") at "+locations.format(value.world(),value.x(),value.y(),value.z())+"; "+controller.arena(value).display()+"; mode="+value.participationMode().value()+".":"Adventure site save failed; desired state is unchanged.")));}case"fishing"->{fishing(sender,args);}case"mode"->{if(args.length<3||args.length>4){usageSite(sender);return;}UUID id=parse(args[2]);var current=id==null?null:site(id).orElse(null);if(current==null){sender.sendMessage("Adventure site not found.");return;}if(args.length==3){sender.sendMessage("Adventure site "+id+" mode="+current.participationMode().value()+".");return;}AdventureQuestState.ParticipationMode requested;try{requested=AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){usageSite(sender);return;}var audit=fishingEvents;var auditActor=audit==null?null:audit.actor(sender);directory.setSiteMode(id,current.participationMode(),requested).whenComplete((result,failure)->{if(audit!=null&&current.type()==AdventureQuestState.SiteType.FISHING&&failure==null&&result.status()==AdventureQuestDirectory.ModeStatus.APPLIED)audit.site("adventure.site.mode_changed",UUID.randomUUID(),auditActor,result.site().orElseThrow(),Map.of("mode_before",current.participationMode().value(),"mode_after",requested.value()));respond(sender,()->{if(failure!=null){sender.sendMessage("Adventure site mode save failed; the acknowledged mode is unchanged.");return;}sender.sendMessage(switch(result.status()){case APPLIED->"Updated adventure site "+id+" mode="+result.site().orElseThrow().participationMode().value()+" for future acceptances.";case UNCHANGED->"Adventure site "+id+" mode is already "+requested.value()+".";case STALE->"Adventure site mode update refused: the acknowledged mode changed; inspect and retry.";case NOT_FOUND->"Adventure site not found.";});});});}case"remove"->{if(args.length!=3){usageSite(sender);return;}UUID id=parse(args[2]);if(id==null){usageSite(sender);return;}directory.removeSite(id).whenComplete((removed,failure)->respond(sender,()->{if(failure!=null)sender.sendMessage("Adventure site removal failed; desired state is unchanged.");else if(removed.isEmpty())sender.sendMessage("Adventure site not found.");else{controller.removeSiteLoaded(id);sender.sendMessage("Removed adventure site "+id+"; its quests expired.");}}));}case"teleport"->{if(args.length!=3){usageSite(sender);return;}if(!(sender instanceof Player)){sender.sendMessage("Adventure-site teleport requires a player.");return;}UUID id=parse(args[2]);if(id==null){usageSite(sender);return;}var site=site(id).orElse(null);if(site==null){sender.sendMessage("Adventure site not found.");return;}teleports.teleport(sender,"Adventure-site",target(site),()->site(id).map(AdventureQuestCommands::target));}default->usageSite(sender);}}
public void giver(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usageGiver(sender);return;}switch(args[1]){case"list"->{if(args.length!=2){usageGiver(sender);return;}if(directory.snapshot().givers().isEmpty())sender.sendMessage("No Quest Givers registered.");else directory.snapshot().givers().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.displayName()+" at "+locations.format(v.world(),v.x(),v.y(),v.z())));}case"place"->{if(!(sender instanceof Player player)){usageGiver(sender);return;}String name=join(args,2);var location=player.getLocation();directory.placeGiver(name,Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Quest Giver save failed; desired state is unchanged.");return;}var world=plugin.getServer().getWorld(value.world());if(world!=null&&world.isChunkLoaded(section(value.x()),section(value.z())))controller.reconcile(world.getChunkAt(section(value.x()),section(value.z())));sender.sendMessage("Placed Quest Giver "+value.id()+" ("+value.displayName()+") at "+locations.format(value.world(),value.x(),value.y(),value.z())+".");}));}case"remove"->{if(args.length!=3){usageGiver(sender);return;}UUID id=parse(args[2]);if(id==null){usageGiver(sender);return;}directory.removeGiver(id).whenComplete((removed,failure)->respond(sender,()->{if(failure!=null)sender.sendMessage("Quest Giver removal failed; desired state is unchanged.");else if(removed.isEmpty())sender.sendMessage("Quest Giver not found.");else{controller.removeGiverLoaded(id);sender.sendMessage("Removed Quest Giver "+id+".");}}));}default->usageGiver(sender);}}
private games.dmg.fantasystick.events.CampEventBridge campEvents;
public void attachCampEvents(games.dmg.fantasystick.events.CampEventBridge value){campEvents=Objects.requireNonNull(value);}
private games.dmg.fantasystick.events.ChampionEventBridge championEvents;
public void attachChampionEvents(games.dmg.fantasystick.events.ChampionEventBridge value){championEvents=Objects.requireNonNull(value);}
public void adventure(CommandSender sender,String[] args){if(args.length==1){if(sender instanceof Player player)controller.inspect(player);else sender.sendMessage("Usage: /fstick adventure");return;}if(args.length==2&&args[1].equals("tracking")){if(sender instanceof Player player)controller.tracking(player,null);else sender.sendMessage("Quest tracking requires a player.");return;}if(args.length==3&&args[1].equals("tracking")&&(args[2].equals("on")||args[2].equals("off"))){if(sender instanceof Player player)controller.tracking(player,args[2].equals("on"));else sender.sendMessage("Quest tracking requires a player.");return;}if(args.length==2&&args[1].equals("reroll")){if(!authorized(sender))return;sender.sendMessage("Reroll expires every current adventure quest, encounter and reward claim. Run /fstickadmin adventure reroll confirm to reset the day.");return;}if(args.length!=3||!args[1].equals("reroll")||!args[2].equals("confirm")){sender.sendMessage("Usage: /fstick adventure [tracking [on|off]] or /fstickadmin adventure reroll [confirm]");return;}if(!authorized(sender))return;var current=directory.snapshot().catalog();UUID expected=current==null?null:current.revision();var campAudit=campEvents;var actor=campAudit==null?null:campAudit.actor(sender,null);directory.rerollToday(expected,actor).whenComplete((result,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Adventure reset failed; the current generation is unchanged.");return;}switch(result.status()){case STALE->sender.sendMessage("Adventure reset refused: the generation changed or is still being published; inspect it and try again.");case APPLIED->{var catalog=result.catalog().orElseThrow();controller.publishPendingReset().whenComplete((published,publishFailure)->respond(sender,()->{if(publishFailure!=null||!published){sender.sendMessage("Adventure reset is saved but pending recovery; the new generation is not yet available.");return;}sender.sendMessage("Reset daily adventures: generation="+catalog.revision()+", offers="+catalog.sites().size()+", expires="+catalog.expiresAt()+".");}));}}}));}
public void site(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usageSite(sender);return;}switch(args[1]){case"list"->{if(args.length!=2){usageSite(sender);return;}if(directory.snapshot().sites().stream().noneMatch(v->v.type()!=AdventureQuestState.SiteType.DUNGEON))sender.sendMessage("No adventure sites registered.");else directory.snapshot().sites().stream().filter(v->v.type()!=AdventureQuestState.SiteType.DUNGEON).sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.type().name().toLowerCase(Locale.ROOT)+" "+v.displayName()+" at "+locations.format(v.world(),v.x(),v.y(),v.z())+"; "+controller.arena(v).display()+"; mode="+v.participationMode().value()+siteDetails(v)));}case"place"->{if(args.length<3||!(sender instanceof Player player)){usageSite(sender);return;}if(args[2].equals("fishing")){placeFishing(sender,player,args);return;}if(!(args[2].equals("camp")||args[2].equals("champion"))){usageSite(sender);return;}int start=3;var mode=AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;if(args.length>3&&args[3].equals("--mode")){if(args.length<5){usageSite(sender);return;}try{mode=AdventureQuestState.ParticipationMode.parse(args[4]);}catch(IllegalArgumentException invalid){usageSite(sender);return;}start=5;}var placement=placement(args,start);if(placement==null){usageSite(sender);return;}var location=player.getLocation();var type=args[2].equals("camp")?AdventureQuestState.SiteType.CAMP:AdventureQuestState.SiteType.CHAMPION;var campAudit=campEvents;var campActor=campAudit==null?null:campAudit.actor(sender,location);UUID operation=UUID.randomUUID();directory.placeSite(type,placement.name(),Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),placement.arena(),mode).whenComplete((value,failure)->{try{if(failure==null){if(campAudit!=null)campAudit.siteRegistered(operation,campActor,value,controller.arena(value));if(championEvents!=null)championEvents.siteRegistered(operation,campActor,value,controller.arena(value));}}catch(RuntimeException ignored){}respond(sender,()->sender.sendMessage(failure==null?"Placed "+value.type().name().toLowerCase(Locale.ROOT)+" "+value.id()+" ("+value.displayName()+") at "+locations.format(value.world(),value.x(),value.y(),value.z())+"; "+controller.arena(value).display()+"; mode="+value.participationMode().value()+".":"Adventure site save failed; desired state is unchanged."));});}case"fishing"->{fishing(sender,args);}case"mode"->{if(args.length<3||args.length>4){usageSite(sender);return;}UUID id=parse(args[2]);var current=id==null?null:site(id).orElse(null);if(current==null){sender.sendMessage("Adventure site not found.");return;}if(args.length==3){sender.sendMessage("Adventure site "+id+" mode="+current.participationMode().value()+".");return;}AdventureQuestState.ParticipationMode requested;try{requested=AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){usageSite(sender);return;}var audit=fishingEvents;var auditActor=audit==null?null:audit.actor(sender);var campAudit=campEvents;var campActor=campAudit==null?null:campAudit.actor(sender,null);UUID operation=UUID.randomUUID();directory.setSiteMode(id,current.participationMode(),requested).whenComplete((result,failure)->{try{if(failure==null&&result.status()==AdventureQuestDirectory.ModeStatus.APPLIED){if(campAudit!=null)campAudit.siteModeChanged(operation,campActor,current,result.site().orElseThrow(),controller.arena(result.site().orElseThrow()));if(championEvents!=null)championEvents.siteModeChanged(operation,campActor,current,result.site().orElseThrow(),controller.arena(result.site().orElseThrow()));}}catch(RuntimeException ignored){}if(audit!=null&&current.type()==AdventureQuestState.SiteType.FISHING&&failure==null&&result.status()==AdventureQuestDirectory.ModeStatus.APPLIED)audit.site("adventure.site.mode_changed",UUID.randomUUID(),auditActor,result.site().orElseThrow(),Map.of("mode_before",current.participationMode().value(),"mode_after",requested.value()));respond(sender,()->{if(failure!=null){sender.sendMessage("Adventure site mode save failed; the acknowledged mode is unchanged.");return;}sender.sendMessage(switch(result.status()){case APPLIED->"Updated adventure site "+id+" mode="+result.site().orElseThrow().participationMode().value()+" for future acceptances.";case UNCHANGED->"Adventure site "+id+" mode is already "+requested.value()+".";case STALE->"Adventure site mode update refused: the acknowledged mode changed; inspect and retry.";case NOT_FOUND->"Adventure site not found.";});});});}case"remove"->{if(args.length!=3){usageSite(sender);return;}UUID id=parse(args[2]);if(id==null){usageSite(sender);return;}var campAudit=campEvents;var campActor=campAudit==null?null:campAudit.actor(sender,null);UUID operation=UUID.randomUUID();directory.removeSite(id).whenComplete((removed,failure)->{try{if(failure==null&&removed.isPresent()){if(campAudit!=null)camLine truncated
public void giver(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usageGiver(sender);return;}switch(args[1]){case"list"->{if(args.length!=2){usageGiver(sender);return;}if(directory.snapshot().givers().isEmpty())sender.sendMessage("No Quest Givers registered.");else directory.snapshot().givers().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.displayName()+" at "+locations.format(v.world(),v.x(),v.y(),v.z())));}case"place"->{if(!(sender instanceof Player player)){usageGiver(sender);return;}String name=join(args,2);var location=player.getLocation();var campAudit=campEvents;var campActor=campAudit==null?null:campAudit.actor(sender,location);UUID operation=UUID.randomUUID();directory.placeGiver(name,Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Quest Giver save failed; desired state is unchanged.");return;}var observed=controller.observedGiver(value);boolean nativeReturned=false;try{var world=plugin.getServer().getWorld(value.world());if(world!=null&&world.isChunkLoaded(section(value.x()),section(value.z())))controller.reconcile(world.getChunkAt(section(value.x()),section(value.z())));nativeReturned=true;}finally{auditGiver(campAudit,"adventure.quest_giver.placed",operation,campActor,value,observed,nativeReturned);}sender.sendMessage("Placed Quest Giver "+value.id()+" ("+value.displayName()+") at "+locations.format(value.world(),value.x(),value.y(),value.z())+".");}));}case"remove"->{if(args.length!=3){usageGiver(sender);return;}UUID id=parse(args[2]);if(id==null){usageGiver(sender);return;}var campAudit=campEvents;var campActor=campAudit==null?null:campAudit.actor(sender,null);UUID operation=UUID.randomUUID();directory.removeGiver(id).whenComplete((removed,failure)->respond(sender,()->{if(failure!=null)sender.sendMessage("Quest Giver removal failed; desired state is unchanged.");else if(removed.isEmpty())sender.sendMessage("Quest Giver not found.");else{var value=removed.orElseThrow();var observed=controller.observedGiver(value);boolean nativeReturned=false;try{controller.removeGiverLoaded(id);nativeReturned=true;}finally{auditGiver(campAudit,"adventure.quest_giver.removed",operation,campActor,value,observed,nativeReturned);}sender.sendMessage("Removed Quest Giver "+id+".");}}));}default->usageGiver(sender);}}
public List<String> complete(CommandSender sender,String[] args){if(args.length==1){var values=new ArrayList<String>();values.add("adventure");if(sender.hasPermission(PERMISSION)){values.add("adventure-site");values.add("quest-giver");}return matching(values,args[0]);}if(args.length==2&&args[0].equals("adventure")){var values=new ArrayList<String>();if(sender instanceof Player player&&controller.trackingAvailable(player))values.add("tracking");if(sender.hasPermission(PERMISSION))values.add("reroll");return matching(values,args[1]);}if(args.length==3&&args[0].equals("adventure")&&args[1].equals("tracking")&&sender instanceof Player player&&controller.trackingAvailable(player))return matching(List.of("on","off"),args[2]);if(!sender.hasPermission(PERMISSION))return List.of();if(args.length==3&&args[0].equals("adventure")&&args[1].equals("reroll"))return matching(List.of("confirm"),args[2]);if(args.length==2&&args[0].equals("adventure-site"))return matching(sender instanceof Player?List.of("fishing","list","mode","place","remove","teleport"):List.of("fishing","list","mode","remove"),args[1]);if(args.length==3&&args[0].equals("adventure-site")&&args[1].equals("place"))return matching(List.of("camp","champion","fishing"),args[2]);if(args.length>=4&&args[0].equals("adventure-site")&&args[1].equals("place")&&args[2].equals("fishing"))return fishingOptionCompletion(args);if(args.length==4&&args[0].equals("adventure-site")&&args[1].equals("place")&&(args[2].equals("camp")||args[2].equals("champion"))){var values=new ArrayList<>(List.of("--mode","4","16","64"));return matching(values,args[3]);}if(args.length==5&&args[0].equals("adventure-site")&&args[1].equals("place")&&args[3].equals("--mode"))return matching(modeValues(),args[4]);if(args.length==6&&args[0].equals("adventure-site")&&args[1].equals("place")&&args[3].equals("--mode"))return matching(List.of("4","16","64"),args[5]);if(args.length==5&&args[0].equals("adventure-site")&&args[1].equals("place")&&whole(args[3])!=null)return matching(List.of("8","16","128"),args[4]);if(args.length==7&&args[0].equals("adventure-site")&&args[1].equals("place")&&args[3].equals("--mode")&&whole(args[5])!=null)return matching(List.of("8","16","128"),args[6]);if(args.length==3&&args[0].equals("adventure-site")&&Set.of("fishing","mode","remove","teleport").contains(args[1]))return matching(directory.snapshot().sites().stream().filter(v->v.type()!=AdventureQuestState.SiteType.DUNGEON).map(v->v.id().toString()).toList(),args[2]);if(args.length==4&&args[0].equals("adventure-site")&&args[1].equals("mode"))return matching(modeValues(),args[3]);if(args.length>=4&&args[0].equals("adventure-site")&&args[1].equals("fishing"))return fishingOptionCompletion(args);if(args.length==2&&args[0].equals("quest-giver"))return matching(sender instanceof Player?List.of("list","place","remove"):List.of("list","remove"),args[1]);if(args.length==3&&args[0].equals("quest-giver")&&args[1].equals("remove"))return matching(directory.snapshot().givers().stream().map(v->v.id().toString()).toList(),args[2]);return List.of();}
private void placeFishing(CommandSender sender,Player player,String[] args){var options=options(args,3,Set.of("name","item","required","chance","xp","coins","radius","mode"));if(options==null||!options.keySet().containsAll(Set.of("name","item","required","chance","xp","coins"))){usageSite(sender);return;}try{int radius=options.containsKey("radius")?bounded(options.get("radius"),4,64):directory.defaultRadius();var mode=options.containsKey("mode")?AdventureQuestState.ParticipationMode.parse(options.get("mode")):AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;var location=player.getLocation();var audit=fishingEvents;var actor=audit==null?null:audit.actor(sender);directory.placeFishingSite(options.get("name"),options.get("item"),Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),radius,percent(options.get("chance")),positive(options.get("required")),nonnegative(options.get("xp")),nonnegative(options.get("coins")),mode).whenComplete((value,failure)->{if(failure==null&&audit!=null)audit.site("adventure.site.registered",value.id(),actor,value,Map.of("definition","created"));respond(sender,()->sender.sendMessage(failure==null?"Placed fishing site "+value.id()+" ("+value.displayName()+"); "+fishingDetails(value)+".":"Fishing site save failed; desired state is unchanged."));});}catch(IllegalArgumentException invalid){usageSite(sender);}}
private void fishing(CommandSender sender,String[] args){if(args.length<3){usageSite(sender);return;}UUID id=parse(args[2]);var current=id==null?null:directory.findSite(id).filter(value->value.type()==AdventureQuestState.SiteType.FISHING).orElse(null);if(current==null){sender.sendMessage("Fishing site not found.");return;}if(args.length==3){sender.sendMessage(current.id()+" "+current.displayName()+"; "+fishingDetails(current)+".");return;}var options=options(args,3,Set.of("name","item","required","chance","xp","coins","radius","mode"));if(options==null||options.isEmpty()){usageSite(sender);return;}try{var before=current.fishing();var definition=new AdventureQuestState.FishingDefinition(before.itemId(),options.getOrDefault("item",before.itemName()),options.containsKey("required")?positive(options.get("required")):before.required(),options.containsKey("chance")?percent(options.get("chance")):before.chancePercent(),options.containsKey("xp")?nonnegative(options.get("xp")):before.rewardXp(),options.containsKey("coins")?nonnegative(options.get("coins")):before.rewardCoins(),options.containsKey("radius")?bounded(options.get("radius"),4,64):before.radius());var mode=options.containsKey("mode")?AdventureQuestState.ParticipationMode.parse(options.get("mode")):current.participationMode();var audit=fishingEvents;var actor=audit==null?null:audit.actor(sender);directory.updateFishingSite(id,current,options.getOrDefault("name",current.name()),definition,mode).whenComplete((result,failure)->{if(failure==null&&result.status()==AdventureQuestDirectory.FishingUpdateStatus.APPLIED&&audit!=null){var changes=new LinkedHashMap<String,Object>();for(var field:options.keySet())changes.put(field,options.get(field));audit.site("adventure.site.updated",id,actor,result.site().orElseThrow(),changes);}respond(sender,()->{if(failure!=null){sender.sendMessage("Fishing site update failed; the acknowledged definition is unchanged.");return;}sender.sendMessage(switch(result.status()){case APPLIED->"Updated fishing site "+id+"; "+fishingDetails(result.site().orElseThrow())+".";case UNCHANGED->"Fishing site "+id+" is unchanged.";case STALE->"Fishing site update refused: the acknowledged definition changed; inspect and retry.";case NOT_FOUND->"Fishing site not found.";});});});}catch(IllegalArgumentException invalid){usageSite(sender);}}
@@ -31,6 +35,12 @@ public final class AdventureQuestCommands {
private Optional<AdventureQuestState.Site> site(UUID id){return directory.findSite(id).filter(value->value.type()!=AdventureQuestState.SiteType.DUNGEON);}
private static AdventureAdminTeleport.Target target(AdventureQuestState.Site site){return new AdventureAdminTeleport.Target(site.id(),site.displayName(),switch(site.type()){case CAMP->"monster camp";case CHAMPION->"champion";case FISHING->"fishing site";case DUNGEON->"dungeon entrance";},site.world(),site.x(),site.y(),site.z(),0,0,true);}
private boolean authorized(CommandSender sender){if(sender.hasPermission(PERMISSION))return true;sender.sendMessage("Permission denied: you need fantasystick.admin to administer Fantasy Stick.");return false;}
private void auditGiver(games.dmg.fantasystick.events.CampEventBridge bridge,String type,UUID operation,
games.dmg.fantasystick.events.CampEventBridge.Actor actor,AdventureQuestState.Giver giver,
AdventureQuestController.ObservedGiver before,boolean nativeReturned){
try{if(bridge!=null)bridge.giverMutation(type,operation,actor,giver,before,controller.observedGiver(giver),nativeReturned);}
catch(RuntimeException ignored){}
}
private void respond(CommandSender sender,Runnable response){try{nextTick.accept(()->{if(sender.hasPermission(PERMISSION)&&(!(sender instanceof Player player)||player.isOnline()))response.run();});}catch(RuntimeException ignored){}}
private record Placement(String name,AdventureQuestState.Arena arena){}
private static Placement placement(String[] args,int start){if(args.length==start)return new Placement("",null);if(Arrays.stream(args,start,args.length).anyMatch(value->value.startsWith("--")))return null;Integer first=whole(args[start]);if(first==null)return looksNumeric(args[start])||args[start].startsWith("--")?null:new Placement(join(args,start),null);if(args.length>start+1){Integer second=whole(args[start+1]);if(second!=null)return first>=8&&first<=128&&second>=8&&second<=128?new Placement(join(args,start+2),AdventureQuestState.Arena.rectangle(first,second)):null;if(looksNumeric(args[start+1]))return null;}return first>=4&&first<=64?new Placement(join(args,start+1),AdventureQuestState.Arena.circle(first)):null;}
@@ -44,7 +44,7 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
private java.util.function.BiPredicate<UUID,Location> dungeonRoomPresence=(ignored,location)->false;
private org.bukkit.scheduler.BukkitTask pulseTask;private boolean closed;
private static final class MenuHolder implements InventoryHolder {final UUID catalog;final int page;final AdventureQuestState.Giver giver;MenuHolder(UUID catalog,int page,AdventureQuestState.Giver giver){this.catalog=catalog;this.page=page;this.giver=giver;}@Override public Inventory getInventory(){return null;}}
private static final class Attempt {final UUID id=UUID.randomUUID(),site,catalog;final AdventureQuestState.SiteType type;final Set<UUID> entities=new HashSet<>(),represented=new HashSet<>();final AdventureQuestState.Arena arena;final int resetSeconds;BossBar bossBar;LivingEntity champion;Instant absentSince;
private static final class Attempt {AdventureQuestState.Site auditSite;Map<String,Object> auditActivation=Map.of("capture_quality","unavailable");Map<String,Object> auditConfiguration=Map.of();List<org.bukkit.entity.EntityType> auditRoster=List.of();final Map<UUID,String> auditEntities=new LinkedHashMap<>();final UUID id=UUID.randomUUID(),site,catalog;final AdventureQuestState.SiteType type;final Set<UUID> entities=new HashSet<>(),represented=new HashSet<>();final AdventureQuestState.Arena arena;final int resetSeconds;BossBar bossBar;LivingEntity champion;Instant absentSince;
UUID defeatedEntity;Map<String,Object> defeatLocation=Map.of("status","unavailable");Instant defeatedAt;
Attempt(UUID site,UUID catalog,AdventureQuestState.SiteType type,AdventureQuestState.Arena arena,int resetSeconds){this.site=site;this.catalog=catalog;this.type=type;this.arena=arena;this.resetSeconds=resetSeconds;}}
public AdventureQuestController(Plugin plugin,AdventureQuestDirectory directory,AdventureQuestSettings settings,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,Clock clock,java.util.random.RandomGenerator random){this(plugin,directory,settings,sessions,parties,characters,progression,nextTick,clock,random,games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());}
@@ -74,33 +74,69 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
?Optional.of(origin):Optional.empty();
}
public void reconcile(Chunk chunk){if(closed)return;var desired=new HashMap<UUID,AdventureQuestState.Giver>();for(var giver:directory.snapshot().givers())if(giver.world().equals(chunk.getWorld().getUID())&&section(giver.x())==chunk.getX()&&section(giver.z())==chunk.getZ())desired.put(giver.id(),giver);var retained=new HashSet<UUID>();for(var entity:chunk.getEntities()){if(managed(entity)){entity.remove();continue;}if(!giver(entity))continue;UUID id=id(entity,idKey);var value=id==null?null:desired.get(id);if(!(entity instanceof Villager villager)||value==null||!retained.add(id)){entity.remove();continue;}configure(villager,value);var expected=location(value);var current=villager.getLocation();if(current.distanceSquared(expected)>0.000001||Math.abs(current.getYaw()-expected.getYaw())>0.001||Math.abs(current.getPitch()-expected.getPitch())>0.001){relocating.add(villager);boolean moved;try{moved=villager.teleport(expected);}finally{relocating.remove(villager);}if(!moved){villager.remove();spawnGiver(value);}}}for(var value:desired.values())if(!retained.contains(value.id()))spawnGiver(value);}
public record ObservedGiver(java.util.List<UUID> ids,String chunkStatus,String quality) {
public ObservedGiver{ids=java.util.List.copyOf(ids);}
}
/** Read only currently loaded native entities; optional audit getters never force a load or authorize reconciliation. */
public ObservedGiver observedGiver(AdventureQuestState.Giver value){
try{
var world=plugin.getServer().getWorld(value.world());
if(world==null)return new ObservedGiver(java.util.List.of(),"world_unavailable","unavailable");
if(!world.isChunkLoaded(section(value.x()),section(value.z())))return new ObservedGiver(java.util.List.of(),"unloaded","not_loaded");
var entities=world.getEntities();
if(entities==null)return new ObservedGiver(java.util.List.of(),"loaded","unavailable");
var ids=new java.util.ArrayList<UUID>();boolean complete=true;
for(var entity:entities){
try{if(giver(entity)&&value.id().equals(id(entity,idKey))&&entity.isValid()){
var nativeId=entity.getUniqueId();if(nativeId==null)complete=false;else ids.add(nativeId);
}}catch(RuntimeException unavailable){complete=false;}
}
return new ObservedGiver(ids.stream().sorted().toList(),"loaded",complete?"native":"partial");
}catch(RuntimeException unavailable){return new ObservedGiver(java.util.List.of(),"unavailable","unavailable");}
}
public void removeGiverLoaded(UUID id){for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(giver(entity)&&id.equals(id(entity,idKey)))entity.remove();}
public void removeSiteLoaded(UUID id){var attempt=attempts.remove(id);if(attempt!=null)cleanup(attempt);readyAt.remove(id);refreshTrackingPlayers();}
public void removeSiteLoaded(UUID id){var attempt=attempts.remove(id);if(attempt!=null)cleanup(attempt,"site_removed");readyAt.remove(id);refreshTrackingPlayers();}
public boolean hasActiveEncounter(){return !attempts.isEmpty()||!starting.isEmpty();}
public AdventureQuestState.Arena arena(AdventureQuestState.Site site){return site.arenaOrDefault(settings.rules().radius());}
public void registerFishing(FishingController fishing){if(closed||this.fishing!=null)throw new IllegalStateException("Fishing already attached or adventure controller closed");this.fishing=Objects.requireNonNull(fishing);}
private games.dmg.fantasystick.events.FishingEventBridge fishingEvents;
public void attachFishingEvents(games.dmg.fantasystick.events.FishingEventBridge value){fishingEvents=Objects.requireNonNull(value);}
private games.dmg.fantasystick.events.CampEventBridge campEvents;
public void attachCampEvents(games.dmg.fantasystick.events.CampEventBridge value){campEvents=Objects.requireNonNull(value);}
private games.dmg.fantasystick.events.ChampionEventBridge championEvents;
public void attachChampionEvents(games.dmg.fantasystick.events.ChampionEventBridge value){championEvents=Objects.requireNonNull(value);}
private games.dmg.fantasystick.dungeon.DungeonState.Dungeon captureDungeonDefinition(UUID id){try{return dungeonDefinitions.apply(id).orElse(null);}catch(RuntimeException unavailable){return null;}}
private games.dmg.fantasystick.events.DungeonAuditEventBridge dungeonEvents;
private java.util.function.Function<UUID,Optional<games.dmg.fantasystick.dungeon.DungeonState.Dungeon>> dungeonDefinitions=ignored->Optional.empty();
public void attachDungeonEvents(games.dmg.fantasystick.events.DungeonAuditEventBridge events,java.util.function.Function<UUID,Optional<games.dmg.fantasystick.dungeon.DungeonState.Dungeon>> definitions){dungeonEvents=Objects.requireNonNull(events);dungeonDefinitions=Objects.requireNonNull(definitions);}
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);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);}
private void open(Player player,int requestedPage){open(player,requestedPage,null);}
private void open(Player player,int requestedPage,AdventureQuestState.Giver giver){var context=context(player);if(context==null){player.sendMessage("Quest Giver refused: actively play a living fantasy character first.");return;}var pending=directory.pendingClaim(context.character);if(pending.isPresent()){var record=sessions.currentRecord(context.active).orElse(null);if(record==null){player.sendMessage("Reward claim recovery requires the exact active character.");return;}attemptClaim(player,context.active,pending.orElseThrow(),record);return;}directory.ensureToday().whenComplete((catalog,failure)->schedule(()->{var currentContext=context(player);if(failure!=null||currentContext==null||!currentContext.active.equals(context.active)){player.sendMessage("Daily quests are unavailable.");return;}if(directory.snapshot().reset()!=null){publishPendingReset();player.sendMessage("Daily adventure reset is completing; try again.");return;}int pages=Math.max(1,(catalog.sites().size()+44)/45),page=Math.max(0,Math.min(requestedPage,pages-1));int count=Math.min(45,Math.max(0,catalog.sites().size()-page*45));int size=pages>1?54:Math.min(54,Math.max(18,((count+8)/9)*9+9));var inventory=plugin.getServer().createInventory(new MenuHolder(catalog.revision(),page,giver),size,net.kyori.adventure.text.Component.text("Daily Adventure Quests"));int slot=0;for(UUID siteId:catalog.sites().stream().skip(page*45L).limit(45).toList()){var site=directory.findSite(siteId).orElse(null);var item=new ItemStack(Material.PAPER);var meta=item.getItemMeta();String name=site==null?"Unavailable site":site.displayName();meta.displayName(net.kyori.adventure.text.Component.text(name));var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog.revision(),siteId,context.character);boolean rewardsReady=unclaimed.xp().signum()>0||unclaimed.coins().signum()>0;String state=rewardsReady?"Rewards ready — click to claim":active!=null&&active.site().equals(siteId)?"Active — right-click to abandon":active!=null?"Another quest is active":directory.characterClaimed(catalog.revision(),siteId,context.character)?directory.rewardClaimed(catalog.revision(),siteId,context.character)?"Already claimed":"Completed with nothing owed":site==null||!directory.offerOpen(siteId)||!directory.available(site)?"Unavailable / cleared":"Available — click to accept";String kind=site==null?"Unavailable":switch(site.type()){case CAMP->"Monster camp";case CHAMPION->"Champion";case DUNGEON->"Dungeon";case FISHING->"Fishing";};var reward=site==null?new AdventureQuestDirectory.Reward(BigInteger.ZERO,BigInteger.ZERO):directory.reward(site);var rewardXp=rewardsReady?unclaimed.xp():active!=null&&active.site().equals(siteId)?active.rewardXp():reward.xp();var rewardCoins=rewardsReady?unclaimed.coins():active!=null&&active.site().equals(siteId)?active.rewardCoins():reward.coins();String rewardLabel=rewardsReady?"Banked reward: ":site!=null&&site.type()==AdventureQuestState.SiteType.DUNGEON?"Completion trophy pool: ":"Reward: ";var lore=new ArrayList<net.kyori.adventure.text.Component>();lore.add(net.kyori.adventure.text.Component.text(kind));var fishingTerms=active!=null&&active.site().equals(siteId)&&active.fishing()!=null?active.fishing():site==null||site.fishing()==null?null:site.fishing().terms();if(fishingTerms!=null){lore.add(net.kyori.adventure.text.Component.text("Item: "+fishingTerms.itemName()+" × "+fishingTerms.required()));lore.add(net.kyori.adventure.text.Component.text("Catch chance: "+fishingTerms.chancePercent().toPlainString()+"%"));if(active!=null&&active.site().equals(siteId)&&fishing!=null)lore.add(net.kyori.adventure.text.Component.text("Held: "+fishing.quantity(context.character,fishingTerms.itemId())+" / "+fishingTerms.required()));}lore.add(net.kyori.adventure.text.Component.text(rewardLabel+rewardXp+" XP, "+rewardCoins+" coins"));if(site!=null&&site.type()==AdventureQuestState.SiteType.DUNGEON)lore.add(net.kyori.adventure.text.Component.text("Mob kills: additional variable XP, coins and native loot"));if(site!=null){lore.add(net.kyori.adventure.text.Component.text("Mode: "+site.participationMode().value()));if(active==null&&!rewardsReady&&!directory.characterClaimed(catalog.revision(),siteId,context.character)&&directory.offerOpen(siteId)&&directory.available(site))acceptanceLore(lore,site.participationMode(),context);}lore.add(net.kyori.adventure.text.Component.text("Expires: "+catalog.expiresAt()));lore.add(net.kyori.adventure.text.Component.text(state));meta.lore(lore);meta.getPersistentDataContainer().set(menuSiteKey,PersistentDataType.STRING,siteId.toString());meta.getPersistentDataContainer().set(menuCatalogKey,PersistentDataType.STRING,catalog.revision().toString());item.setItemMeta(meta);inventory.setItem(slot++,item);}if(pages>1){if(page>0)inventory.setItem(45,pageItem(page-1,"Previous page"));if(page+1<pages)inventory.setItem(53,pageItem(page+1,"Next page"));}if(fishing!=null){var turnIn=new ILine truncated
private ItemStack pageItem(int page,String name){var item=new ItemStack(Material.ARROW);var meta=item.getItemMeta();meta.displayName(net.kyori.adventure.text.Component.text(name));meta.getPersistentDataContainer().set(menuPageKey,PersistentDataType.INTEGER,page);item.setItemMeta(meta);return item;}
private static games.dmg.fantasystick.events.CampEventBridge.Actor championQuestActor(games.dmg.fantasystick.events.CampEventBridge.Actor actor,AdventureQuestState.Giver giver){
var facts=new LinkedHashMap<String,Object>(actor==null?Map.of("actor_kind","unavailable"):actor.captured());
if(giver!=null){facts.put("giver_id",giver.id().toString());facts.put("giver_location",Map.of("world_id",giver.world().toString(),"x",giver.x(),"y",giver.y(),"z",giver.z()));facts.put("giver_capture_quality","original_menu_target");}
else facts.put("giver_capture_quality","unavailable");
return new games.dmg.fantasystick.events.CampEventBridge.Actor(actor==null?null:actor.context(),facts);
}
private static void acceptanceLore(List<net.kyori.adventure.text.Component> lore,AdventureQuestState.ParticipationMode mode,Context context){if(mode!=AdventureQuestState.ParticipationMode.PARTY_REQUIRED)lore.add(net.kyori.adventure.text.Component.text("Left click: Accept individually"));if(mode!=AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY){if(context.partyId!=null&&context.leader)lore.add(net.kyori.adventure.text.Component.text("Right click: Accept for party"));else lore.add(net.kyori.adventure.text.Component.text("Party acceptance requires the active leader"));}}
private record Context(EquipmentSessions.Active active,UUID character,UUID partyId,boolean leader,Set<UUID> members){}
private Context context(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return null;var record=sessions.currentRecord(active).orElse(null);if(record==null||record.dead()||player.isDead())return null;var party=parties.partyOf(player.getUniqueId()).orElse(null);if(party==null)return new Context(active,record.id(),null,true,Set.of(record.id()));return new Context(active,record.id(),party.id(),party.leaderCharacter().equals(record.id()),Set.copyOf(party.characters()));}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void interact(PlayerInteractEntityEvent event){if(!giver(event.getRightClicked()))return;event.setCancelled(true);if(event.getHand()!=EquipmentSlot.HAND)return;UUID id=id(event.getRightClicked(),idKey);if(id!=null)directory.findGiver(id).ifPresent(giver->open(event.getPlayer(),0,giver));}
@EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof MenuHolder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null)return;var data=event.getCurrentItem().getPersistentDataContainer();Integer page=data.get(menuPageKey,PersistentDataType.INTEGER);if(page!=null){open(player,page,holder.giver);return;}if(data.has(menuFishingKey,PersistentDataType.INTEGER)&&fishing!=null){var now=directory.snapshot();if(now.reset()!=null||now.catalog()==null||!holder.catalog.equals(now.catalog().revision())){player.sendMessage("Quest item entry refused: the Quest Giver menu expired.");return;}fishing.open(player,holder.giver);return;}UUID site=parse(data.get(menuSiteKey,PersistentDataType.STRING)),catalog=parse(data.get(menuCatalogKey,PersistentDataType.STRING));if(site==null||catalog==null||!holder.catalog.equals(catalog))return;var context=context(player);if(context==null){player.sendMessage("Quest action refused: stale fantasy session.");return;}var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog,site,context.character);if(unclaimed.xp().signum()>0||unclaimed.coins().signum()>0){claim(player,context,catalog,site);return;}if(active!=null){if(!active.site().equals(site)){player.sendMessage("Quest action refused: another quest is active.");return;}if(!event.isRightClick()){player.sendMessage("This quest is active; right-click its Paper to abandon it.");return;}directory.abandon(active.id(),active.unit()).whenComplete((changed,failure)->schedule(()->{refreshTracking(player);player.sendMessage(failure==null&&changed?"Adventure quest abandoned.":"Quest abandonment failed; retained state is unchanged.");open(player);}));return;}if(directory.characterClaimed(catalog,site,context.character)){player.sendMessage(directory.rewardClaimed(catalog,site,context.character)?"Adventure rewards were already claimed.":"Adventure complete with no reward owed.");return;}if(!directory.offerOpen(site)||directory.findSite(site).filter(directory::available).isEmpty()){player.sendMessage("Quest action refused: this offer is unavailable.");return;}var offered=directory.findSite(site).orElseThrow();var type=event.isRightClick()?AdventureQuestState.UnitType.PARTY:AdventureQuestState.UnitType.SOLO;if(!offered.participationMode().permits(type)){player.sendMessage("Quest acceptance refused: this site is "+offered.participationMode().value()+".");return;}if(type==AdventureQuestState.UnitType.PARTY&&(context.partyId==null||!context.leader)){player.sendMessage("Only the active party leader may accept a party quest.");return;}var unit=type==AdventureQuestState.UnitType.SOLO?context.character:context.partyId;var members=type==AdventureQuestState.UnitType.SOLO?Set.of(context.character):context.members;directory.accept(catalog,site,type,unit,members,offered.participationMode()).whenComplete((result,failure)->schedule(()->{if(failure!=null){player.sendMessage("Quest acceptance failed; retained state is unchanged.");return;}if(result.status()!=AdventureQuestDirectory.Acceptance.APPLIED){player.sendMessage("Quest acceptance refused: "+result.status().name().toLowerCase(Locale.ROOT)+".");return;}var quest=result.quest().orElseThrow();var current=context(player);boolean same=current!=null&&current.active.equals(context.active)&&current.character.equals(context.character)&&(type==AdventureQuestState.UnitType.SOLO||current.leader&&Objects.equals(current.partyId,context.partyId)&&current.members.equals(context.members));if(!same){directory.abandon(quest.id(),quest.unit()).whenComplete((ignored,rollbackFailure)->schedule(()->player.sendMessage(rollbackFailure==null?"Quest acceptance refused: the character or party changed.":"Quest acceptance is pending safe cancellation; retry after recovery.")));return;}var location=directory.findSite(site).orElseThrow();if(quest.fishing()!=null&&fishingEvents!=null)fishingEvents.accepted(quest.id(),fishingEvents.actor(player),quest);refreshTracking(player);player.sendMessage("Accepted "+location.displayName()+"; expires "+quest.expiresAt()+". Follow the Quest Compass.");}));}
@EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof MenuHolder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null)return;var data=event.getCurrentItem().getPersistentDataContainer();Integer page=data.get(menuPageKey,PersistentDataType.INTEGER);if(page!=null){open(player,page,holder.giver);return;}if(data.has(menuFishingKey,PersistentDataType.INTEGER)&&fishing!=null){var now=directory.snapshot();if(now.reset()!=null||now.catalog()==null||!holder.catalog.equals(now.catalog().revision())){player.sendMessage("Quest item entry refused: the Quest Giver menu expired.");return;}fishing.open(player,holder.giver);return;}UUID site=parse(data.get(menuSiteKey,PersistentDataType.STRING)),catalog=parse(data.get(menuCatalogKey,PersistentDataType.STRING));if(site==null||catalog==null||!holder.catalog.equals(catalog))return;var context=context(player);if(context==null){player.sendMessage("Quest action refused: stale fantasy session.");return;}var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog,site,context.character);if(unclaimed.xp().signum()>0||unclaimed.coins().signum()>0){claim(player,context,catalog,site);return;}if(active!=null){if(!active.site().equals(site)){player.sendMessage("Quest action refused: another quest is active.");return;}if(!event.isRightClick()){player.sendMessage("This quest is active; right-click its Paper to abandon it.");return;}var audit=campEvents;var championAudit=championEvents;var actor=audit==null?null:audit.actor(player,null);var championActor=championQuestActor(actor,holder.giver);UUID operation=UUID.randomUUID();var capturedSite=directory.findSite(active.site()).orElse(null);var dungeonAudit=dungeonEvents;var originalDungeon=captureDungeonDefinition(active.site());directory.abandon(active.id(),active.unit()).whenComplete((changed,failure)->{if(failure==null&&changed){if(audit!=null)audit.questTransition("adventure.quest.abandoned",operation,actor,active,capturedSite,null,"player_abandoned");if(championAudit!=null)championAudit.questTransition("adventure.quest.abandoned",operation,championActor,active,capturedSite,null,"player_abandoned");if(dungeonAudit!=null)try{dungeonAudit.questTransition("adventure.quest.abandoned",operation,championActor,active,capturedSite,originalDungeon,"player_abandoned");}catch(RuntimeException ignored){}}schedule(()->{refreshTracking(player);player.sendMessage(failure==null&&changed?"Adventure quest abandoned.":"Quest abandonment failed; retained state is unchanged.");open(player);});});return;}if(directory.characterClaimed(catalog,site,context.character)){player.sendMessage(directory.rewardClaimed(catalog,site,context.character)?"Adventure rewards were already claimed.":"Adventure complete with no reward owed.");return;}if(!directory.offerOpen(site)||directory.findSite(site).filter(directory::available).isEmpty()){player.sendMessage("Quest action refused: this offer is unavailable.");return;}var offered=directory.findSite(site).orElseThrow();var type=event.isRightClick()?AdventureQuestState.UnitType.PARTY:AdventureQuestState.UnitType.SOLO;if(!offered.participationMode().permits(type)){player.sendMessage("Quest acceptance refused: this site is "+offered.participationMode().value()+".");return;}if(type==AdventureQuestState.UnitType.PARTY&&(context.partyId==null||!context.leader)){player.sendMessage("Only the active party leader may accept a party quest.");return;}var unit=type==AdventureQuestState.UnitType.SOLO?context.character:context.partyId;var members=type==AdventureQuestState.UnitType.SOLO?Set.of(context.character):context.members;var campAudit=campEvents;var championAudit=championEvents;var campActor=campAudit==null?null:campAudit.actor(player,null);var championActor=championQuestActor(campActor,holder.giver);var dungeonAudit=dungeonEvents;var originalDungeon=captureDungeonDefinition(site);UUID operation=UUID.randomUUID();directory.accept(catalog,site,type,unit,members,offered.participationMode()).whenComplete((result,failure)->schedule(()->{if(failure!=null){player.sendMessage("Quest acceptance failed; retained state is unchanged.");return;}if(result.status()!=AdventureQuestDirectory.Acceptance.APPLIED){player.sendMessage("Quest acceptance refused: "+result.status().name().toLowerCase(Locale.ROOT)+".");return;}var quest=result.quest().orElseThrow();var current=context(player);boolean same=current!=null&&current.active.equals(context.active)&&current.character.equals(context.character)&&(type==AdventureQuestState.UnitType.SOLO||current.leader&&Objects.equals(current.partyId,context.partyId)&&current.members.equals(context.members));if(!same){directory.abandon(quest.id(),quest.unit()).whenComplete((ignored,rollbackFailure)->schedule(()->player.sendMessage(rollbackFailure==null?"Quest acceptance refused: the character or party chLine truncated
private void claim(Player player,Context expected,UUID catalog,UUID site){directory.beginClaim(catalog,site,expected.character).whenComplete((operation,failure)->schedule(()->{if(failure!=null||operation.isEmpty()){player.sendMessage("Reward claim failed; retained rewards are unchanged.");return;}var current=context(player);if(current==null||!current.active.equals(expected.active)||!current.character.equals(operation.orElseThrow().character())){player.sendMessage("Reward claim paused: actively play the exact living character and try again.");return;}var record=sessions.currentRecord(current.active).orElse(null);if(record==null){player.sendMessage("Reward claim paused: the current character changed.");return;}attemptClaim(player,current.active,operation.orElseThrow(),record);}));}
private void attemptClaim(Player player,EquipmentSessions.Active active,AdventureQuestState.RewardClaim operation,games.dmg.fantasystick.persistence.CharacterRecord record){characters.awardCombatReward(record.id(),record.revision(),operation.id(),operation.xp(),operation.coins(),progression,games.dmg.fantasystick.progression.CombatRewardRules.defaults(),null).whenComplete((award,failure)->{if(failure!=null){schedule(()->player.sendMessage("Reward claim failed; retained rewards are unchanged."));return;}if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){attemptClaim(player,active,operation,award.record().orElseThrow());return;}if(award.record().isEmpty()){schedule(()->player.sendMessage("Reward claim failed; retained rewards are unchanged."));return;}var saved=award.record().orElseThrow();directory.finishClaim(operation.id()).whenComplete((finished,finishFailure)->schedule(()->{if(finishFailure!=null||!finished){player.sendMessage("Reward claim is pending recovery; retry at a Quest Giver.");return;}var holder=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE&&holder!=null)sessions.acknowledgeProgress(holder,saved.revision().subtract(BigInteger.ONE),saved);var currentPlayer=holder==null?null:plugin.getServer().getPlayer(holder.player());var current=currentPlayer==null?null:context(currentPlayer);if(current!=null&&current.character.equals(operation.character())){currentPlayer.sendMessage("Claimed "+operation.xp()+" fantasy XP and "+operation.coins()+" character coins.");open(currentPlayer);}}));});}
public boolean trackingAvailable(Player player){var context=context(player);return context!=null&&directory.activeFor(context.character,context.partyId).filter(quest->quest.snapshot().contains(context.character)).isPresent();}
public void tracking(Player player,Boolean enabled){var context=context(player);if(context==null){player.sendMessage("Quest tracking refused: actively play a living fantasy character first.");return;}var quest=directory.activeFor(context.character,context.partyId).filter(value->value.snapshot().contains(context.character)).orElse(null);if(quest==null){player.sendMessage("Quest tracking refused: this character has no eligible active quest.");refreshTracking(player);return;}if(enabled==null){player.sendMessage("Quest tracking is "+(directory.trackingEnabled(quest.id(),context.character)?"on":"off")+" for the current adventure quest.");return;}directory.setTracking(quest.id(),context.character,enabled).whenComplete((status,failure)->schedule(()->{if(failure!=null||status==AdventureQuestDirectory.TrackingStatus.INELIGIBLE){player.sendMessage("Quest tracking change failed; retained state is unchanged.");return;}refreshTracking(player);player.sendMessage("Quest tracking is now "+(enabled?"on.":"off."));}));}
public void inspect(Player player){var context=context(player);if(context==null){player.sendMessage("No active fantasy character.");return;}var quest=directory.activeFor(context.character,context.partyId).orElse(null);if(quest==null){player.sendMessage("No active adventure quest.");return;}var site=directory.findSite(quest.site()).orElse(null);if(site==null){player.sendMessage("Active quest site is unavailable.");return;}player.sendMessage(site.displayName()+"; state="+(attempts.containsKey(site.id())?"active":"waiting")+"; expires "+quest.expiresAt()+"; Quest Compass guidance="+(directory.trackingEnabled(quest.id(),context.character)?"on":"off")+".");}
private void pulse(){if(closed)return;directory.ensureToday().whenComplete((catalog,failure)->{if(failure==null)schedule(()->{if(directory.snapshot().reset()!=null)publishPendingReset();});});if(directory.snapshot().reset()!=null){publishPendingReset();return;}directory.expireParties(parties::exists);refreshTrackingPlayers(true);showArenaBoundaries();for(var attempt:new ArrayList<>(attempts.values())){var catalog=directory.snapshot().catalog();if(catalog==null||!catalog.revision().equals(attempt.catalog)||!clock.instant().isBefore(catalog.expiresAt())){cleanup(attempt);attempts.remove(attempt.site);readyAt.remove(attempt.site);continue;}contain(attempt);syncBossBar(attempt);boolean present=eligibleInside(attempt.site,attempt.arena).findAny().isPresent();if(present)attempt.absentSince=null;else if(attempt.absentSince==null)attempt.absentSince=clock.instant();else if(!clock.instant().isBefore(attempt.absentSince.plusSeconds(attempt.resetSeconds))){cleanup(attempt);attempts.remove(attempt.site);readyAt.put(attempt.site,clock.instant());}}for(var quest:directory.active()){var type=directory.findSite(quest.site()).map(AdventureQuestState.Site::type).orElse(null);if(type==AdventureQuestState.SiteType.DUNGEON||type==AdventureQuestState.SiteType.FISHING)continue;if(attempts.containsKey(quest.site())||starting.contains(quest.site()))continue;var ready=readyAt.get(quest.site());if(ready!=null&&clock.instant().isBefore(ready))continue;if(eligibleInside(quest.site()).findAny().isPresent())start(quest.site(),quest.catalog());}}
private void pulse(){if(closed)return;directory.ensureToday().whenComplete((catalog,failure)->{if(failure==null)schedule(()->{if(directory.snapshot().reset()!=null)publishPendingReset();});});if(directory.snapshot().reset()!=null){publishPendingReset();return;}directory.expireParties(parties::exists);refreshTrackingPlayers(true);showArenaBoundaries();for(var attempt:new ArrayList<>(attempts.values())){var catalog=directory.snapshot().catalog();if(catalog==null||!catalog.revision().equals(attempt.catalog)||!clock.instant().isBefore(catalog.expiresAt())){cleanup(attempt,"catalog_or_utc_expired");attempts.remove(attempt.site);readyAt.remove(attempt.site);continue;}contain(attempt);syncBossBar(attempt);boolean present=eligibleInside(attempt.site,attempt.arena).findAny().isPresent();if(present)attempt.absentSince=null;else if(attempt.absentSince==null)attempt.absentSince=clock.instant();else if(!clock.instant().isBefore(attempt.absentSince.plusSeconds(attempt.resetSeconds))){cleanup(attempt,"no_eligible_accepted_character_remained");attempts.remove(attempt.site);readyAt.put(attempt.site,clock.instant());}}for(var quest:directory.active()){var type=directory.findSite(quest.site()).map(AdventureQuestState.Site::type).orElse(null);if(type==AdventureQuestState.SiteType.DUNGEON||type==AdventureQuestState.SiteType.FISHING)continue;if(attempts.containsKey(quest.site())||starting.contains(quest.site()))continue;var ready=readyAt.get(quest.site());if(ready!=null&&clock.instant().isBefore(ready))continue;var activation=eligibleInside(quest.site()).findAny();if(activation.isPresent())start(quest.site(),quest.catalog(),activation.orElseThrow());}}
private void showArenaBoundaries(){for(UUID siteId:directory.active().stream().map(AdventureQuestState.Quest::site).distinct().toList()){var site=directory.findSite(siteId).orElse(null);if(site==null||site.type()==AdventureQuestState.SiteType.DUNGEON)continue;var world=plugin.getServer().getWorld(site.world());if(world==null)continue;var center=new Location(world,site.x(),site.y(),site.z());var points=perimeter(arena(site));for(var player:plugin.getServer().getOnlinePlayers()){if(eligible(player,siteId)==null)continue;var viewer=player.getLocation();if(viewer.getWorld()==null||!viewer.getWorld().getUID().equals(site.world())||viewer.distanceSquared(center)>64d*64d)continue;for(var point:points)particles.viewer(player,Particle.END_ROD,new Location(world,site.x()+point[0],site.y()+0.2,site.z()+point[1]),1,0,0,0,0);}}}
private static List<double[]> perimeter(AdventureQuestState.Arena arena){var result=new ArrayList<double[]>();if(arena.shape()==AdventureQuestState.ArenaShape.CIRCLE){int count=Math.min(256,Math.max(16,(int)Math.ceil(Math.PI*arena.xBlocks())));for(int index=0;index<count;index++){double angle=2*Math.PI*index/count;result.add(new double[]{Math.cos(angle)*arena.xBlocks(),Math.sin(angle)*arena.xBlocks()});}}else{double halfX=arena.xBlocks()/2d,halfZ=arena.zBlocks()/2d;edge(result,-halfX,halfX,-halfZ,true);edge(result,-halfX,halfX,halfZ,true);edge(result,-halfZ,halfZ,-halfX,false);edge(result,-halfZ,halfZ,halfX,false);}return result.size()<=256?List.copyOf(result):List.copyOf(result.subList(0,256));}
private static void edge(List<double[]> result,double from,double to,double fixed,boolean xAxis){for(double value=from;value<to;value+=2)result.add(xAxis?new double[]{value,fixed}:new double[]{fixed,value});result.add(xAxis?new double[]{to,fixed}:new double[]{fixed,to});}
@@ -108,10 +144,33 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
private java.util.stream.Stream<Player> eligibleInside(UUID site){var value=directory.findSite(site).orElse(null);return value==null?java.util.stream.Stream.empty():eligibleInside(site,arena(value));}
private java.util.stream.Stream<Player> eligibleInside(UUID site,AdventureQuestState.Arena arena){var value=directory.findSite(site).orElse(null);if(value==null)return java.util.stream.Stream.empty();return plugin.getServer().getOnlinePlayers().stream().map(Player.class::cast).filter(player->eligible(player,site)!=null).filter(player->inside(player.getLocation(),value,arena));}
private AdventureQuestState.Quest eligible(Player player,UUID site){var context=context(player);if(context==null)return null;var quest=directory.activeFor(context.character,context.partyId).orElse(null);return quest!=null&&quest.site().equals(site)&&quest.snapshot().contains(context.character)?quest:null;}
private void start(UUID site,UUID catalog){var value=directory.findSite(site).orElse(null);if(value==null||value.type()==AdventureQuestState.SiteType.DUNGEON||value.type()==AdventureQuestState.SiteType.FISHING)return;if(value.type()==AdventureQuestState.SiteType.CHAMPION){startChampion(value,catalog);return;}starting.add(site);directory.roster(site).whenComplete((roster,failure)->schedule(()->{starting.remove(site);if(failure!=null||closed||attempts.containsKey(site))return;var current=directory.findSite(site).orElse(null);if(current==null)return;var world=plugin.getServer().getWorld(current.world());if(world==null||!world.isChunkLoaded(section(current.x()),section(current.z())))return;var rules=settings.rules();var attempt=new Attempt(site,catalog,current.type(),arena(current),rules.resetSeconds());attempts.put(site,attempt);for(int index=0;index<roster.size();index++)spawnEnemy(world,current,roster.get(index),attempt,index);}));}
private void startChampion(AdventureQuestState.Site site,UUID catalog){if(closed||attempts.containsKey(site.id())||starting.contains(site.id()))return;var world=plugin.getServer().getWorld(site.world());if(world==null||!world.isChunkLoaded(section(site.x()),section(site.z())))return;starting.add(site.id());try{var rules=settings.rules();var attempt=new Attempt(site.id(),catalog,site.type(),arena(site),rules.resetSeconds());attempt.bossBar=plugin.getServer().createBossBar("Daybreak Champion",BarColor.RED,BarStyle.SOLID);attempts.put(site.id(),attempt);world.spawn(new Location(world,site.x(),site.y(),site.z()),Vindicator.class,champion->{configureEnemy(champion,attempt);var maximum=Objects.requireNonNull(champion.getAttribute(Attribute.MAX_HEALTH));maximum.setBaseValue(rules.championHealth());champion.setHealth(rules.championHealth());Objects.requireNonNull(champion.getAttribute(Attribute.ATTACK_DAMAGE)).setBaseValue(rules.championDamage());champion.customName(net.kyori.adventure.text.Component.text("Daybreak Champion"));champion.setCustomNameVisible(true);attempt.champion=champion;});syncBossBar(attempt);}catch(RuntimeException failure){var attempt=attempts.remove(site.id());if(attempt!=null)cleanup(attempt);plugin.getLogger().log(java.util.logging.Level.WARNING,"Champion attempt could not start at site "+site.id(),failure);}finally{starting.remove(site.id());}}
private Map<String,Object> captureArenaActivation(Player player,AdventureQuestState.Site site,AdventureQuestState.Arena arena){
var facts=new LinkedHashMap<String,Object>();facts.put("capture_quality","unavailable");facts.put("observed_at",clock.instant().toString());
try{
for(var field:games.dmg.fantasystick.events.CampEventBridge.arenaData(site,arena).entrySet()){
if(field.getValue() instanceof Map<?,?> geometry)for(var part:geometry.entrySet())facts.put(field.getKey()+"_"+part.getKey(),part.getValue());
else facts.put(field.getKey(),field.getValue());
}
facts.put("player_id",player.getUniqueId().toString());
var context=context(player);
if(context!=null){facts.put("character_id",context.character.toString());facts.put("session_id",context.active.session().toString());
var quest=directory.activeFor(context.character,context.partyId).filter(value->value.site().equals(site.id())).orElse(null);
if(quest!=null){facts.put("quest_id",quest.id().toString());facts.put("unit_id",quest.unit().toString());facts.put("unit_kind",quest.unitType().name().toLowerCase(Locale.ROOT));}
}
var at=player.getLocation();
if(at!=null&&at.getWorld()!=null&&Double.isFinite(at.getX())&&Double.isFinite(at.getY())&&Double.isFinite(at.getZ())){
facts.put("location_world_id",at.getWorld().getUID().toString());facts.put("location_x",at.getX());facts.put("location_y",at.getY());facts.put("location_z",at.getZ());
double dx=at.getX()-site.x(),dz=at.getZ()-site.z();boolean within=at.getWorld().getUID().equals(site.world())&&arena.contains(dx,dz);
boolean edge=arena.shape()==AdventureQuestState.ArenaShape.CIRCLE?dx*dx+dz*dz==arena.xBlocks()*(double)arena.xBlocks():Math.abs(dx)==arena.xBlocks()/2d||Math.abs(dz)==arena.zBlocks()/2d;
facts.put("boundary_result",within?edge?"edge":"inside":"outside");facts.put("capture_quality","native_activation_observed");
}else facts.put("location_capture_quality","unavailable");
}catch(RuntimeException unavailable){/* Independent original metadata cannot interrupt an eligible native start. */}
return Map.copyOf(facts);
}
private void start(UUID site,UUID catalog,Player activation){var value=directory.findSite(site).orElse(null);if(value==null||value.type()==AdventureQuestState.SiteType.DUNGEON||value.type()==AdventureQuestState.SiteType.FISHING)return;var activationFacts=captureArenaActivation(activation,value,arena(value));if(value.type()==AdventureQuestState.SiteType.CHAMPION){startChampion(value,catalog,activationFacts);return;}starting.add(site);directory.roster(site).whenComplete((roster,failure)->schedule(()->{starting.remove(site);if(failure!=null||closed||attempts.containsKey(site))return;var current=directory.findSite(site).orElse(null);if(current==null)return;var world=plugin.getServer().getWorld(current.world());if(world==null||!world.isChunkLoaded(section(current.x()),section(current.z())))return;var rules=settings.rules();var attempt=new Attempt(site,catalog,current.type(),arena(current),rules.resetSeconds());attempt.auditSite=current;attempt.auditActivation=activationFacts;attempt.auditRoster=List.copyOf(roster);attempts.put(site,attempt);boolean spawned=false;try{for(int index=0;index<roster.size();index++)spawnEnemy(world,current,roster.get(index),attempt,index);spawned=true;}finally{auditAttempt("adventure.camp.started",attempt,"eligible_accepted_unit_entered",spawned);}}));}
private void startChampion(AdventureQuestState.Site site,UUID catalog,Map<String,Object> activation){if(closed||attempts.containsKey(site.id())||starting.contains(site.id()))return;var world=plugin.getServer().getWorld(site.world());if(world==null||!world.isChunkLoaded(section(site.x()),section(site.z())))return;starting.add(site.id());Attempt observed=null;boolean spawned=false;try{var rules=settings.rules();var attempt=new Attempt(site.id(),catalog,site.type(),arena(site),rules.resetSeconds());observed=attempt;attempt.auditSite=site;attempt.auditActivation=activation;attempt.auditRoster=List.of(org.bukkit.entity.EntityType.VINDICATOR);attempt.auditConfiguration=Map.of("requested_health",rules.championHealth(),"requested_damage",rules.championDamage());attempt.bossBar=plugin.getServer().createBossBar("Daybreak Champion",BarColor.RED,BarStyle.SOLID);attempts.put(site.id(),attempt);world.spawn(new Location(world,site.x(),site.y(),site.z()),Vindicator.class,champion->{configureEnemy(champion,attempt);var maximum=Objects.requireNonNull(champion.getAttribute(Attribute.MAX_HEALTH));maximum.setBaseValue(rules.championHealth());champion.setHealth(rules.championHealth());Objects.requireNonNull(champion.getAttribute(Attribute.ATTACK_DAMAGE)).setBaseValue(rules.championDamage());champion.customName(net.kyori.adventure.text.Component.text("Daybreak Champion"));champion.setCustomNameVisible(true);attempt.champion=champion;});syncBossBar(attempt);spawned=true;}catch(RuntimeException failure){var attempt=attempts.remove(site.id());if(attempt!=null)cleanup(attempt);plugin.getLogger().log(java.util.logging.Level.WARNING,"Champion attempt could not start at site "+site.id(),failure);}finally{try{if(observed!=null){captureChampionConfiguration(observed);auditAttempt("adventure.camp.started",observed,spawned?"eligible_accepted_unit_entered":"native_spawn_or_configuration_failed",spawned);}}catch(RuntimeException ignored){/* Audit cannot interrupt native lifecycle. */}starting.remove(site.id());}}
private void spawnEnemy(World world,AdventureQuestState.Site site,org.bukkit.entity.EntityType type,Attempt attempt,int index){var location=new Location(world,site.x()+(index%3)-1,site.y(),site.z()+(index/3));switch(type){case HUSK->world.spawn(location,Husk.class,mob->configureEnemy(mob,attempt));case PILLAGER->world.spawn(location,Pillager.class,mob->configureEnemy(mob,attempt));case VINDICATOR->world.spawn(location,Vindicator.class,mob->configureEnemy(mob,attempt));case WITCH->world.spawn(location,Witch.class,mob->configureEnemy(mob,attempt));default->throw new IllegalArgumentException("Unsupported camp entity");}}
private void configureEnemy(Mob mob,Attempt attempt){mob.getPersistentDataContainer().set(typeKey,PersistentDataType.STRING,ENEMY);mob.getPersistentDataContainer().set(siteKey,PersistentDataType.STRING,attempt.site.toString());mob.getPersistentDataContainer().set(catalogKey,PersistentDataType.STRING,attempt.catalog.toString());mob.getPersistentDataContainer().set(attemptKey,PersistentDataType.STRING,attempt.id.toString());var kind=attempt.type==AdventureQuestState.SiteType.CHAMPION?games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CHAMPION:games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CAMP;mob.getPersistentDataContainer().set(encounterKindKey,PersistentDataType.STRING,kind.name());mob.setCanPickupItems(false);mob.setRemoveWhenFarAway(false);mob.setPersistent(true);mob.setFireTicks(0);if(mob instanceof Husk husk){husk.setShouldBurnInDay(false);husk.setCanBreakDoors(false);}if(mob instanceof Raider raider){raider.setPatrolLeader(false);raider.setCanJoinRaid(false);}attempt.entities.add(mob.getUniqueId());}
private void configureEnemy(Mob mob,Attempt attempt){mob.getPersistentDataContainer().set(typeKey,PersistentDataType.STRING,ENEMY);mob.getPersistentDataContainer().set(siteKey,PersistentDataType.STRING,attempt.site.toString());mob.getPersistentDataContainer().set(catalogKey,PersistentDataType.STRING,attempt.catalog.toString());mob.getPersistentDataContainer().set(attemptKey,PersistentDataType.STRING,attempt.id.toString());var kind=attempt.type==AdventureQuestState.SiteType.CHAMPION?games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CHAMPION:games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CAMP;mob.getPersistentDataContainer().set(encounterKindKey,PersistentDataType.STRING,kind.name());mob.setCanPickupItems(false);mob.setRemoveWhenFarAway(false);mob.setPersistent(true);mob.setFireTicks(0);if(mob instanceof Husk husk){husk.setShouldBurnInDay(false);husk.setCanBreakDoors(false);}if(mob instanceof Raider raider){raider.setPatrolLeader(false);raider.setCanJoinRaid(false);}UUID entityId=mob.getUniqueId();attempt.entities.add(entityId);auditNativeEnemy(attempt,entityId,mob);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void damage(EntityDamageEvent event){if(enemy(event.getEntity())&&encounterOrigin(event.getEntity()).isPresent()){if(!(event instanceof EntityDamageByEntityEvent by)||source(by)==null||eligible(source(by),site(event.getEntity()))==null)event.setCancelled(true);return;}if(event instanceof EntityDamageByEntityEvent by&&managed(by.getDamager())){UUID site=site(by.getDamager());if(!(event.getEntity() instanceof Player player)||site==null||eligible(player,site)==null)event.setCancelled(true);}}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void accepted(EntityDamageByEntityEvent event){if(!enemy(event.getEntity())||event.getFinalDamage()<=0||!Double.isFinite(event.getFinalDamage()))return;var player=source(event);if(player==null)return;var quest=eligible(player,site(event.getEntity()));if(quest!=null){var attempt=attempts.get(quest.site());if(attempt!=null){attempt.represented.add(quest.id());if(attempt.champion==event.getEntity()&&attempt.bossBar!=null){double maximum=Objects.requireNonNull(attempt.champion.getAttribute(Attribute.MAX_HEALTH)).getValue();attempt.bossBar.setProgress(Math.max(0,Math.min(1,(attempt.champion.getHealth()-event.getFinalDamage())/maximum)));}}}}
@EventHandler(priority=EventPriority.HIGHEST)public void death(EntityDeathEvent event){
@@ -148,6 +207,7 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
}
if(attempt.entities.isEmpty()){attempt.defeatedEntity=event.getEntity().getUniqueId();
attempt.defeatLocation=trophyLocation(event.getEntity().getLocation());attempt.defeatedAt=clock.instant();
if(attempt.type==AdventureQuestState.SiteType.CHAMPION)captureChampionDefeat(attempt,event);
clear(attempt);}}
private static Map<String,Object> trophyLocation(Location location){
if(location==null||location.getWorld()==null)return Map.of("status","unavailable");
@@ -158,7 +218,79 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
return Map.copyOf(value);
}catch(RuntimeException unavailable){return Map.of("status","unavailable");}
}
private void clear(Attempt attempt){attempts.remove(attempt.site);if(attempt.bossBar!=null)attempt.bossBar.removeAll();readyAt.put(attempt.site,clock.instant().plusSeconds(attempt.resetSeconds));for(UUID questId:Set.copyOf(attempt.represented)){var quest=directory.findQuest(questId).orElse(null);if(quest==null)continue;var recipients=recipients(quest,attempt.site,attempt.arena);if(!recipients.isEmpty())award(quest,recipients,attempt);} }
private void clear(Attempt attempt){attempts.remove(attempt.site);if(attempt.bossBar!=null)attempt.bossBar.removeAll();readyAt.put(attempt.site,clock.instant().plusSeconds(attempt.resetSeconds));auditAttempt("adventure.camp.cleared",attempt,"final_native_enemy_defeated",true);for(UUID questId:Set.copyOf(attempt.represented)){var quest=directory.findQuest(questId).orElse(null);if(quest==null)continue;var recipients=recipients(quest,attempt.site,attempt.arena);if(!recipients.isEmpty())award(quest,recipients,attempt);} }
private static void auditNativeEnemy(Attempt attempt,UUID id,Mob mob){
try{if(id!=null){var type=mob.getType();attempt.auditEntities.put(id,type==null?"unavailable":type.name());}}
catch(RuntimeException unavailable){if(id!=null)attempt.auditEntities.put(id,"unavailable");}
}
private void auditAttempt(String type,Attempt attempt,String reason,boolean succeeded){
try{
boolean champion=attempt.type==AdventureQuestState.SiteType.CHAMPION;
if(attempt.auditSite==null||(champion?championEvents==null:campEvents==null||attempt.type!=AdventureQuestState.SiteType.CAMP))return;
var entities=attempt.auditEntities.entrySet().stream().map(entry->Map.<String,Object>of("entity_id",entry.getKey().toString(),"entity_type",entry.getValue())).toList();
var units=new ArrayList<Map<String,Object>>();
for(UUID id:attempt.represented.stream().sorted().toList()){
var quest=directory.findQuest(id).orElse(null);var unit=new LinkedHashMap<String,Object>();unit.put("quest_id",id.toString());
if(quest!=null){unit.put("unit_id",quest.unit().toString());unit.put("unit_kind",quest.unitType().name().toLowerCase(Locale.ROOT));unit.put("captured_mode",quest.participationMode().value());}
else unit.put("unit_capture_quality","unavailable");units.add(Map.copyOf(unit));
}
var catalog=directory.snapshot().catalog();boolean current=catalog!=null&&catalog.revision().equals(attempt.catalog)&&directory.offerOpen(attempt.site);
var facts=new games.dmg.fantasystick.events.CampEventBridge.CampAttempt(attempt.id,attempt.catalog,
attempt.auditSite,attempt.arena,attempt.resetSeconds,attempt.auditRoster.stream().map(Enum::name).toList(),entities,units,
attempt.entities.size(),attempt.defeatedEntity,attempt.defeatLocation,attempt.defeatedAt,current,
type.equals("adventure.camp.reset")||type.equals("adventure.camp.started")?auditNativePresence(attempt):Map.of("status","not_applicable"),attempt.auditActivation);
if(champion)championEvents.attemptTransition(switch(type){case "adventure.camp.started"->"adventure.champion.spawned";case "adventure.camp.cleared"->"adventure.champion.defeated";case "adventure.camp.reset"->"adventure.champion.reset";default->throw new IllegalArgumentException("Unknown champion transition");},facts,attempt.auditConfiguration,reason,succeeded);
else campEvents.attemptTransition(type,facts,reason,succeeded);
}catch(RuntimeException ignored){/* Metadata cannot spawn, reset, represent, clear or award gameplay. */}
}
private static void captureChampionConfiguration(Attempt attempt){
var facts=new LinkedHashMap<String,Object>(attempt.auditConfiguration);var champion=attempt.champion;
for(var field:Map.of("health",Attribute.MAX_HEALTH,"damage",Attribute.ATTACK_DAMAGE).entrySet()){
try{var attribute=champion==null?null:champion.getAttribute(field.getValue());double value=attribute==null?Double.NaN:attribute.getBaseValue();
if(Double.isFinite(value)&&value>0){facts.put("configured_"+field.getKey(),value);facts.put(field.getKey()+"_capture_quality","native_base_value_observed");}
else facts.put(field.getKey()+"_capture_quality","unavailable");
}catch(RuntimeException unavailable){facts.put(field.getKey()+"_capture_quality","unavailable");}
}
try{var name=champion==null?null:champion.customName();
if(name!=null){facts.put("champion_name",net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(name));facts.put("champion_name_capture_quality","native_name_observed");}
else facts.put("champion_name_capture_quality","unavailable");
}catch(RuntimeException unavailable){facts.put("champion_name_capture_quality","unavailable");}
attempt.auditConfiguration=Map.copyOf(facts);
}
private void captureChampionDefeat(Attempt attempt,EntityDeathEvent event){
try{
var source=new LinkedHashMap<String,Object>();source.put("capture_quality","captured_native_death");
source.put("damage_action_capture_quality","unavailable_not_in_native_death");
org.bukkit.entity.Entity causing=null,direct=null;
try{var damage=event.getDamageSource();if(damage!=null)causing=damage.getCausingEntity();}catch(RuntimeException unavailable){}
try{var damage=event.getDamageSource();if(damage!=null)direct=damage.getDirectEntity();}catch(RuntimeException unavailable){}
captureSourceEntity(source,"causing",causing);captureSourceEntity(source,"direct",direct);
if(causing instanceof Player player&&campEvents!=null){
var actor=campEvents.actor(player,null);
for(var field:Map.of("request_player_id","killer_player_id","request_character_id","killer_character_id","request_session_id","killer_session_id").entrySet()){
var value=actor.captured().get(field.getKey());if(value!=null)source.put(field.getValue(),value);
}
source.put("killer_context_capture_quality",actor.captured().get("actor_capture_quality"));
var location=actor.captured().get("actor_location");if(location instanceof Map<?,?> coordinates)
for(var field:coordinates.entrySet())source.put("killer_"+field.getKey(),field.getValue());
}else source.put("killer_context_capture_quality","unavailable");
var facts=new LinkedHashMap<String,Object>(attempt.auditConfiguration);facts.put("defeat_source",Map.copyOf(source));attempt.auditConfiguration=Map.copyOf(facts);
}catch(RuntimeException ignored){/* Missing original source metadata remains unavailable, never gameplay authority. */}
}
private static void captureSourceEntity(Map<String,Object> facts,String prefix,org.bukkit.entity.Entity entity){
try{if(entity!=null&&entity.getUniqueId()!=null)facts.put(prefix+"_entity_id",entity.getUniqueId().toString());}catch(RuntimeException unavailable){}
try{if(entity!=null&&entity.getType()!=null)facts.put(prefix+"_entity_type",entity.getType().name());}catch(RuntimeException unavailable){}
facts.put(prefix+"_capture_quality",facts.containsKey(prefix+"_entity_id")?"native_entity_observed":"unavailable");
}
private Map<String,Object> auditNativePresence(Attempt attempt){
try{
var world=plugin.getServer().getWorld(attempt.auditSite.world());if(world==null)return Map.of("status","world_unavailable");
if(!world.isChunkLoaded(section(attempt.auditSite.x()),section(attempt.auditSite.z())))return Map.of("status","not_loaded");
var entities=world.getEntities();if(entities==null)return Map.of("status","unavailable");
int remaining=Math.toIntExact(entities.stream().filter(entity->attempt.auditEntities.containsKey(entity.getUniqueId())).filter(Entity::isValid).count());
return Map.of("status","loaded_observed","remaining_native_entities",remaining);
}catch(RuntimeException unavailable){return Map.of("status","unavailable");}
}
private List<UUID> recipients(AdventureQuestState.Quest quest,UUID site){var value=directory.findSite(site).orElse(null);return value==null?List.of():recipients(quest,site,arena(value));}
private List<UUID> recipients(AdventureQuestState.Quest quest,UUID site,AdventureQuestState.Arena arena){var value=directory.findSite(site).orElse(null);if(value==null)return List.of();var result=new ArrayList<UUID>();for(UUID character:quest.snapshot()){if(directory.characterClaimed(quest.catalog(),quest.site(),character))continue;var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);if(active==null)continue;var player=plugin.getServer().getPlayer(active.player());if(player==null||!inside(player.getLocation(),value,arena))continue;if(quest.unitType()==AdventureQuestState.UnitType.PARTY){var party=parties.partyOf(player.getUniqueId()).orElse(null);if(party==null||!party.id().equals(quest.unit())||!party.characters().contains(character))continue;}result.add(character);}return result.stream().distinct().sorted().toList();}
private void award(AdventureQuestState.Quest quest,List<UUID> recipients,Attempt attempt){var unit=new RewardAllocator.Unit<UUID>(quest.unit(),recipients);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);if((attempt.type==AdventureQuestState.SiteType.CHAMPION||attempt.type==AdventureQuestState.SiteType.CAMP)&&fishing!=null){var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(UUID character:recipients)rewards.put(character,new FishingState.Reward(xp.get(character),coins.get(character)));UUID carrier=quest.unitType()==AdventureQuestState.UnitType.SOLO?quest.unit():recipients.stream().map(parties::partyOfCharacter).flatMap(Optional::stream).filter(value->value.id().equals(quest.unit())).map(PartyRoster.Party::leaderCharacter).findFirst().orElse(null);var site=directory.findSite(quest.site()).orElse(null);if(carrier==null||site==null){plugin.getLogger().warning("Champion trophy carrier unavailable for quest "+quest.id());return;}var trophyType=attempt.type==AdventureQuestState.SiteType.CHAMPION?FishingState.TrophyType.CHAMPION:FishingState.TrophyType.CAMP;UUID trophy=UUID.nameUUIDFromBytes((trophyType.name().toLowerCase(java.util.Locale.ROOT)+":"+quest.id()).getBytes(java.nio.charset.StandardCharsets.UTF_8));String trophyName=trophyType==FishingState.TrophyType.CHAMPION?"Daybreak Champion Head":"Spoils of "+site.displayName();var audit=new LinkedHashMap<String,String>();
@@ -191,12 +323,13 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void leash(PlayerLeashEntityEvent event){if(giver(event.getEntity())||managed(event.getEntity()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void teleport(EntityTeleportEvent event){if((giver(event.getEntity())||managed(event.getEntity()))&&!relocating.contains(event.getEntity()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.MONITOR)public void chunkLoad(org.bukkit.event.world.ChunkLoadEvent event){reconcile(event.getChunk());}
@EventHandler(priority=EventPriority.MONITOR)public void chunkUnload(org.bukkit.event.world.ChunkUnloadEvent event){for(var entity:event.getChunk().getEntities())if(enemy(entity)){UUID site=site(entity);var attempt=site==null?null:attempts.remove(site);if(attempt!=null){cleanup(attempt);readyAt.put(site,clock.instant());}}}
@EventHandler(priority=EventPriority.MONITOR)public void chunkUnload(org.bukkit.event.world.ChunkUnloadEvent event){for(var entity:event.getChunk().getEntities())if(enemy(entity)){UUID site=site(entity);var attempt=site==null?null:attempts.remove(site);if(attempt!=null){cleanup(attempt,"chunk_unload");readyAt.put(site,clock.instant());}}}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void giverDamage(EntityDamageEvent event){if(giver(event.getEntity()))event.setCancelled(true);}
private Player source(EntityDamageByEntityEvent event){if(event.getDamager() instanceof Player player)return player;if(event.getDamager() instanceof org.bukkit.entity.Projectile projectile&&projectile.getShooter() instanceof Player player)return player;return null;}
private UUID site(Entity entity){return parse(entity.getPersistentDataContainer().get(siteKey,PersistentDataType.STRING));}
private void syncBossBar(Attempt attempt){if(attempt.bossBar==null||attempt.champion==null)return;var viewers=eligibleInside(attempt.site,attempt.arena).collect(java.util.stream.Collectors.toSet());for(var player:plugin.getServer().getOnlinePlayers())if(viewers.contains(player))attempt.bossBar.addPlayer(player);else attempt.bossBar.removePlayer(player);double maximum=Objects.requireNonNull(attempt.champion.getAttribute(Attribute.MAX_HEALTH)).getValue();attempt.bossBar.setProgress(Math.max(0,Math.min(1,attempt.champion.getHealth()/maximum)));}
private void cleanup(Attempt attempt){if(attempt.bossBar!=null)attempt.bossBar.removeAll();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(enemy(entity)&&attempt.site.equals(site(entity)))entity.remove();attempt.entities.clear();}
private void cleanup(Attempt attempt){cleanup(attempt,"native_cleanup");}
private void cleanup(Attempt attempt,String reason){boolean cleaned=false;try{if(attempt.bossBar!=null)attempt.bossBar.removeAll();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(enemy(entity)&&attempt.site.equals(site(entity)))entity.remove();attempt.entities.clear();cleaned=true;}finally{auditAttempt("adventure.camp.reset",attempt,reason,cleaned);}}
private void refreshTrackingPlayers(){refreshTrackingPlayers(false);}
private void refreshTrackingPlayers(boolean actionBar){for(var value:plugin.getServer().getOnlinePlayers())refreshTracking(value,actionBar);}
private void refreshTracking(Player player){refreshTracking(player,false);}
@@ -208,5 +341,5 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
private static UUID parse(String value){try{return value==null?null:UUID.fromString(value);}catch(IllegalArgumentException invalid){return null;}}
private static UUID id(Entity entity,NamespacedKey key){return parse(entity.getPersistentDataContainer().get(key,PersistentDataType.STRING));}
private static <T>List<T> safe(Collection<T> values){return values==null?List.of():List.copyOf(values);}
@Override public void close(){for(var player:plugin.getServer().getOnlinePlayers()){sessions.active(player.getUniqueId()).filter(sessions::current).ifPresent(sessions::restoreBook);}closed=true;if(pulseTask!=null)pulseTask.cancel();for(var attempt:new ArrayList<>(attempts.values()))cleanup(attempt);attempts.clear();dyingOrigins.clear();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(giver(entity)||enemy(entity))entity.remove();}
@Override public void close(){for(var player:plugin.getServer().getOnlinePlayers()){sessions.active(player.getUniqueId()).filter(sessions::current).ifPresent(sessions::restoreBook);}closed=true;if(pulseTask!=null)pulseTask.cancel();for(var attempt:new ArrayList<>(attempts.values()))cleanup(attempt,"plugin_disable");attempts.clear();dyingOrigins.clear();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(giver(entity)||enemy(entity))entity.remove();}
}
@@ -17,7 +17,32 @@ public final class AdventureQuestDirectory implements AutoCloseable {
public enum FishingUpdateStatus { APPLIED, UNCHANGED, STALE, NOT_FOUND }
public enum RerollStatus { APPLIED, STALE }
public enum TrackingStatus { APPLIED, UNCHANGED, INELIGIBLE }
public record AcceptResult(Acceptance status,Optional<AdventureQuestState.Quest> quest) { }
public record CatalogPublication(AdventureQuestState.Catalog catalog,UUID operation,String reason,
AdventureQuestSettings.Rules settings,List<AdventureQuestState.Site> selected,
games.dmg.fantasystick.events.CampEventBridge.Actor actor,boolean originalCapture) {
public CatalogPublication{selected=List.copyOf(selected);}
}
private volatile java.util.function.Consumer<CatalogPublication> catalogObserver=ignored->{};
private CatalogPublication pendingCatalogPublication;
public void attachCatalogEvents(java.util.function.Consumer<CatalogPublication> observer){catalogObserver=Objects.requireNonNull(observer);}
public record 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->{};
public void attachQuestExpiryEvents(java.util.function.Consumer<QuestExpiry> observer){expiryObserver=Objects.requireNonNull(observer);}
private volatile java.util.function.Consumer<QuestExpiry> championExpiryObserver=ignored->{};
public void attachChampionQuestExpiryEvents(java.util.function.Consumer<QuestExpiry> observer){championExpiryObserver=Objects.requireNonNull(observer);}
private volatile java.util.function.Consumer<QuestExpiry> dungeonExpiryObserver=ignored->{};
private final Map<UUID,AdventureQuestState.Site> originalQuestSites=new HashMap<>();
private volatile java.util.function.Predicate<UUID> dungeonQuestIdentity=ignored->false;
public void attachDungeonQuestExpiryEvents(java.util.function.Consumer<QuestExpiry> observer,java.util.function.Predicate<UUID> identity){dungeonExpiryObserver=Objects.requireNonNull(observer);dungeonQuestIdentity=Objects.requireNonNull(identity);}
public record AcceptResult(Acceptance status,Optional<AdventureQuestState.Quest> quest,Optional<AdventureQuestState.Site> capturedSite) {
public AcceptResult(Acceptance status,Optional<AdventureQuestState.Quest> quest){this(status,quest,Optional.empty());}
}
public record ModeResult(ModeStatus status,Optional<AdventureQuestState.Site> site) { }
public record FishingUpdateResult(FishingUpdateStatus status,Optional<AdventureQuestState.Site> site) { }
public record RerollResult(RerollStatus status,Optional<AdventureQuestState.Catalog> catalog) { }
@@ -41,25 +66,28 @@ public final class AdventureQuestDirectory implements AutoCloseable {
public Optional<AdventureQuestState.Quest> findQuest(UUID id){return snapshot.quests().stream().filter(v->v.id().equals(id)).findFirst();}
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z){return placeSite(type,name,world,x,y,z,null,AdventureQuestState.ParticipationMode.PARTY_AVAILABLE);}
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z,AdventureQuestState.Arena arena){return placeSite(type,name,world,x,y,z,arena,AdventureQuestState.ParticipationMode.PARTY_AVAILABLE);}
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z,AdventureQuestState.Arena arena,AdventureQuestState.ParticipationMode mode){if(type==AdventureQuestState.SiteType.FISHING)throw new IllegalArgumentException("Use complete fishing definition");var value=new AdventureQuestState.Site(UUID.randomUUID(),type,name,world,x,y,z,arena,mode);return mutate(state->{var sites=new ArrayList<>(state.sites());sites.add(value);return result(copy(state,List.copyOf(sites),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z,AdventureQuestState.Arena arena,AdventureQuestState.ParticipationMode mode){if(type==AdventureQuestState.SiteType.FISHING)throw new IllegalArgumentException("Use complete fishing definition");var value=new AdventureQuestState.Site(UUID.randomUUID(),type,name,world,x,y,z,arena,mode,null,(type==AdventureQuestState.SiteType.CAMP||type==AdventureQuestState.SiteType.CHAMPION)?UUID.randomUUID():null);return mutate(state->{var sites=new ArrayList<>(state.sites());sites.add(value);return result(copy(state,List.copyOf(sites),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
public CompletableFuture<AdventureQuestState.Site> placeFishingSite(String name,String itemName,UUID world,double x,double y,double z,int radius,java.math.BigDecimal chance,BigInteger required,BigInteger rewardXp,BigInteger rewardCoins,AdventureQuestState.ParticipationMode mode){var fishing=new AdventureQuestState.FishingDefinition(UUID.randomUUID(),itemName,required,chance,rewardXp,rewardCoins,radius);var value=new AdventureQuestState.Site(UUID.randomUUID(),AdventureQuestState.SiteType.FISHING,name,world,x,y,z,AdventureQuestState.Arena.circle(radius),mode,fishing);return mutate(state->{var sites=new ArrayList<>(state.sites());sites.add(value);return result(copy(state,List.copyOf(sites),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
public CompletableFuture<FishingUpdateResult> updateFishing(UUID id,AdventureQuestState.FishingDefinition expected,AdventureQuestState.FishingDefinition requested){var current=findSite(id).orElse(null);if(current==null||current.type()!=AdventureQuestState.SiteType.FISHING)return CompletableFuture.completedFuture(new FishingUpdateResult(FishingUpdateStatus.NOT_FOUND,Optional.empty()));if(!current.fishing().equals(expected))return CompletableFuture.completedFuture(new FishingUpdateResult(FishingUpdateStatus.STALE,Optional.of(current)));return updateFishingSite(id,current,current.name(),requested,current.participationMode());}
public CompletableFuture<FishingUpdateResult> updateFishingSite(UUID id,AdventureQuestState.Site expected,String name,AdventureQuestState.FishingDefinition requested,AdventureQuestState.ParticipationMode mode){Objects.requireNonNull(expected);Objects.requireNonNull(requested);Objects.requireNonNull(mode);return mutate(state->{var found=state.sites().stream().filter(value->value.id().equals(id)&&value.type()==AdventureQuestState.SiteType.FISHING).findFirst();if(found.isEmpty())return result(state,new FishingUpdateResult(FishingUpdateStatus.NOT_FOUND,Optional.empty()));var current=found.orElseThrow();if(!current.equals(expected))return result(state,new FishingUpdateResult(FishingUpdateStatus.STALE,Optional.of(current)));if(!current.fishing().itemId().equals(requested.itemId()))throw new IllegalArgumentException("Fishing item identity cannot change");var changed=new AdventureQuestState.Site(current.id(),current.type(),name,current.world(),current.x(),current.y(),current.z(),AdventureQuestState.Arena.circle(requested.radius()),mode,requested);if(changed.equals(current))return result(state,new FishingUpdateResult(FishingUpdateStatus.UNCHANGED,Optional.of(current)));var sites=state.sites().stream().map(value->value.id().equals(id)?changed:value).toList();return result(copy(state,sites,state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),new FishingUpdateResult(FishingUpdateStatus.APPLIED,Optional.of(changed)));});}
public CompletableFuture<ModeResult> setSiteMode(UUID id,AdventureQuestState.ParticipationMode expected,AdventureQuestState.ParticipationMode requested){Objects.requireNonNull(expected);Objects.requireNonNull(requested);return mutate(state->{var found=state.sites().stream().filter(value->value.id().equals(id)&&value.type()!=AdventureQuestState.SiteType.DUNGEON).findFirst();if(found.isEmpty())return result(state,new ModeResult(ModeStatus.NOT_FOUND,Optional.empty()));var current=found.orElseThrow();if(current.participationMode()!=expected)return result(state,new ModeResult(ModeStatus.STALE,Optional.of(current)));if(current.participationMode()==requested)return result(state,new ModeResult(ModeStatus.UNCHANGED,Optional.of(current)));var changed=new AdventureQuestState.Site(current.id(),current.type(),current.name(),current.world(),current.x(),current.y(),current.z(),current.arena(),requested,current.fishing());var sites=state.sites().stream().map(value->value.id().equals(id)?changed:value).toList();return result(copy(state,sites,state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),new ModeResult(ModeStatus.APPLIED,Optional.of(changed)));});}
public CompletableFuture<FishingUpdateResult> updateFishingSite(UUID id,AdventureQuestState.Site expected,String name,AdventureQuestState.FishingDefinition requested,AdventureQuestState.ParticipationMode mode){Objects.requireNonNull(expected);Objects.requireNonNull(requested);Objects.requireNonNull(mode);return mutate(state->{var found=state.sites().stream().filter(value->value.id().equals(id)&&value.type()==AdventureQuestState.SiteType.FISHING).findFirst();if(found.isEmpty())return result(state,new FishingUpdateResult(FishingUpdateStatus.NOT_FOUND,Optional.empty()));var current=found.orElseThrow();if(!current.equals(expected))return result(state,new FishingUpdateResult(FishingUpdateStatus.STALE,Optional.of(current)));if(!current.fishing().itemId().equals(requested.itemId()))throw new IllegalArgumentException("Fishing item identity cannot change");var changed=new AdventureQuestState.Site(current.id(),current.type(),name,current.world(),current.x(),current.y(),current.z(),AdventureQuestState.Arena.circle(requested.radius()),mode,requested,current.registrationRevision());if(changed.equals(current))return result(state,new FishingUpdateResult(FishingUpdateStatus.UNCHANGED,Optional.of(current)));var sites=state.sites().stream().map(value->value.id().equals(id)?changed:value).toList();return result(copy(state,sites,state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),new FishingUpdateResult(FishingUpdateStatus.APPLIED,Optional.of(changed)));});}
public CompletableFuture<ModeResult> setSiteMode(UUID id,AdventureQuestState.ParticipationMode expected,AdventureQuestState.ParticipationMode requested){Objects.requireNonNull(expected);Objects.requireNonNull(requested);return mutate(state->{var found=state.sites().stream().filter(value->value.id().equals(id)&&value.type()!=AdventureQuestState.SiteType.DUNGEON).findFirst();if(found.isEmpty())return result(state,new ModeResult(ModeStatus.NOT_FOUND,Optional.empty()));var current=found.orElseThrow();if(current.participationMode()!=expected)return result(state,new ModeResult(ModeStatus.STALE,Optional.of(current)));if(current.participationMode()==requested)return result(state,new ModeResult(ModeStatus.UNCHANGED,Optional.of(current)));var changed=new AdventureQuestState.Site(current.id(),current.type(),current.name(),current.world(),current.x(),current.y(),current.z(),current.arena(),requested,current.fishing(),(current.type()==AdventureQuestState.SiteType.CAMP||current.type()==AdventureQuestState.SiteType.CHAMPION)?UUID.randomUUID():current.registrationRevision());var sites=state.sites().stream().map(value->value.id().equals(id)?changed:value).toList();return result(copy(state,sites,state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),new ModeResult(ModeStatus.APPLIED,Optional.of(changed)));});}
public CompletableFuture<Optional<AdventureQuestState.Site>> removeSite(UUID id){return mutate(state->{var found=state.sites().stream().filter(v->v.id().equals(id)&&v.type()!=AdventureQuestState.SiteType.DUNGEON).findFirst();if(found.isEmpty())return result(state,Optional.empty());var sites=state.sites().stream().filter(v->!v.id().equals(id)).toList();var quests=state.quests().stream().filter(v->!v.site().equals(id)).toList();var rosters=new HashMap<>(state.rosters());rosters.remove(id);return result(copy(state,sites,state.givers(),state.catalog(),quests,state.unitClaims(),state.characterClaims(),rosters),found);});}
public CompletableFuture<Optional<AdventureQuestState.Site>> removeDungeonSite(UUID id){return mutate(state->{var found=state.sites().stream().filter(v->v.id().equals(id)&&v.type()==AdventureQuestState.SiteType.DUNGEON).findFirst();if(found.isEmpty())return result(state,Optional.empty());var sites=state.sites().stream().filter(v->!v.id().equals(id)).toList();return result(copy(state,sites,state.givers(),state.catalog(),state.quests().stream().filter(v->!v.site().equals(id)).toList(),state.unitClaims(),state.characterClaims(),state.rosters()),found);});}
public CompletableFuture<AdventureQuestState.Giver> placeGiver(String name,UUID world,double x,double y,double z,float yaw,float pitch){var value=new AdventureQuestState.Giver(UUID.randomUUID(),name,world,x,y,z,yaw,pitch);return mutate(state->{var values=new ArrayList<>(state.givers());values.add(value);return result(copy(state,state.sites(),List.copyOf(values),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
public CompletableFuture<AdventureQuestState.Giver> placeGiver(String name,UUID world,double x,double y,double z,float yaw,float pitch){var value=new AdventureQuestState.Giver(UUID.randomUUID(),name,world,x,y,z,yaw,pitch,UUID.randomUUID());return mutate(state->{var values=new ArrayList<>(state.givers());values.add(value);return result(copy(state,state.sites(),List.copyOf(values),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
public CompletableFuture<Optional<AdventureQuestState.Giver>> removeGiver(UUID id){return mutate(state->{var found=state.givers().stream().filter(v->v.id().equals(id)).findFirst();if(found.isEmpty())return result(state,Optional.empty());return result(copy(state,state.sites(),state.givers().stream().filter(v->!v.id().equals(id)).toList(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),found);});}
public CompletableFuture<AdventureQuestState.Catalog> appendOffer(UUID site){return mutate(state->{if(state.reset()!=null||site(state,site).filter(v->v.type()==AdventureQuestState.SiteType.DUNGEON).isEmpty()||state.catalog()==null||!state.catalog().day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC))))throw new IllegalArgumentException("Dungeon offer unavailable");if(state.catalog().sites().contains(site)){if(!state.catalog().closed().contains(site))return result(state,state.catalog());var closed=new HashSet<>(state.catalog().closed());closed.remove(site);var reopened=new AdventureQuestState.Catalog(state.catalog().day(),state.catalog().revision(),state.catalog().createdAt(),state.catalog().sites(),closed);return result(copy(state,state.sites(),state.givers(),reopened,state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),reopened);}if(state.catalog().sites().size()>=256)throw new IllegalArgumentException("Adventure offer limit reached");var offers=new ArrayList<>(state.catalog().sites());offers.add(site);var catalog=new AdventureQuestState.Catalog(state.catalog().day(),state.catalog().revision(),state.catalog().createdAt(),offers,state.catalog().closed());return result(copy(state,state.sites(),state.givers(),catalog,state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),catalog);});}
public CompletableFuture<Boolean> closeOffer(UUID site){return mutate(state->{if(state.catalog()==null||!state.catalog().sites().contains(site)||state.catalog().closed().contains(site))return result(state,false);var closed=new HashSet<>(state.catalog().closed());closed.add(site);var catalog=new AdventureQuestState.Catalog(state.catalog().day(),state.catalog().revision(),state.catalog().createdAt(),state.catalog().sites(),closed);var quests=state.quests().stream().filter(value->!value.site().equals(site)).toList();return result(copy(state,state.sites(),state.givers(),catalog,quests,state.unitClaims(),state.characterClaims(),state.rosters()),true);});}
public CompletableFuture<AdventureQuestState.Catalog> ensureToday(){return mutate(state->{if(state.reset()!=null)return result(state,state.catalog());var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));if(state.catalog()!=null&&state.catalog().day().equals(today))return result(expire(state),state.catalog());var prior=state.catalog();var catalog=roll(state,today,prior==null?Set.of():Set.copyOf(prior.sites()));var reset=prior==null?null:new AdventureQuestState.Reset(UUID.randomUUID(),catalog.revision(),clock.instant());var next=copyReset(state,state.sites(),state.givers(),catalog,List.of(),Set.of(),Set.of(),Map.of(),reset);return result(next,catalog);});}
public record OfferPublication(UUID site,AdventureQuestState.Catalog before,AdventureQuestState.Catalog after,boolean changed){}
public CompletableFuture<AdventureQuestState.Catalog> appendOffer(UUID site){return appendOfferWithEvidence(site).thenApply(OfferPublication::after);}
public CompletableFuture<OfferPublication> appendOfferWithEvidence(UUID site){return mutate(state->{if(state.reset()!=null||site(state,site).filter(v->v.type()==AdventureQuestState.SiteType.DUNGEON).isEmpty()||state.catalog()==null||!state.catalog().day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC))))throw new IllegalArgumentException("Dungeon offer unavailable");if(state.catalog().sites().contains(site)){if(!state.catalog().closed().contains(site))return result(state,new OfferPublication(site,state.catalog(),state.catalog(),false));var closed=new HashSet<>(state.catalog().closed());closed.remove(site);var reopened=new AdventureQuestState.Catalog(state.catalog().day(),state.catalog().revision(),state.catalog().createdAt(),state.catalog().sites(),closed);return result(copy(state,state.sites(),state.givers(),reopened,state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),new OfferPublication(site,state.catalog(),reopened,true));}if(state.catalog().sites().size()>=256)throw new IllegalArgumentException("Adventure offer limit reached");var offers=new ArrayList<>(state.catalog().sites());offers.add(site);var catalog=new AdventureQuestState.Catalog(state.catalog().day(),state.catalog().revision(),state.catalog().createdAt(),offers,state.catalog().closed());return result(copy(state,state.sites(),state.givers(),catalog,state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),new OfferPublication(site,state.catalog(),catalog,true));});}
public CompletableFuture<Boolean> closeOffer(UUID site){return mutate("offer_closed",state->{if(state.catalog()==null||!state.catalog().sites().contains(site)||state.catalog().closed().contains(site))return result(state,false);var closed=new HashSet<>(state.catalog().closed());closed.add(site);var catalog=new AdventureQuestState.Catalog(state.catalog().day(),state.catalog().revision(),state.catalog().createdAt(),state.catalog().sites(),closed);var quests=state.quests().stream().filter(value->!value.site().equals(site)).toList();return result(copy(state,state.sites(),state.givers(),catalog,quests,state.unitClaims(),state.characterClaims(),state.rosters()),true);});}
public CompletableFuture<AdventureQuestState.Catalog> ensureToday(){return mutate(state->{if(state.reset()!=null)return result(state,state.catalog());var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));if(state.catalog()!=null&&state.catalog().day().equals(today))return result(expire(state),state.catalog());var prior=state.catalog();var rolled=roll(state,today,prior==null?Set.of():Set.copyOf(prior.sites()));var catalog=rolled.catalog();var reset=prior==null?null:new AdventureQuestState.Reset(UUID.randomUUID(),catalog.revision(),clock.instant());var next=copyReset(state,state.sites(),state.givers(),catalog,List.of(),Set.of(),Set.of(),Map.of(),reset);return new Result<>(next,catalog,new CatalogPublication(catalog,reset==null?catalog.revision():reset.operation(),prior==null?"initial_current_day":"utc_day_rollover",rolled.settings(),rolled.selected(),null,true));});}
public CompletableFuture<RerollResult> rerollToday(){var current=snapshot.catalog();return rerollToday(current==null?null:current.revision());}
public CompletableFuture<RerollResult> rerollToday(UUID expectedRevision){return mutate(original->{var state=expire(original);var current=state.catalog();UUID actual=current==null?null:current.revision();if(state.reset()!=null||!Objects.equals(expectedRevision,actual))return result(state,new RerollResult(RerollStatus.STALE,Optional.ofNullable(current)));var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));boolean sameDay=current!=null&&current.day().equals(today);var catalog=roll(state,today,sameDay?Set.copyOf(current.sites()):Set.of());var reset=new AdventureQuestState.Reset(UUID.randomUUID(),catalog.revision(),clock.instant());var next=copyReset(state,state.sites(),state.givers(),catalog,List.of(),Set.of(),Set.of(),Map.of(),reset);return result(next,new RerollResult(RerollStatus.APPLIED,Optional.of(catalog)));});}
public CompletableFuture<RerollResult> rerollToday(UUID expectedRevision){return rerollToday(expectedRevision,null);}
public CompletableFuture<RerollResult> rerollToday(UUID expectedRevision,games.dmg.fantasystick.events.CampEventBridge.Actor actor){return mutate(original->{var state=expire(original);var current=state.catalog();UUID actual=current==null?null:current.revision();if(state.reset()!=null||!Objects.equals(expectedRevision,actual))return result(state,new RerollResult(RerollStatus.STALE,Optional.ofNullable(current)));var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));boolean sameDay=current!=null&&current.day().equals(today);var rolled=roll(state,today,sameDay?Set.copyOf(current.sites()):Set.of());var catalog=rolled.catalog();var reset=new AdventureQuestState.Reset(UUID.randomUUID(),catalog.revision(),clock.instant());var next=copyReset(state,state.sites(),state.givers(),catalog,List.of(),Set.of(),Set.of(),Map.of(),reset);return new Result<>(next,new RerollResult(RerollStatus.APPLIED,Optional.of(catalog)),new CatalogPublication(catalog,reset.operation(),"admin_full_reroll",rolled.settings(),rolled.selected(),actor,true));});}
public CompletableFuture<Boolean> completeReset(UUID operation){return mutate(state->{if(state.reset()==null||!state.reset().operation().equals(operation))return result(state,false);return result(copyReset(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters(),null),true);});}
public CompletableFuture<AcceptResult> accept(UUID catalog,UUID site,AdventureQuestState.UnitType type,UUID unit,Set<UUID> members){var offered=findSite(site).orElse(null);return offered==null?CompletableFuture.completedFuture(new AcceptResult(Acceptance.UNAVAILABLE,Optional.empty())):accept(catalog,site,type,unit,members,offered.participationMode());}
public CompletableFuture<AcceptResult> accept(UUID catalog,UUID site,AdventureQuestState.UnitType type,UUID unit,Set<UUID> members,AdventureQuestState.ParticipationMode expectedMode){Objects.requireNonNull(expectedMode);var requestedMembers=Set.copyOf(members);return mutate(original->{var state=expire(original);var current=state.catalog();if(state.reset()!=null||current==null||!current.day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC)))||!current.revision().equals(catalog))return result(state,new AcceptResult(Acceptance.STALE,Optional.empty()));var offered=site(state,site).filter(this::available).orElse(null);if(!current.sites().contains(site)||current.closed().contains(site)||offered==null)return result(state,new AcceptResult(Acceptance.UNAVAILABLE,Optional.empty()));if(offered.participationMode()!=expectedMode)return result(state,new AcceptResult(Acceptance.STALE,Optional.empty()));if(!offered.participationMode().permits(type))return result(state,new AcceptResult(Acceptance.POLICY,Optional.empty()));boolean unitClaimed=state.unitClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.type()==type&&claim.unit().equals(unit));boolean characterClaimed=requestedMembers.stream().anyMatch(member->state.characterClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.character().equals(member)));if(unitClaimed||characterClaimed)return result(state,new AcceptResult(Acceptance.CLAIMED,Optional.empty()));boolean conflict=state.quests().stream().anyMatch(q->q.unit().equals(unit)||!Collections.disjoint(q.snapshot(),requestedMembers));if(conflict)return result(state,new AcceptResult(Acceptance.CONFLICT,Optional.empty()));var reward=reward(offered);var quest=new AdventureQuestState.Quest(UUID.randomUUID(),catalog,site,type,unit,requestedMembers,current.expiresAt(),reward.xp(),reward.coins(),offered.participationMode(),offered.fishing()==null?null:offered.fishing().terms());var quests=new ArrayList<>(state.quests());quests.add(quest);var tracking=new HashSet<>(state.tracking());for(UUID character:requestedMembers)tracking.add(new AdventureQuestState.Tracking(quest.id(),character,true));return result(copyTracking(state,state.sites(),state.givers(),current,List.copyOf(quests),state.unitClaims(),state.characterClaims(),state.rosters(),tracking),new AcceptResult(Acceptance.APPLIED,Optional.of(quest)));});}
public CompletableFuture<Integer> expireOfferQuests(UUID site){return mutate(state->{var quests=state.quests().stream().filter(value->!value.site().equals(site)).toList();int removed=state.quests().size()-quests.size();return removed==0?result(state,0):result(copy(state,state.sites(),state.givers(),state.catalog(),quests,state.unitClaims(),state.characterClaims(),state.rosters()),removed);});}
public CompletableFuture<Boolean> abandon(UUID quest,UUID unit){return mutate(state->{var found=state.quests().stream().filter(q->q.id().equals(quest)&&q.unit().equals(unit)).findFirst();if(found.isEmpty())return result(state,false);return result(copy(state,state.sites(),state.givers(),state.catalog(),state.quests().stream().filter(q->!q.id().equals(quest)).toList(),state.unitClaims(),state.characterClaims(),state.rosters()),true);});}
public CompletableFuture<AcceptResult> accept(UUID catalog,UUID site,AdventureQuestState.UnitType type,UUID unit,Set<UUID> members,AdventureQuestState.ParticipationMode expectedMode){Objects.requireNonNull(expectedMode);var requestedMembers=Set.copyOf(members);return mutate(original->{var state=expire(original);var current=state.catalog();if(state.reset()!=null||current==null||!current.day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC)))||!current.revision().equals(catalog))return result(state,new AcceptResult(Acceptance.STALE,Optional.empty()));var offered=site(state,site).filter(this::available).orElse(null);if(!current.sites().contains(site)||current.closed().contains(site)||offered==null)return result(state,new AcceptResult(Acceptance.UNAVAILABLE,Optional.empty()));if(offered.participationMode()!=expectedMode)return result(state,new AcceptResult(Acceptance.STALE,Optional.empty()));if(!offered.participationMode().permits(type))return result(state,new AcceptResult(Acceptance.POLICY,Optional.empty()));boolean unitClaimed=state.unitClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.type()==type&&claim.unit().equals(unit));boolean characterClaimed=requestedMembers.stream().anyMatch(member->state.characterClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.character().equals(member)));if(unitClaimed||characterClaimed)return result(state,new AcceptResult(Acceptance.CLAIMED,Optional.empty()));boolean conflict=state.quests().stream().anyMatch(q->q.unit().equals(unit)||!Collections.disjoint(q.snapshot(),requestedMembers));if(conflict)return result(state,new AcceptResult(Acceptance.CONFLICT,Optional.empty()));var reward=reward(offered);var quest=new AdventureQuestState.Quest(UUID.randomUUID(),catalog,site,type,unit,requestedMembers,current.expiresAt(),reward.xp(),reward.coins(),offered.participationMode(),offered.fishing()==null?null:offered.fishing().terms());var quests=new ArrayList<>(state.quests());quests.add(quest);var tracking=new HashSet<>(state.tracking());for(UUID character:requestedMembers)tracking.add(new AdventureQuestState.Tracking(quest.id(),character,true));return result(copyTracking(state,state.sites(),state.givers(),current,List.copyOf(quests),state.unitClaims(),state.characterClaims(),state.rosters(),tracking),new AcceptResult(Acceptance.APPLIED,Optional.of(quest),Optional.of(offered)));});}
public CompletableFuture<Integer> expireOfferQuests(UUID site){return mutate("offer_expired",state->{var quests=state.quests().stream().filter(value->!value.site().equals(site)).toList();int removed=state.quests().size()-quests.size();return removed==0?result(state,0):result(copy(state,state.sites(),state.givers(),state.catalog(),quests,state.unitClaims(),state.characterClaims(),state.rosters()),removed);});}
public CompletableFuture<Boolean> abandon(UUID quest,UUID unit){return mutate("player_abandoned",state->{var found=state.quests().stream().filter(q->q.id().equals(quest)&&q.unit().equals(unit)).findFirst();if(found.isEmpty())return result(state,false);return result(copy(state,state.sites(),state.givers(),state.catalog(),state.quests().stream().filter(q->!q.id().equals(quest)).toList(),state.unitClaims(),state.characterClaims(),state.rosters()),true);});}
public CompletableFuture<List<EntityType>> roster(UUID site){return mutate(state->{state=expire(state);if(state.reset()!=null||state.catalog()==null||!state.catalog().day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC)))||!state.catalog().sites().contains(site)||find(state.sites(),site).filter(v->v.type()==AdventureQuestState.SiteType.CAMP).isEmpty())throw new IllegalArgumentException("Unavailable camp");var existing=state.rosters().get(site);if(existing!=null)return result(state,existing);var rolled=CampRoster.roll(settings.get().campEnemies(),random);var rosters=new HashMap<>(state.rosters());rosters.put(site,rolled);return result(copy(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),rosters),rolled);});}
public CompletableFuture<Boolean> complete(UUID questId,Set<UUID> recipients){return mutate(state->{var quest=state.quests().stream().filter(q->q.id().equals(questId)).findFirst().orElse(null);if(quest==null)return result(state,false);var units=new HashSet<>(state.unitClaims());units.add(new AdventureQuestState.UnitClaim(quest.catalog(),quest.site(),quest.unitType(),quest.unit()));var characters=new HashSet<>(state.characterClaims());for(UUID recipient:recipients)characters.add(new AdventureQuestState.CharacterClaim(quest.catalog(),quest.site(),recipient));return result(copy(state,state.sites(),state.givers(),state.catalog(),state.quests().stream().filter(q->!q.id().equals(questId)).toList(),units,characters,state.rosters()),true);});}
public List<AdventureQuestState.Quest> active(){return expireView(snapshot).quests();}
@@ -78,10 +106,11 @@ public final class AdventureQuestDirectory implements AutoCloseable {
public CompletableFuture<Optional<AdventureQuestState.RewardClaim>> beginLegacyClaim(UUID character){return mutate(original->{var state=expire(original);var pending=state.rewards().operations().stream().filter(value->value.character().equals(character)).findFirst();if(pending.isPresent())return result(state,pending);var allocations=state.rewards().allocations().stream().filter(value->value.character().equals(character)).sorted(Comparator.comparing(AdventureQuestState.RewardAllocation::id)).toList();if(allocations.isEmpty())return result(state,Optional.empty());BigInteger xp=allocations.stream().map(AdventureQuestState.RewardAllocation::xp).reduce(BigInteger.ZERO,BigInteger::add),coins=allocations.stream().map(AdventureQuestState.RewardAllocation::coins).reduce(BigInteger.ZERO,BigInteger::add);var ids=allocations.stream().map(AdventureQuestState.RewardAllocation::id).collect(java.util.stream.Collectors.toSet());var first=allocations.getFirst();UUID operation=UUID.nameUUIDFromBytes((character+":"+ids.stream().sorted().map(UUID::toString).collect(java.util.stream.Collectors.joining(","))).getBytes(java.nio.charset.StandardCharsets.UTF_8));var claim=new AdventureQuestState.RewardClaim(operation,first.catalog(),first.site(),character,ids,xp,coins);var operations=new ArrayList<>(state.rewards().operations());operations.add(claim);var ledger=new AdventureQuestState.RewardLedger(state.rewards().allocations(),operations,state.rewards().receipts());return result(copyRewards(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters(),ledger),Optional.of(claim));});}
public CompletableFuture<Optional<AdventureQuestState.RewardClaim>> beginClaim(UUID catalog,UUID site,UUID character){return mutate(original->{var state=expire(original);var current=state.catalog();if(current==null||!current.revision().equals(catalog)||!current.sites().contains(site)||!clock.instant().isBefore(current.expiresAt()))return result(state,Optional.empty());var pending=state.rewards().operations().stream().filter(value->value.catalog().equals(catalog)&&value.site().equals(site)&&value.character().equals(character)).findFirst();if(pending.isPresent())return result(state,pending);var allocations=state.rewards().allocations().stream().filter(value->value.catalog().equals(catalog)&&value.site().equals(site)&&value.character().equals(character)).toList();if(allocations.isEmpty())return result(state,Optional.empty());BigInteger xp=allocations.stream().map(AdventureQuestState.RewardAllocation::xp).reduce(BigInteger.ZERO,BigInteger::add),coins=allocations.stream().map(AdventureQuestState.RewardAllocation::coins).reduce(BigInteger.ZERO,BigInteger::add);var ids=allocations.stream().map(AdventureQuestState.RewardAllocation::id).collect(java.util.stream.Collectors.toSet());UUID operation=UUID.nameUUIDFromBytes((catalog+":"+site+":"+character+":"+ids.stream().sorted().map(UUID::toString).collect(java.util.stream.Collectors.joining(","))).getBytes(java.nio.charset.StandardCharsets.UTF_8));var claim=new AdventureQuestState.RewardClaim(operation,catalog,site,character,ids,xp,coins);var operations=new ArrayList<>(state.rewards().operations());operations.add(claim);var ledger=new AdventureQuestState.RewardLedger(state.rewards().allocations(),operations,state.rewards().receipts());return result(copyRewards(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters(),ledger),Optional.of(claim));});}
public CompletableFuture<Boolean> finishClaim(UUID operation){return mutate(state->{var claim=state.rewards().operations().stream().filter(value->value.id().equals(operation)).findFirst().orElse(null);if(claim==null)return result(state,false);var allocations=state.rewards().allocations().stream().filter(value->!claim.allocations().contains(value.id())).toList();var operations=state.rewards().operations().stream().filter(value->!value.id().equals(operation)).toList();var receipts=new HashSet<>(state.rewards().receipts());receipts.add(new AdventureQuestState.RewardReceipt(claim.catalog(),claim.site(),claim.character()));var ledger=new AdventureQuestState.RewardLedger(allocations,operations,receipts);return result(copyRewards(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters(),ledger),true);});}
public CompletableFuture<Integer> expireParties(java.util.function.Predicate<UUID> exists){return mutate(state->{var quests=state.quests().stream().filter(q->q.unitType()!=AdventureQuestState.UnitType.PARTY||exists.test(q.unit())).toList();int removed=state.quests().size()-quests.size();return removed==0?result(state,0):result(copy(state,state.sites(),state.givers(),state.catalog(),quests,state.unitClaims(),state.characterClaims(),state.rosters()),removed);});}
public CompletableFuture<Integer> expireParties(java.util.function.Predicate<UUID> exists){return mutate("party_invalidated",state->{var quests=state.quests().stream().filter(q->q.unitType()!=AdventureQuestState.UnitType.PARTY||exists.test(q.unit())).toList();int removed=state.quests().size()-quests.size();return removed==0?result(state,0):result(copy(state,state.sites(),state.givers(),state.catalog(),quests,state.unitClaims(),state.characterClaims(),state.rosters()),removed);});}
private AdventureQuestState expire(AdventureQuestState state){var view=expireView(state);return view.equals(state)?state:view;}
private AdventureQuestState expireView(AdventureQuestState state){if(state.catalog()==null||clock.instant().isBefore(state.catalog().expiresAt()))return state;return new AdventureQuestState(state.sites(),state.givers(),state.catalog(),List.of(),state.unitClaims(),state.characterClaims(),state.rosters(),Set.of(),pendingRewards(state.rewards()),state.reset());}
private AdventureQuestState.Catalog roll(AdventureQuestState state,LocalDate day,Set<UUID> previous){var candidates=candidateSites(state);var alternatives=candidates.stream().map(AdventureQuestState.Site::id).filter(id->!previous.contains(id)).collect(java.util.stream.Collectors.toCollection(ArrayList::new));var old=candidates.stream().map(AdventureQuestState.Site::id).filter(previous::contains).collect(java.util.stream.Collectors.toCollection(ArrayList::new));shuffle(alternatives);shuffle(old);int count=Math.min(settings.get().dailySiteCount(),candidates.size());var selected=new ArrayList<UUID>();selected.addAll(alternatives.subList(0,Math.min(count,alternatives.size())));if(selected.size()<count)selected.addAll(old.subList(0,count-selected.size()));return new AdventureQuestState.Catalog(day,UUID.randomUUID(),clock.instant(),selected);}
private record Rolled(AdventureQuestState.Catalog catalog,AdventureQuestSettings.Rules settings,List<AdventureQuestState.Site> selected){}
private Rolled roll(AdventureQuestState state,LocalDate day,Set<UUID> previous){var candidates=candidateSites(state);var alternatives=candidates.stream().map(AdventureQuestState.Site::id).filter(id->!previous.contains(id)).collect(java.util.stream.Collectors.toCollection(ArrayList::new));var old=candidates.stream().map(AdventureQuestState.Site::id).filter(previous::contains).collect(java.util.stream.Collectors.toCollection(ArrayList::new));shuffle(alternatives);shuffle(old);var capturedSettings=settings.get();int count=Math.min(capturedSettings.dailySiteCount(),candidates.size());var selected=new ArrayList<UUID>();selected.addAll(alternatives.subList(0,Math.min(count,alternatives.size())));if(selected.size()<count)selected.addAll(old.subList(0,count-selected.size()));return new Rolled(new AdventureQuestState.Catalog(day,UUID.randomUUID(),clock.instant(),selected),capturedSettings,candidates.stream().filter(site->selected.contains(site.id())).toList());}
private void shuffle(List<UUID> values){for(int i=values.size()-1;i>0;i--){int j=random.nextInt(i+1);var v=values.get(i);values.set(i,values.get(j));values.set(j,v);}}
private List<AdventureQuestState.Site> candidateSites(AdventureQuestState state){var values=new LinkedHashMap<UUID,AdventureQuestState.Site>();state.sites().stream().filter(value->value.type()!=AdventureQuestState.SiteType.DUNGEON).forEach(value->values.put(value.id(),value));dungeonSites.get().forEach(value->values.put(value.id(),value));return List.copyOf(values.values());}
private Optional<AdventureQuestState.Site> site(AdventureQuestState state,UUID id){var stored=find(state.sites(),id);return stored.isPresent()?stored:dungeonSites.get().stream().filter(value->value.id().equals(id)).findFirst();}
@@ -93,9 +122,55 @@ public final class AdventureQuestDirectory implements AutoCloseable {
private static AdventureQuestState.RewardLedger pendingRewards(AdventureQuestState.RewardLedger rewards){return rewards;}
private static Set<AdventureQuestState.Tracking> validTracking(Set<AdventureQuestState.Tracking> tracking,List<AdventureQuestState.Quest> quests){var active=quests.stream().collect(java.util.stream.Collectors.toMap(AdventureQuestState.Quest::id,value->value));return tracking.stream().filter(value->{var quest=active.get(value.quest());return quest!=null&&quest.snapshot().contains(value.character());}).collect(java.util.stream.Collectors.toUnmodifiableSet());}
private static UUID rewardId(AdventureQuestState.Quest quest,UUID source,UUID character){return UUID.nameUUIDFromBytes((quest.catalog()+":"+quest.site()+":"+quest.id()+":"+source+":"+character).getBytes(java.nio.charset.StandardCharsets.UTF_8));}
private record Result<T>(AdventureQuestState state,T value){}
private static <T>Result<T> result(AdventureQuestState state,T value){return new Result<>(state,value);}
private <T>CompletableFuture<T> mutate(java.util.function.Function<AdventureQuestState,Result<T>> operation){var future=new CompletableFuture<T>();synchronized(this){if(closed){future.completeExceptionally(new IOException("Adventure directory closed"));return future;}pending++;}worker.execute(()->{try{var result=operation.apply(snapshot);if(result.state()!=snapshot){repository.save(result.state());snapshot=result.state();}future.complete(result.value());}catch(Throwable failure){future.completeExceptionally(failure);}finally{finished();}});return future;}
private record Result<T>(AdventureQuestState state,T value,CatalogPublication publication){}
private static <T>Result<T> result(AdventureQuestState state,T value){return new Result<>(state,value,null);}
private void observeCatalogCommit(AdventureQuestState before,Result<?> result){
try{
var after=result.state();
if(result.publication()!=null)pendingCatalogPublication=result.publication();
if(after.reset()!=null)return;
if(pendingCatalogPublication==null&&before.reset()!=null&&after.catalog()!=null){
pendingCatalogPublication=new CatalogPublication(after.catalog(),before.reset().operation(),"recovery_pending_publication",null,
candidateSites(after).stream().filter(site->after.catalog().sites().contains(site.id())).toList(),null,false);
}
if(pendingCatalogPublication!=null&&after.catalog()!=null&&pendingCatalogPublication.catalog().revision().equals(after.catalog().revision())){
var publication=pendingCatalogPublication;pendingCatalogPublication=null;catalogObserver.accept(publication);
}
}catch(RuntimeException ignored){/* Metadata observation cannot change durable catalog authority. */}
}
private void 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{
var after=result.state();var remaining=after.quests().stream().map(AdventureQuestState.Quest::id).collect(java.util.stream.Collectors.toSet());
if(result.value() instanceof AcceptResult accepted&&accepted.status()==Acceptance.APPLIED&&accepted.quest().isPresent()&&accepted.capturedSite().isPresent())originalQuestSites.put(accepted.quest().orElseThrow().id(),accepted.capturedSite().orElseThrow());
if(reason.equals("player_abandoned")){originalQuestSites.keySet().retainAll(remaining);return;}
for(var quest:before.quests()){
if(remaining.contains(quest.id()))continue;
var definition=find(before.sites(),quest.site()).orElse(originalQuestSites.get(quest.id()));
boolean dungeon=definition==null?dungeonQuestIdentity.test(quest.site()):definition.type()==AdventureQuestState.SiteType.DUNGEON;
if(!dungeon&&(definition==null||(definition.type()!=AdventureQuestState.SiteType.CAMP&&definition.type()!=AdventureQuestState.SiteType.CHAMPION)))continue;
var claim=new AdventureQuestState.UnitClaim(quest.catalog(),quest.site(),quest.unitType(),quest.unit());
if(after.unitClaims().contains(claim)&&!before.unitClaims().contains(claim))continue;
String observed=!clock.instant().isBefore(quest.expiresAt())?"utc_deadline":
!dungeon&&find(after.sites(),quest.site()).isEmpty()?"site_removed":result.publication()!=null?
result.publication().reason().equals("admin_full_reroll")?"full_day_reroll":"utc_day_rollover":reason;
var publication=result.publication();
var observer=dungeon?dungeonExpiryObserver:definition.type()==AdventureQuestState.SiteType.CHAMPION?championExpiryObserver:expiryObserver;
observer.accept(new QuestExpiry(publication==null?operation:publication.operation(),observed,quest,definition,
publication==null?null:publication.actor()));
}
originalQuestSites.keySet().retainAll(remaining);
}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();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);}
@@ -41,16 +41,18 @@ public record AdventureQuestState(List<Site> sites,List<Giver> givers,Catalog ca
public boolean contains(double xOffset,double zOffset){return shape==ArenaShape.CIRCLE?xOffset*xOffset+zOffset*zOffset<=xBlocks*(double)xBlocks:Math.abs(xOffset)<=xBlocks/2d&&Math.abs(zOffset)<=zBlocks/2d;}
public String display(){return shape==ArenaShape.CIRCLE?"circle radius="+xBlocks:"rectangle x="+xBlocks+" z="+zBlocks;}
}
public record Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z,Arena arena,ParticipationMode participationMode,FishingDefinition fishing) {
public record Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z,Arena arena,ParticipationMode participationMode,FishingDefinition fishing,UUID registrationRevision) {
public Site { Objects.requireNonNull(id);Objects.requireNonNull(type);name=validName(name);Objects.requireNonNull(world);finite(x,y,z);Objects.requireNonNull(participationMode);if((type==SiteType.FISHING)!=(fishing!=null)||fishing!=null&&(!Arena.circle(fishing.radius()).equals(arena)))throw new IllegalArgumentException("Invalid fishing site"); }
public Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z,Arena arena,ParticipationMode participationMode,FishingDefinition fishing){this(id,type,name,world,x,y,z,arena,participationMode,fishing,null);}
public Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z,Arena arena,ParticipationMode participationMode){this(id,type,name,world,x,y,z,arena,participationMode,null);}
public Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z,Arena arena){this(id,type,name,world,x,y,z,arena,ParticipationMode.PARTY_AVAILABLE,null);}
public Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z){this(id,type,name,world,x,y,z,null,ParticipationMode.PARTY_AVAILABLE,null);}
public String displayName(){return name.isBlank()?switch(type){case CAMP->"Monster Camp";case CHAMPION->"Champion Site";case DUNGEON->"Dungeon";case FISHING->"Fishing Site";}:name;}
public Arena arenaOrDefault(int radius){return arena==null?Arena.circle(radius):arena;}
}
public record Giver(UUID id,String name,UUID world,double x,double y,double z,float yaw,float pitch) {
public record Giver(UUID id,String name,UUID world,double x,double y,double z,float yaw,float pitch,UUID registrationRevision) {
public Giver { Objects.requireNonNull(id);name=validName(name);Objects.requireNonNull(world);finite(x,y,z);if(!Float.isFinite(yaw)||!Float.isFinite(pitch))throw new IllegalArgumentException("Invalid facing"); }
public Giver(UUID id,String name,UUID world,double x,double y,double z,float yaw,float pitch){this(id,name,world,x,y,z,yaw,pitch,null);}
public String displayName(){return name.isBlank()?"Quest Giver":name;}
}
public record Catalog(LocalDate day,UUID revision,Instant createdAt,List<UUID> sites,Set<UUID> closed) {
@@ -67,6 +67,9 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
fishingEvents.trophyEarned(trophy.earned());
});
}
public boolean earnedTrophy(UUID id){return fishing.trophy(id)
.map(value->value.phase()==FishingState.TrophyPhase.EARNED)
.orElseGet(()->fishing.snapshot().trophyReceipts().contains(id));}
public void open(Player player){open(player,0,null);}
public void open(Player player,AdventureQuestState.Giver giver){open(player,0,giver);}
private void open(Player player,int requestedPage,AdventureQuestState.Giver giver){
@@ -265,8 +268,7 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
player.sendMessage("Trophy redemption failed; the earned trophy is retained.");return;}
var status=receipt==null?null:receipt.status();
if(status==FishingDirectory.TrophyRedeemStatus.APPLIED){
if(captured!=null&&(captured.type()==FishingState.TrophyType.CHAMPION
||captured.type()==FishingState.TrophyType.CAMP)&&fishingEvents!=null){
if(captured!=null&&fishingEvents!=null){
var data=new LinkedHashMap<String,Object>();data.put("receipt_id",captured.id().toString());
data.put("trophy_type",captured.type().name().toLowerCase(java.util.Locale.ROOT)+"_trophy");
data.put("trophy_name",captured.name());
@@ -274,6 +276,10 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
data.put("final_enemy_id",captured.audit().getOrDefault("final_enemy_id","unavailable_legacy_capture"));
data.put("clear_location",games.dmg.fantasystick.events.FishingEventBridge.savedDefeatLocation(captured.audit()));
data.put("arena",games.dmg.fantasystick.events.FishingEventBridge.savedArena(captured.audit()));
}else if(captured.type()==FishingState.TrophyType.DUNGEON){
data.put("boss_id",captured.audit().getOrDefault("boss_id","unavailable_legacy_capture"));
data.put("room_id",captured.audit().getOrDefault("room_id","unavailable_legacy_capture"));
data.put("boss_death_location",games.dmg.fantasystick.events.FishingEventBridge.savedDefeatLocation(captured.audit()));
}
data.put("source_name",captured.origin());data.put("carrier_character_id",captured.carrier().toString());
data.put("catalog_id",captured.catalog().toString());data.put("site_id",captured.site().toString());
@@ -896,7 +896,12 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
if (closed || passiveEngine == null || !unlock && !unlearn
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)
|| event.getTag() == null) return;
var player=connection.getPlayer();String raw=event.getTag().string();if(raw.length()>120)return;var match=ABILITY_PAYLOAD.matcher(raw);if(!match.matches())return;String passive=match.group(2);var definition=passives.find(passive).orElse(null);if(definition==null)return;var view=passiveViews.get(player.getUniqueId());boolean owned=games.dmg.fantasystick.passive.QuickerRegenerationI.ID.equals(passive);
var player=connection.getPlayer();String raw=event.getTag().string();if(raw.length()>120)return;var match=ABILITY_PAYLOAD.matcher(raw);if(!match.matches())return;String passive=match.group(2);var definition=passives.find(passive).orElse(null);if(definition==null)return;var view=passiveViews.get(player.getUniqueId());boolean owned=games.dmg.fantasystick.passive.QuickerRegenerationI.ID.equals(passive)
|| games.dmg.fantasystick.passive.QuickerRegenerationII.ID.equals(passive)
|| games.dmg.fantasystick.passive.HealthSteal.ID.equals(passive)
|| games.dmg.fantasystick.passive.SpeedI.ID.equals(passive)
|| games.dmg.fantasystick.passive.LowLightVision.ID.equals(passive)
|| games.dmg.fantasystick.passive.StickKnockback.ID.equals(passive);
if (view == null || view.input().player() != player || !view.nonce().equals(match.group(1)) || !view.ids().contains(passive)
|| !passiveViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) {if(owned)refusePassive(player,definition,unlock,"stale_forged_or_repeated_action");return;}
var audit=owned&&passiveEvents!=null?passiveEvents.begin(view.input(),view.before(),definition,unlock):null;
@@ -29,6 +29,7 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
private final games.dmg.fantasystick.training.TrainingDamage trainingDamage;
private final Map<UUID, Instant> readyAt = new HashMap<>();
private games.dmg.fantasystick.events.BasicAttackEventBridge events;
private games.dmg.fantasystick.events.StickKnockbackEventBridge knockbackEvents;
private Pending pending;
private boolean closed;
@@ -77,6 +78,11 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
events = Objects.requireNonNull(bridge);
}
public void attachKnockbackEvents(games.dmg.fantasystick.events.StickKnockbackEventBridge bridge) {
if (closed || knockbackEvents != null) throw new IllegalStateException("Knockback events already attached");
knockbackEvents = Objects.requireNonNull(bridge);
}
@Override public void accept(ControlInput input) {
if (input.kind() != ControlInput.Kind.BASIC_ATTACK || input.gesture() != ControlInput.Gesture.ATTACK) return;
Instant now = clock.instant();
@@ -98,7 +104,11 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
pending = attempt;
boolean completed = false;
try {
var measured = trainingDamage.apply(input.player(), target, () -> target.damage(nativeDamage, input.player()));
var measurementSource = new games.dmg.fantasystick.training.TrainingDamage.Source(
action == null ? java.util.UUID.randomUUID() : action.id(), input.session().player(), record.id(),
record.name(), input.session().session(), "Basic Attack", null);
var measured = trainingDamage.apply(input.player(), target, measurementSource,
() -> target.damage(nativeDamage, input.player()));
measured.ifPresent(amount -> {
attempt.accepted = true;
if (feedback != null) feedback.report("Basic Attack", input.player(), AbilityReport.Type.DAMAGE, target, amount);
@@ -110,7 +120,8 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
readyAt.put(input.session().character(), attempt.attackedAt.plus(CADENCE));
if (attempt.knockback && !trainingDamage.target(target) && input.stillCurrent()
&& sessions.current(input.session())) {
games.dmg.fantasystick.passive.StickKnockback.apply(input.player(), target);
if (knockbackEvents == null) games.dmg.fantasystick.passive.StickKnockback.apply(input.player(), target);
else knockbackEvents.apply(action, input, record, target, sessions);
}
}
if (events != null) events.resolved(action, attempt.accepted ? "accepted" : !completed ? "failed"
@@ -0,0 +1,200 @@
package games.dmg.fantasystick.dungeon;
import games.dmg.fantasystick.events.CharacterBossEventBridge;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
import org.bukkit.event.entity.EntityPotionEffectEvent;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
/** Read-only, native-thread observations. Never owns, removes or restores a potion layer. */
final class CharacterBossEffectAudit {
private record Key(UUID target, PotionEffectType type) {}
private record Application(UUID id, UUID shell, CharacterBossEventBridge.Facts facts,
PotionEffect signature) {}
record NativeCall(CharacterBossEventBridge.Facts facts, LivingEntity target,
PotionEffect requested, PotionEffect prior, boolean returned,
List<EntityPotionEffectEvent> nativeEvents) {
NativeCall { nativeEvents = List.copyOf(nativeEvents); }
}
private final Map<Key, Application> owned = new HashMap<>();
private final CharacterBossEventBridge events;
private final Consumer<Runnable> nextTick;
CharacterBossEffectAudit(CharacterBossEventBridge events, Consumer<Runnable> nextTick) {
this.events = events;
this.nextTick = nextTick;
}
static PotionEffect layer(LivingEntity target, PotionEffectType type) {
return target.getActivePotionEffects().stream().filter(effect -> effect.getType().equals(type)).findFirst().orElse(null);
}
// A passive has no cast event. Require the exact requested infinite native layer after confirmed spawn.
void passive(CharacterBossEventBridge.Facts shell, LivingEntity target, String id,
PotionEffect requested, Map<String,Object> location) {
if (requested.equals(layer(target, requested.getType()))) {
var facts = events.transition(shell, Map.of("passive_id", id,
"source_location", location, "target_location", location,
"target_kind", "native_character_shell"));
apply(facts, target, requested, "confirmed_equipped_passive");
}
}
String resolve(List<NativeCall> calls) {
if (calls.isEmpty()) return null;
boolean accepted = false, cancelled = false, unchanged = false;
for (var call : calls) {
// Ambiguous reentrant observations cannot stand in for the original single native call.
if (call.nativeEvents().size() != 1) continue;
var event = call.nativeEvents().getFirst();
if (event.isCancelled()) { cancelled = true; continue; }
var current = layer(call.target(), call.requested().getType());
if (call.requested().equals(call.prior()) && call.requested().equals(current)) {
unchanged = true;
continue;
}
// Source may be null in pinned Bukkit. Exact per-call target/request plus final decision,
// actual returned acceptance and a changed native layer establish this observation.
if (call.returned() && call.requested().equals(event.getNewEffect())
&& call.requested().equals(current)) {
apply(call.facts(), call.target(), call.requested(), "native_call_changed_layer_confirmed");
accepted = true;
}
}
return accepted ? "accepted" : cancelled ? "cancelled" : unchanged ? "no_op" : "unconfirmed";
}
private void apply(CharacterBossEventBridge.Facts facts, LivingEntity target,
PotionEffect signature, String status) {
var key = new Key(target.getUniqueId(), signature.getType());
var previous = owned.get(key);
if (previous != null) end(key, previous, nativeEnd(previous, null, target),
"native_layer_replaced", "confirmed_replacement");
var id = UUID.randomUUID();
var data = new LinkedHashMap<String,Object>();
data.put("application_id", id.toString());
data.put("effect_type", signature.getType().getKey().toString());
data.put("amplifier", signature.getAmplifier());
data.put("duration_ticks", signature.getDuration());
data.put("particles", signature.hasParticles());
data.put("target_entity_id", target.getUniqueId().toString());
data.put("application_status", status);
data.put("causation", "original_character_boss_native_effect");
var original = events.transition(facts, data);
owned.put(key, new Application(id, UUID.fromString(facts.data().get("native_entity_id").toString()),
original, signature));
events.effectApplied(original, id, Map.of("outcome", "accepted"));
}
// Removal listeners run before the server mutates its layer. Confirm after dispatch AND mutation.
void removal(EntityPotionEffectEvent event) {
if (event.getAction() != EntityPotionEffectEvent.Action.REMOVED
&& event.getAction() != EntityPotionEffectEvent.Action.CHANGED) return;
if (!(event.getEntity() instanceof LivingEntity target)) return;
var old = event.getOldEffect();
if (old == null) return;
var key = new Key(target.getUniqueId(), old.getType());
var application = owned.get(key);
if (application == null || !sameOwnedLayer(application.signature(), old)) return;
var cause = event.getCause().name().toLowerCase(java.util.Locale.ROOT);
var endFacts = nativeEnd(application, event.getSource(), target);
nextTick.accept(() -> {
try {
if (!event.isCancelled() && owned.get(key) == application
&& (layer(target, old.getType()) == null
|| !sameOwnedLayer(application.signature(), layer(target, old.getType())))) {
end(key, application, endFacts, "native_" + cause,
"native_layer_no_longer_matches");
}
} catch (RuntimeException unavailable) { /* Audit must not affect native expiration. */ }
});
}
private static boolean sameOwnedLayer(PotionEffect original, PotionEffect observed) {
return original.getType().equals(observed.getType()) && original.getAmplifier() == observed.getAmplifier()
&& original.hasParticles() == observed.hasParticles() && original.isAmbient() == observed.isAmbient()
&& (original.getDuration() == PotionEffect.INFINITE_DURATION
? observed.getDuration() == PotionEffect.INFINITE_DURATION
: observed.getDuration() <= original.getDuration());
}
void shellEnded(Entity shell, String reason) {
var id = shell.getUniqueId();
for (var entry : new ArrayList<>(owned.entrySet())) {
var application = entry.getValue();
if (application.shell().equals(id) && entry.getKey().target().equals(id))
end(entry.getKey(), application, nativeEnd(application, shell, shell),
reason, "native_shell_lifetime_ended");
}
}
void shellEndUnconfirmed(Entity shell, String reason, String status) {
var id = shell.getUniqueId();
for (var entry : new ArrayList<>(owned.entrySet())) {
var key = entry.getKey(); var application = entry.getValue();
if (!application.shell().equals(id) || !key.target().equals(id)) continue;
try {
var original = nativeEnd(application, shell, shell);
events.effectEndPending(original, application.id(), Map.of("phase", "native_effect_end_confirmation_pending",
"application_id", application.id().toString(), "outcome", "unconfirmed",
"end_status", status, "reason", reason, "causation", "original_native_shell_remove_returned"));
// One native-thread confirmation, not a worker/replay retry or gameplay cleanup.
nextTick.accept(() -> {
try { if (!shell.isValid() && owned.get(key) == application)
end(key, application, original, "native_invalid_confirmed_after_original_remove",
"native_shell_lifetime_ended_after_original_unconfirmed_remove");
} catch (RuntimeException unavailable) { /* Keep immutable pending application evidence, never invent an end. */ }
});
} catch (RuntimeException unavailable) { /* Each original application remains independent. */ }
}
}
private CharacterBossEventBridge.Facts nativeEnd(Application application, Entity source, Entity target) {
var original = application.facts();
var data = new LinkedHashMap<String,Object>();
if (original.data().containsKey("source_location"))
data.put("application_source_location", original.data().get("source_location"));
if (original.data().containsKey("target_location"))
data.put("application_target_location", original.data().get("target_location"));
data.put("application_observed_at", original.request().observedAt().toString());
data.put("source_location", nativeEndLocation(source));
data.put("target_location", nativeEndLocation(target));
if (source != null) data.put("end_event_source_entity_id", source.getUniqueId().toString());
data.put("end_capture_quality", "original_native_transition_before_confirmation");
return events.transition(original, data);
}
private static Map<String,Object> nativeEndLocation(Entity entity) {
try {
var at = entity == null ? null : entity.getLocation();
if (at == null || at.getWorld() == null)
return Map.of("location_status", "unavailable_at_original_native_end");
var data = new LinkedHashMap<String,Object>();
data.put("world_id", at.getWorld().getUID().toString());
data.put("world_name", java.util.Objects.toString(at.getWorld().getName(), "unavailable"));
data.put("x", at.getX()); data.put("y", at.getY()); data.put("z", at.getZ());
data.put("yaw", (double)at.getYaw()); data.put("pitch", (double)at.getPitch());
data.put("captured_at", java.time.Instant.now().toString());
data.put("quality", "native_original");
return Map.copyOf(data);
} catch (RuntimeException unavailable) {
return Map.of("location_status", "unavailable_at_original_native_end");
}
}
private void end(Key key, Application application, CharacterBossEventBridge.Facts facts,
String reason, String status) {
if (!owned.remove(key, application)) return;
events.effectEnded(facts, application.id(), Map.of("application_id", application.id().toString(),
"reason", reason, "cleanup_status", status, "outcome", "ended",
"causation", "original_character_boss_effect_application"));
}
}
@@ -17,33 +17,40 @@ import org.bukkit.potion.PotionEffectType;
/** Registered command adapter for durable dungeon authoring and catalog administration. */
public final class DungeonCommands {
private static final String PERMISSION="fantasystick.admin";
private final Plugin plugin;private final DungeonDirectory directory;private final DungeonController controller;private final games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures;private final Consumer<Runnable> nextTick;private final games.dmg.fantasystick.adventure.AdventureLocationPresentation locations;private final Function<UUID,CompletableFuture<Optional<CharacterRecord>>> characters;private final Predicate<UUID> activeCharacter;private final Predicate<UUID> partyCharacter;private final CharacterSheets sheets=new CharacterSheets();private final AdventureAdminTeleport teleports;
private final Plugin plugin;private final DungeonDirectory directory;private final DungeonController controller;private final games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures;private final Consumer<Runnable> nextTick;private final games.dmg.fantasystick.adventure.AdventureLocationPresentation locations;private final Function<UUID,CompletableFuture<Optional<CharacterRecord>>> characters;private final Predicate<UUID> activeCharacter;private final Predicate<UUID> partyCharacter;private final CharacterSheets sheets=new CharacterSheets();private final AdventureAdminTeleport teleports;private games.dmg.fantasystick.events.DungeonEventBridge auditEvents;private games.dmg.fantasystick.events.DungeonAuditEventBridge ownerEvents;private games.dmg.fantasystick.events.CharacterBossEventBridge characterBossEvents;
public DungeonCommands(Plugin plugin,DungeonDirectory directory,DungeonController controller,games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures,Consumer<Runnable> nextTick){this(plugin,directory,controller,adventures,nextTick,id->CompletableFuture.completedFuture(Optional.empty()),id->false,id->false);}
public DungeonCommands(Plugin plugin,DungeonDirectory directory,DungeonController controller,games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures,Consumer<Runnable> nextTick,Function<UUID,CompletableFuture<Optional<CharacterRecord>>> characters,Predicate<UUID> activeCharacter,Predicate<UUID> partyCharacter){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.controller=Objects.requireNonNull(controller);this.adventures=Objects.requireNonNull(adventures);this.nextTick=Objects.requireNonNull(nextTick);this.characters=Objects.requireNonNull(characters);this.activeCharacter=Objects.requireNonNull(activeCharacter);this.partyCharacter=Objects.requireNonNull(partyCharacter);locations=new games.dmg.fantasystick.adventure.AdventureLocationPresentation(plugin.getServer());teleports=new AdventureAdminTeleport(plugin,nextTick);}
public void attachEvents(games.dmg.fantasystick.events.DungeonEventBridge events){this.auditEvents=Objects.requireNonNull(events);}
public void attachOwnerEvents(games.dmg.fantasystick.events.DungeonAuditEventBridge events){this.ownerEvents=Objects.requireNonNull(events);}
public void attachCharacterBossEvents(games.dmg.fantasystick.events.CharacterBossEventBridge events){this.characterBossEvents=Objects.requireNonNull(events);}
public void execute(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usage(sender);return;}try{switch(args[1]){case"create"->create(sender,args);case"list"->list(sender,args);case"mode"->mode(sender,args);case"remove"->remove(sender,args);case"edit"->edit(sender,args);case"entrance"->entrance(sender,args);case"room"->room(sender,args);case"reward"->reward(sender,args);case"boss"->bossCommand(sender,args);case"teleport"->teleport(sender,args);case"enable"->enable(sender,args);case"disable"->disable(sender,args);case"open"->open(sender,args);case"close"->close(sender,args);default->usage(sender);}}catch(IllegalArgumentException invalid){sender.sendMessage("Dungeon command refused: "+invalid.getMessage()+".");}}
private void create(CommandSender sender,String[] args){if(args.length<3||!(sender instanceof Player player)){usage(sender);return;}int nameStart=2;var mode=games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;if(args[2].equals("--mode")){if(args.length<5){usage(sender);return;}try{mode=games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){usage(sender);return;}nameStart=4;}else if(args[2].startsWith("--")){usage(sender);return;}String name=join(args,nameStart);if(name.isBlank()){usage(sender);return;}var location=player.getLocation();directory.create(name,Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch(),mode).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Created disabled dungeon "+value.id()+" ("+value.name()+"); mode="+value.participationMode().value()+".":"Dungeon creation failed; desired state is unchanged.")));}
private void create(CommandSender sender,String[] args){if(args.length<3||!(sender instanceof Player player)){usage(sender);return;}int nameStart=2;var mode=games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;if(args[2].equals("--mode")){if(args.length<5){usage(sender);return;}try{mode=games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){usage(sender);return;}nameStart=4;}else if(args[2].startsWith("--")){usage(sender);return;}String name=join(args,nameStart);if(name.isBlank()){usage(sender);return;}var location=player.getLocation();var audit=ownerEvents;var actor=audit==null?null:audit.actor(sender);var operation=UUID.randomUUID();directory.create(name,Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch(),mode).whenComplete((value,failure)->{if(failure==null&&audit!=null)try{audit.created(operation,actor,value);}catch(RuntimeException ignored){/* Audit cannot undo acknowledged authoring. */}respond(sender,()->sender.sendMessage(failure==null?"Created disabled dungeon "+value.id()+" ("+value.name()+"); mode="+value.participationMode().value()+".":"Dungeon creation failed; desired state is unchanged."));});}
private void list(CommandSender sender,String[] args){if(args.length!=2){usage(sender);return;}if(directory.snapshot().dungeons().isEmpty()){sender.sendMessage("No dungeons registered.");return;}directory.snapshot().dungeons().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(value->sender.sendMessage(value.id()+" "+value.name()+" at "+locations.format(value.world(),value.entrance().x(),value.entrance().y(),value.entrance().z())+"; "+(value.enabled()?"enabled":"disabled")+"; mode="+value.participationMode().value()+"; rooms="+value.rooms().size()+"; completion="+(value.rewardConfigured()?value.completionReward().xp()+" XP, "+value.completionReward().coins()+" coins":"not configured")+"."));}
private void mode(CommandSender sender,String[] args){if(args.length<3||args.length>4){usage(sender);return;}UUID id=id(args[2]);var current=id==null?null:directory.find(id).orElse(null);if(current==null){sender.sendMessage("Dungeon not found.");return;}if(args.length==3){sender.sendMessage("Dungeon "+id+" mode="+current.participationMode().value()+".");return;}games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode requested;try{requested=games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){usage(sender);return;}directory.setMode(id,current.revision(),requested).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Updated dungeon "+id+" mode="+value.participationMode().value()+" for future acceptances.":"Dungeon mode update refused; the acknowledged definition is unchanged or stale.")));}
private void remove(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}if(directory.custodyForDungeon(id).isPresent()){sender.sendMessage("Recover character boss custody before removing this dungeon.");return;}controller.closeOffer(id).thenCompose(ignored->adventures.removeDungeonSite(id)).thenCompose(ignored->directory.remove(id)).whenComplete((removed,failure)->respond(sender,()->sender.sendMessage(failure!=null?"Dungeon removal failed; desired state is retained.":removed.isPresent()?"Removed dungeon "+id+".":"Dungeon not found.")));}
private void edit(CommandSender sender,String[] args){if(!(sender instanceof Player player)){usage(sender);return;}if(args.length==3&&args[2].equals("done")){sender.sendMessage(controller.done(player.getUniqueId())?"Dungeon editing ended; the dungeon remains disabled.":"No dungeon is being edited.");return;}if(args.length<4||args.length>5||!args[2].equals("start")||args.length==5&&!args[4].equals("confirm")){usage(sender);return;}UUID id=id(args[3]);if(id==null){usage(sender);return;}controller.start(player,id,args.length==5).whenComplete((status,failure)->respond(sender,()->{if(failure!=null){plugin.getLogger().log(java.util.logging.Level.WARNING,"Dungeon edit transition failed",failure);sender.sendMessage("Dungeon edit transition failed; retained state is authoritative.");return;}sender.sendMessage(switch(status){case STARTED->"Editing dungeon "+id+"; it is disabled.";case CONFIRM_REQUIRED->"Dungeon has offered, accepted or progressed work; repeat with exact confirm to close and expire it.";case BUSY->"Dungeon editing refused: the editor or dungeon already has an editing lease.";case NOT_FOUND->"Dungeon not found.";});}));}
private void entrance(CommandSender sender,String[] args){if(args.length!=3||!args[2].equals("set")||!(sender instanceof Player player)){usage(sender);return;}UUID dungeon=editing(player);if(dungeon==null)return;var location=player.getLocation();if(location.getWorld()==null||!directory.find(dungeon).orElseThrow().world().equals(location.getWorld().getUID())){sender.sendMessage("Dungeon entrance must remain in its world.");return;}directory.setEntrance(dungeon,location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->saved(sender,failure,"Updated dungeon entrance."));}
private void room(CommandSender sender,String[] args){if(!(sender instanceof Player player)||args.length<3){usage(sender);return;}UUID dungeon=editing(player);if(dungeon==null)return;switch(args[2]){case"create"->roomCreate(sender,player,dungeon,args);case"list"->{if(args.length!=3){usage(sender);return;}var value=directory.find(dungeon).orElseThrow();if(value.rooms().isEmpty())sender.sendMessage("No rooms defined.");else value.rooms().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.displayName()+" "+v.bounds()+"; mobs="+v.mobs().size()+"; effects="+v.effects().size()+"; boss="+(v.boss()==null?"none":v.boss().type().name().toLowerCase(Locale.ROOT))));}case"remove"->{if(args.length!=4){usage(sender);return;}UUID room=id(args[3]);if(room==null){usage(sender);return;}directory.removeRoom(dungeon,room).whenComplete((value,failure)->{if(failure==null&&value.isPresent())controller.definitionChanged(dungeon);respond(sender,()->sender.sendMessage(failure!=null?"Room removal refused: it would overlap, disconnect or could not be saved.":value.isPresent()?"Removed room "+room+".":"Room not found."));});}case"mob"->mob(sender,player,dungeon,args);case"effect"->effect(sender,player,dungeon,args);case"boss"->boss(sender,player,dungeon,args);default->usage(sender);}}
private void roomCreate(CommandSender sender,Player player,UUID dungeon,String[] args){if(args.length<6){usage(sender);return;}var location=player.getLocation();int x=location.getBlockX(),y=location.getBlockY()-1,z=location.getBlockZ();if(args[3].equals("corner")){if(args.length<7){usage(sender);return;}Integer xs=integer(args[4]),zs=integer(args[5]),height=integer(args[6]);if(xs==null||zs==null||height==null){usage(sender);return;}directory.addCornerRoom(dungeon,join(args,7),x,y,z,xs,zs,height).whenComplete((value,failure)->roomSaved(sender,dungeon,value,failure));return;}if(args[3].equals("radius")){Integer radius=integer(args[4]),height=integer(args[5]);if(radius==null||height==null){usage(sender);return;}directory.addRadiusRoom(dungeon,join(args,6),x,y,z,radius,height).whenComplete((value,failure)->roomSaved(sender,dungeon,value,failure));return;}usage(sender);}
private void mode(CommandSender sender,String[] args){if(args.length<3||args.length>4){usage(sender);return;}UUID id=id(args[2]);var current=id==null?null:directory.find(id).orElse(null);if(current==null){sender.sendMessage("Dungeon not found.");return;}if(args.length==3){sender.sendMessage("Dungeon "+id+" mode="+current.participationMode().value()+".");return;}games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode requested;try{requested=games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){usage(sender);return;}var audit=ownerEvents;var actor=audit==null?null:audit.actor(sender);var operation=UUID.randomUUID();directory.setModeWithEvidence(id,current.revision(),requested).whenComplete((change,failure)->{if(failure==null&&audit!=null)try{audit.modeChanged(operation,actor,change.before(),change.after());}catch(RuntimeException ignored){/* Audit cannot undo acknowledged authoring. */}respond(sender,()->sender.sendMessage(failure==null?"Updated dungeon "+id+" mode="+change.after().participationMode().value()+" for future acceptances.":"Dungeon mode update refused; the acknowledged definition is unchanged or stale."));});}
private void remove(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}if(directory.custodyForDungeon(id).isPresent()){sender.sendMessage("Recover character boss custody before removing this dungeon.");return;}var request=request(sender);controller.closeOffer(id).thenCompose(ignored->adventures.removeDungeonSite(id)).thenCompose(ignored->observedSave(request,"administrator_remove",()->directory.remove(id))).whenComplete((removed,failure)->respond(sender,()->sender.sendMessage(failure!=null?"Dungeon removal failed; desired state is retained.":removed.isPresent()?"Removed dungeon "+id+".":"Dungeon not found.")));}
private void edit(CommandSender sender,String[] args){if(!(sender instanceof Player player)){usage(sender);return;}if(args.length==3&&args[2].equals("done")){var request=request(sender);var before=controller.editing(player.getUniqueId()).flatMap(directory::find).orElse(null);boolean ended=controller.done(player.getUniqueId());if(ended&&before!=null&&request.events()!=null)try{request.events().lease("dungeon.edit.ended",request.operation(),request.actor(),before,"administrator_done");}catch(RuntimeException ignored){}sender.sendMessage(ended?"Dungeon editing ended; the dungeon remains disabled.":"No dungeon is being edited.");return;}if(args.length<4||args.length>5||!args[2].equals("start")||args.length==5&&!args[4].equals("confirm")){usage(sender);return;}UUID id=id(args[3]);if(id==null){usage(sender);return;}var request=request(sender);controller.startWithEvidence(player,id,args.length==5).whenComplete((publication,failure)->{var status=publication==null?null:publication.status();if(failure==null&&status==DungeonController.EditStatus.STARTED&&request.events()!=null)try{request.events().lease("dungeon.edit.started",request.operation(),request.actor(),publication.dungeon(),"administrator_start");}catch(RuntimeException ignored){}respond(sender,()->{if(failure!=null){plugin.getLogger().log(java.util.logging.Level.WARNING,"Dungeon edit transition failed",failure);sender.sendMessage("Dungeon edit transition failed; retained state is authoritative.");return;}sender.sendMessage(switch(status){case STARTED->"Editing dungeon "+id+"; it is disabled.";case CONFIRM_REQUIRED->"Dungeon has offered, accepted or progressed work; repeat with exact confirm to close and expire it.";case BUSY->"Dungeon editing refused: the editor or dungeon already has an editing lease.";case NOT_FOUND->"Dungeon not found.";});});});}
private void entrance(CommandSender sender,String[] args){if(args.length!=3||!args[2].equals("set")||!(sender instanceof Player player)){usage(sender);return;}UUID dungeon=editing(player);if(dungeon==null)return;var location=player.getLocation();if(location.getWorld()==null||!directory.find(dungeon).orElseThrow().world().equals(location.getWorld().getUID())){sender.sendMessage("Dungeon entrance must remain in its world.");return;}observedSave(sender,"administrator_entrance",()->directory.setEntrance(dungeon,location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch())).whenComplete((value,failure)->saved(sender,failure,"Updated dungeon entrance."));}
private void room(CommandSender sender,String[] args){if(!(sender instanceof Player player)||args.length<3){usage(sender);return;}UUID dungeon=editing(player);if(dungeon==null)return;switch(args[2]){case"create"->roomCreate(sender,player,dungeon,args);case"list"->{if(args.length!=3){usage(sender);return;}var value=directory.find(dungeon).orElseThrow();if(value.rooms().isEmpty())sender.sendMessage("No rooms defined.");else value.rooms().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.displayName()+" "+v.bounds()+"; mobs="+v.mobs().size()+"; effects="+v.effects().size()+"; boss="+(v.boss()==null?"none":v.boss().type().name().toLowerCase(Locale.ROOT))));}case"remove"->{if(args.length!=4){usage(sender);return;}UUID room=id(args[3]);if(room==null){usage(sender);return;}observedSave(sender,"administrator_room_remove",()->directory.removeRoom(dungeon,room)).whenComplete((value,failure)->{if(failure==null&&value.isPresent())controller.definitionChanged(dungeon);respond(sender,()->sender.sendMessage(failure!=null?"Room removal refused: it would overlap, disconnect or could not be saved.":value.isPresent()?"Removed room "+room+".":"Room not found."));});}case"mob"->mob(sender,player,dungeon,args);case"effect"->effect(sender,player,dungeon,args);case"boss"->boss(sender,player,dungeon,args);default->usage(sender);}}
private void roomCreate(CommandSender sender,Player player,UUID dungeon,String[] args){if(args.length<6){usage(sender);return;}var location=player.getLocation();int x=location.getBlockX(),y=location.getBlockY()-1,z=location.getBlockZ();if(args[3].equals("corner")){if(args.length<7){usage(sender);return;}Integer xs=integer(args[4]),zs=integer(args[5]),height=integer(args[6]);if(xs==null||zs==null||height==null){usage(sender);return;}observedSave(sender,"administrator_room_create",()->directory.addCornerRoom(dungeon,join(args,7),x,y,z,xs,zs,height)).whenComplete((value,failure)->roomSaved(sender,dungeon,value,failure));return;}if(args[3].equals("radius")){Integer radius=integer(args[4]),height=integer(args[5]);if(radius==null||height==null){usage(sender);return;}observedSave(sender,"administrator_room_create",()->directory.addRadiusRoom(dungeon,join(args,6),x,y,z,radius,height)).whenComplete((value,failure)->roomSaved(sender,dungeon,value,failure));return;}usage(sender);}
private void roomSaved(CommandSender sender,UUID dungeon,DungeonState.Room value,Throwable failure){if(failure==null)controller.definitionChanged(dungeon);String message=failure==null?"Created room "+value.id()+" ("+value.displayName()+").":roomFailure(failure);respond(sender,()->sender.sendMessage(message));}
private static String roomFailure(Throwable failure){Throwable cause=failure;while((cause instanceof java.util.concurrent.CompletionException||cause instanceof java.util.concurrent.ExecutionException)&&cause.getCause()!=null)cause=cause.getCause();String detail=cause.getMessage();if(cause instanceof IllegalArgumentException&&"Rooms overlap".equals(detail))return "Room creation refused: it overlaps an existing room.";if(cause instanceof IllegalArgumentException&&detail!=null&&detail.startsWith("Room exceeds configured link distance of ")){String blocks=detail.substring("Room exceeds configured link distance of ".length()).replace(" blocks","");return "Room creation refused: nearest room is farther than the configured "+blocks+"-block link distance.";}if(cause instanceof IllegalArgumentException&&(detail!=null&&(detail.equals("Room dimensions out of range")||detail.equals("Room limit reached"))))return "Room creation refused: dimensions or room limit are out of range.";if(cause instanceof java.io.IOException)return "Room creation failed: persistence is unavailable; desired state is unchanged.";return "Room creation refused: geometry validation failed; desired state is unchanged.";}
private void mob(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length>=5&&args[3].equals("add")){var type=entity(args[4]);if(!ordinary(type)){sender.sendMessage("Unsupported living mob type.");return;}var location=player.getLocation();directory.addMob(dungeon,room.id(),type,join(args,5),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->saved(sender,failure,"Added mob slot "+(value==null?"":value.id())+"."));return;}if(args.length==5&&args[3].equals("remove")){UUID id=id(args[4]);if(id==null){usage(sender);return;}directory.removeMob(dungeon,room.id(),id).whenComplete((value,failure)->saved(sender,failure,value!=null&&value.isPresent()?"Removed mob slot "+id+".":"Mob slot not found."));return;}if(args.length==4&&args[3].equals("list")){if(room.mobs().isEmpty())sender.sendMessage("No ordinary mob slots in this room.");else room.mobs().forEach(value->sender.sendMessage(value.id()+" "+value.type().name().toLowerCase(Locale.ROOT)+" "+value.name()));return;}usage(sender);}
private void effect(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length>=7&&args[3].equals("add")){DungeonState.EffectTarget target;try{target=DungeonState.EffectTarget.valueOf(args[4].toUpperCase(Locale.ROOT));}catch(IllegalArgumentException invalid){usage(sender);return;}String key=args[5].contains(":")?args[5]:"minecraft:"+args[5];var namespaced=NamespacedKey.fromString(key);Integer level=integer(args[6]);boolean particles=args.length<8||Boolean.parseBoolean(args[7]);if(namespaced==null||PotionEffectType.getByKey(namespaced)==null||level==null||args.length>8||args.length==8&&!Set.of("true","false").contains(args[7])){usage(sender);return;}directory.addEffect(dungeon,room.id(),target,key,level,particles).whenComplete((value,failure)->saved(sender,failure,"Added room effect "+(value==null?"":value.id())+"."));return;}if(args.length==5&&args[3].equals("remove")){UUID id=id(args[4]);if(id==null){usage(sender);return;}directory.removeEffect(dungeon,room.id(),id).whenComplete((value,failure)->saved(sender,failure,value!=null&&value.isPresent()?"Removed room effect "+id+".":"Room effect not found."));return;}usage(sender);}
private void reward(CommandSender sender,String[] args){if(args.length<3||args.length>5){usage(sender);return;}UUID dungeon=id(args[2]);var current=dungeon==null?null:directory.find(dungeon).orElse(null);if(current==null){sender.sendMessage("Dungeon not found.");return;}if(args.length==3){sender.sendMessage(current.rewardConfigured()?"Dungeon "+dungeon+" completion reward is "+current.completionReward().xp()+" XP and "+current.completionReward().coins()+" coins.":"Dungeon "+dungeon+" completion reward is not configured.");return;}if(current.enabled()||adventures.offerOpen(dungeon)||adventures.active().stream().anyMatch(quest->quest.site().equals(dungeon))){sender.sendMessage("Dungeon reward update requires disabled, closed dungeon state without accepted quests.");return;}if(controller.beingEdited(dungeon)&&(!(sender instanceof Player player)||!controller.editing(player.getUniqueId()).filter(dungeon::equals).isPresent())){sender.sendMessage("Dungeon reward update refused: another administrator owns the editing lease.");return;}BigInteger xp=args.length==5?whole(args[3]):null,coins=args.length==5?whole(args[4]):null;if(xp==null||coins==null){usage(sender);return;}directory.setCompletionReward(dungeon,xp,coins).whenComplete((value,failure)->saved(sender,failure,"Set dungeon completion reward to "+xp+" XP and "+coins+" coins."));}
private void boss(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length==5&&args[3].equals("assign-sheet")){assignCharacterBoss(sender,player,dungeon,room,args[4]);return;}if(args.length>=5&&args[3].equals("mob")){var type=entity(args[4]);if(!ordinary(type)){sender.sendMessage("Unsupported living boss type.");return;}var location=player.getLocation();directory.setBoss(dungeon,room.id(),type,join(args,5),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->saved(sender,failure,"Configured native boss "+type.name().toLowerCase(Locale.ROOT)+"."));return;}if(args.length==4&&args[3].equals("clear")){directory.clearBoss(dungeon,room.id()).whenComplete((value,failure)->saved(sender,failure,"Cleared the room boss."));return;}usage(sender);}
private void assignCharacterBoss(CommandSender sender,Player player,UUID dungeon,DungeonState.Room room,String rawMultiplier){BigDecimal multiplier;try{if(!rawMultiplier.matches("(?:0|[1-9][0-9]*)(?:\\.[0-9]{1,3})?"))throw new NumberFormatException();multiplier=new BigDecimal(rawMultiplier);}catch(NumberFormatException invalid){usage(sender);return;}var held=player.getInventory().getItemInMainHand();var sheetIdentity=sheets.identity(held).orElse(null);var character=sheetIdentity==null?null:sheetIdentity.character();if(character==null||held.getAmount()!=1){sender.sendMessage("Hold exactly one authentic living character sheet in your main hand.");return;}if(physicalCopies(character)!=1){sender.sendMessage("Character boss assignment requires one unambiguous physical sheet.");return;}if(activeCharacter.test(character)){sender.sendMessage("That character has an active equipment session.");return;}if(partyCharacter.test(character)){sender.sendMessage("That character must leave its party before boss assignment.");return;}characters.apply(character).whenComplete((loaded,failure)->{if(failure!=null||loaded==null||loaded.isEmpty()||loaded.orElseThrow().dead()||loaded.orElseThrow().health().signum()<=0){respond(sender,()->sender.sendMessage("Character boss assignment refused: the saved living character could not be verified."));return;}var expected=loaded.orElseThrow();if(!sheetIdentity.generation().equals(expected.sheetGeneration())||(sheetIdentity.legacy()&&expected.sheetGeneration().signum()!=0)){respond(sender,()->sender.sendMessage("Character boss assignment refused: that sheet generation is no longer authorized."));return;}directory.assignCharacterBoss(dungeon,room.id(),expected,multiplier).whenComplete((custody,saveFailure)->{if(saveFailure!=null){respond(sender,()->sender.sendMessage("Character boss assignment failed before sheet custody changed."));return;}characters.apply(character).whenComplete((fresh,readFailure)->respond(sender,()->{if(readFailure!=null||fresh==null||fresh.isEmpty()||!fresh.orElseThrow().equals(expected)||activeCharacter.test(character)||partyCharacter.test(character)){directory.cancelAssignment(dungeon,custody.operation()).whenComplete((ignored,rollbackFailure)->respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the saved character changed.":"Character boss custody was saved but assignment rollback could not be confirmed; recover it before editing.")));return;}var current=player.getInventory().getItemInMainHand();if(!player.isOnline()||physicalCopies(character)!=1||current.getAmount()!=1||!sheets.authorizes(current,expected)){directory.cancelAssignment(dungeon,custody.operation()).whenComplete((ignored,rollbackFailure)->respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the held sheet changed.":"Character boss custody was saved but sheet consumption could not be confirmed; recover it before editing.")));return;}player.getInventory().setItemInMainHand(null);sender.sendMessage("Assigned character "+custody.character().name()+" as the dungeon boss at "+custody.multiplier().toPlainString()+"x stats; its sheet is now in durable custody.");}));});});}
private void mob(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length>=5&&args[3].equals("add")){var type=entity(args[4]);if(!ordinary(type)){sender.sendMessage("Unsupported living mob type.");return;}var location=player.getLocation();observedSave(sender,"administrator_mob_add",()->directory.addMob(dungeon,room.id(),type,join(args,5),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch())).whenComplete((value,failure)->saved(sender,failure,"Added mob slot "+(value==null?"":value.id())+"."));return;}if(args.length==5&&args[3].equals("remove")){UUID id=id(args[4]);if(id==null){usage(sender);return;}observedSave(sender,"administrator_mob_remove",()->directory.removeMob(dungeon,room.id(),id)).whenComplete((value,failure)->saved(sender,failure,value!=null&&value.isPresent()?"Removed mob slot "+id+".":"Mob slot not found."));return;}if(args.length==4&&args[3].equals("list")){if(room.mobs().isEmpty())sender.sendMessage("No ordinary mob slots in this room.");else room.mobs().forEach(value->sender.sendMessage(value.id()+" "+value.type().name().toLowerCase(Locale.ROOT)+" "+value.name()));return;}usage(sender);}
private void effect(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length>=7&&args[3].equals("add")){DungeonState.EffectTarget target;try{target=DungeonState.EffectTarget.valueOf(args[4].toUpperCase(Locale.ROOT));}catch(IllegalArgumentException invalid){usage(sender);return;}String key=args[5].contains(":")?args[5]:"minecraft:"+args[5];var namespaced=NamespacedKey.fromString(key);Integer level=integer(args[6]);boolean particles=args.length<8||Boolean.parseBoolean(args[7]);if(namespaced==null||PotionEffectType.getByKey(namespaced)==null||level==null||args.length>8||args.length==8&&!Set.of("true","false").contains(args[7])){usage(sender);return;}observedSave(sender,"administrator_effect_add",()->directory.addEffect(dungeon,room.id(),target,key,level,particles)).whenComplete((value,failure)->saved(sender,failure,"Added room effect "+(value==null?"":value.id())+"."));return;}if(args.length==5&&args[3].equals("remove")){UUID id=id(args[4]);if(id==null){usage(sender);return;}observedSave(sender,"administrator_effect_remove",()->directory.removeEffect(dungeon,room.id(),id)).whenComplete((value,failure)->saved(sender,failure,value!=null&&value.isPresent()?"Removed room effect "+id+".":"Room effect not found."));return;}usage(sender);}
private void reward(CommandSender sender,String[] args){if(args.length<3||args.length>5){usage(sender);return;}UUID dungeon=id(args[2]);var current=dungeon==null?null:directory.find(dungeon).orElse(null);if(current==null){sender.sendMessage("Dungeon not found.");return;}if(args.length==3){sender.sendMessage(current.rewardConfigured()?"Dungeon "+dungeon+" completion reward is "+current.completionReward().xp()+" XP and "+current.completionReward().coins()+" coins.":"Dungeon "+dungeon+" completion reward is not configured.");return;}if(current.enabled()||adventures.offerOpen(dungeon)||adventures.active().stream().anyMatch(quest->quest.site().equals(dungeon))){sender.sendMessage("Dungeon reward update requires disabled, closed dungeon state without accepted quests.");return;}if(controller.beingEdited(dungeon)&&(!(sender instanceof Player player)||!controller.editing(player.getUniqueId()).filter(dungeon::equals).isPresent())){sender.sendMessage("Dungeon reward update refused: another administrator owns the editing lease.");return;}BigInteger xp=args.length==5?whole(args[3]):null,coins=args.length==5?whole(args[4]):null;if(xp==null||coins==null){usage(sender);return;}var audit=auditEvents;var actor=audit==null?null:audit.actor(sender);var operation=UUID.randomUUID();directory.setCompletionRewardWithEvidence(dungeon,xp,coins).whenComplete((change,failure)->{if(failure==null&&audit!=null)try{audit.rewardChanged(operation,actor,change);}catch(RuntimeException ignored){}saved(sender,failure,"Set dungeon completion reward to "+xp+" XP and "+coins+" coins.");});}
private void boss(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length==5&&args[3].equals("assign-sheet")){assignCharacterBoss(sender,player,dungeon,room,args[4]);return;}if(args.length>=5&&args[3].equals("mob")){var type=entity(args[4]);if(!ordinary(type)){sender.sendMessage("Unsupported living boss type.");return;}var location=player.getLocation();observedSave(sender,"administrator_native_boss",()->directory.setBoss(dungeon,room.id(),type,join(args,5),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch())).whenComplete((value,failure)->saved(sender,failure,"Configured native boss "+type.name().toLowerCase(Locale.ROOT)+"."));return;}if(args.length==4&&args[3].equals("clear")){observedSave(sender,"administrator_native_boss_clear",()->directory.clearBoss(dungeon,room.id())).whenComplete((value,failure)->saved(sender,failure,"Cleared the room boss."));return;}usage(sender);}
private void assignCharacterBoss(CommandSender sender,Player player,UUID dungeon,DungeonState.Room room,String rawMultiplier){BigDecimal multiplier;try{if(!rawMultiplier.matches("(?:0|[1-9][0-9]*)(?:\\.[0-9]{1,3})?"))throw new NumberFormatException();multiplier=new BigDecimal(rawMultiplier);}catch(NumberFormatException invalid){usage(sender);return;}var held=player.getInventory().getItemInMainHand();var sheetIdentity=sheets.identity(held).orElse(null);var character=sheetIdentity==null?null:sheetIdentity.character();if(character==null||held.getAmount()!=1){sender.sendMessage("Hold exactly one authentic living character sheet in your main hand.");return;}if(physicalCopies(character)!=1){sender.sendMessage("Character boss assignment requires one unambiguous physical sheet.");return;}if(activeCharacter.test(character)){sender.sendMessage("That character has an active equipment session.");return;}if(partyCharacter.test(character)){sender.sendMessage("That character must leave its party before boss assignment.");return;}var audit=characterBossEvents;var request=audit==null?null:audit.request(sender);characters.apply(character).whenComplete((loaded,failure)->{if(failure!=null||loaded==null||loaded.isEmpty()||loaded.orElseThrow().dead()||loaded.orElseThrow().health().signum()<=0){respond(sender,()->sender.sendMessage("Character boss assignment refused: the saved living character could not be verified."));return;}var expected=loaded.orElseThrow();if(!sheetIdentity.generation().equals(expected.sheetGeneration())||(sheetIdentity.legacy()&&expected.sheetGeneration().signum()!=0)){respond(sender,()->sender.sendMessage("Character boss assignment refused: that sheet generation is no longer authorized."));return;}var assignment=directory.assignCharacterBoss(dungeon,room.id(),expected,multiplier);assignment.whenComplete((custody,saveFailure)->{if(saveFailure!=null){respond(sender,()->sender.sendMessage("Character boss assignment failed before sheet custody changed."));return;}if(audit!=null)try{DungeonDirectory.publication(assignment).ifPresent(publication->audit.custodySaved(audit.assignment(request,publication,dungeon,room.id(),custody)));}catch(RuntimeException ignored){/* Saved custody observation never gates native revalidation or consumption. */}characters.apply(character).whenComplete((fresh,readFailure)->respond(sender,()->{if(readFailure!=null||fresh==null||fresh.isEmpty()||!fresh.orElseThrow().equals(expected)||activeCharacter.test(character)||partyCharacter.test(character)){var rollback=directory.cancelAssignment(dungeon,custody.operation());rollback.whenComplete((applied,rollbackFailure)->{if(audit!=null)try{DungeonDirectory.publication(assignment).ifPresent(publication->audit.rolledBack(audit.assignment(request,publication,dungeon,room.id(),custody),rollbackFailure==null&&Boolean.TRUE.equals(applied),DungeonDirectory.publication(rollback).orElse(null),"saved_character_changed"));}catch(RuntimeException ignored){/* Original rollback observation cannot change character/custody authority. */}respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the saved character changed.":"Character boss custody was saved but assignment rollback could not be confirmed; recover it before editing."));});return;}var current=player.getInventory().getItemInMainHand();if(!player.isOnline()||physicalCopies(character)!=1||current.getAmount()!=1||!sheets.authorizes(current,expected)){var rollback=directory.cancelAssignment(dungeon,custody.operation());rollback.whenComplete((applied,rollbackFailure)->{if(audit!=null)try{DungeonDirectory.publication(assignment).ifPresent(publication->audit.rolledBack(audit.assignment(request,publication,dungeon,room.id(),custody),rollbackFailure==null&&Boolean.TRUE.equals(applied),DungeonDirectory.publication(rollback).orElse(null),"held_sheet_changed"));}catch(RuntimeException ignored){/* Original rollback observation cannot change inventory/custody authority. */}respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the held sheet changed.":"Character boss custody was saved but sheet consumption could not be confirmed; recover it before editing."));});return;}player.getInventory().setItemInMainHand(null);if(audit!=null)try{DungeonDirectory.publication(assignment).ifPresent(publication->audit.assigned(audit.assignment(request,publication,dungeon,room.id(),custody)));}catch(RuntimeException ignored){/* Optional observation cannot interrupt physical consumption. */}sender.sendMessage("Assigned character "+custody.character().name()+" as the dungeon boss at "+custody.multiplier().toPlainString()+"x stats; its sheet is now in durable custody.");}));});});}
private void bossCommand(CommandSender sender,String[] args){if(args.length!=4||!args[2].equals("recover-sheet")||!(sender instanceof Player player)){usage(sender);return;}UUID dungeon=id(args[3]);if(dungeon==null){usage(sender);return;}recoverCharacterBoss(sender,player,dungeon);}
private void recoverCharacterBoss(CommandSender sender,Player player,UUID dungeon){var definition=directory.find(dungeon).orElse(null);var custody=directory.custodyForDungeon(dungeon).orElse(null);if(definition==null||custody==null){sender.sendMessage("Dungeon character boss custody was not found.");return;}var catalog=adventures.snapshot().catalog();boolean offered=catalog!=null&&adventures.offerOpen(dungeon);boolean accepted=adventures.active().stream().anyMatch(quest->quest.site().equals(dungeon));if(definition.enabled()||offered||accepted||controller.beingEdited(dungeon)||controller.editing(player.getUniqueId()).isPresent()||controller.occupied(dungeon)){sender.sendMessage("Recovery requires a disabled, closed, unedited and unoccupied dungeon with no accepted quests.");return;}var physical=plugin.getServer().getOnlinePlayers().stream().flatMap(holder->Arrays.stream(holder.getInventory().getContents()).filter(Objects::nonNull).filter(item->sheets.identify(item).filter(custody.character().id()::equals).isPresent()).map(item->Map.entry(holder,item))).toList();if(physical.size()>1||physical.size()==1&&physical.getFirst().getKey()!=player){sender.sendMessage("Recovery refused because existing physical-sheet custody is ambiguous.");return;}boolean delivered=!physical.isEmpty()||custody.recovery()!=null&&Arrays.stream(player.getInventory().getContents()).anyMatch(item->sheets.hasRecovery(item,custody.recovery().operation()));if(!delivered&&player.getInventory().firstEmpty()<0){sender.sendMessage("One empty inventory slot is required before recovery.");return;}directory.prepareRecovery(dungeon,player.getUniqueId()).whenComplete((prepared,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Character boss recovery could not be prepared; custody is retained.");return;}var operation=prepared.recovery().operation();org.bukkit.inventory.ItemStack sheet=null;for(var item:player.getInventory().getContents())if(sheets.hasRecovery(item,operation)||sheets.identify(item).filter(prepared.character().id()::equals).isPresent()){sheet=item;break;}if(sheet==null){int slot=player.getInventory().firstEmpty();if(slot<0){sender.sendMessage("Character boss recovery is prepared, but one empty inventory slot is required.");return;}sheet=sheets.create(prepared.character());sheets.markRecovery(sheet,operation);player.getInventory().setItem(slot,sheet);}var item=sheet;directory.completeRecovery(dungeon,operation).whenComplete((completed,completeFailure)->respond(sender,()->{if(completeFailure!=null||!completed){sender.sendMessage("Recovery sheet delivery is retained, but durable custody completion must be retried.");return;}sheets.clearRecovery(item);sender.sendMessage("Recovered character sheet for "+prepared.character().name()+" from dungeon "+dungeon+"; configure another boss before enabling it.");}));}));}
private void recoverCharacterBoss(CommandSender sender,Player player,UUID dungeon){var definition=directory.find(dungeon).orElse(null);var custody=directory.custodyForDungeon(dungeon).orElse(null);if(definition==null||custody==null){sender.sendMessage("Dungeon character boss custody was not found.");return;}var catalog=adventures.snapshot().catalog();boolean offered=catalog!=null&&adventures.offerOpen(dungeon);boolean accepted=adventures.active().stream().anyMatch(quest->quest.site().equals(dungeon));if(definition.enabled()||offered||accepted||controller.beingEdited(dungeon)||controller.editing(player.getUniqueId()).isPresent()||controller.occupied(dungeon)){sender.sendMessage("Recovery requires a disabled, closed, unedited and unoccupied dungeon with no accepted quests.");return;}var physical=plugin.getServer().getOnlinePlayers().stream().flatMap(holder->Arrays.stream(holder.getInventory().getContents()).filter(Objects::nonNull).filter(item->sheets.identify(item).filter(custody.character().id()::equals).isPresent()).map(item->Map.entry(holder,item))).toList();if(physical.size()>1||physical.size()==1&&physical.getFirst().getKey()!=player){sender.sendMessage("Recovery refused because existing physical-sheet custody is ambiguous.");return;}boolean delivered=!physical.isEmpty()||custody.recovery()!=null&&Arrays.stream(player.getInventory().getContents()).anyMatch(item->sheets.hasRecovery(item,custody.recovery().operation()));if(!delivered&&player.getInventory().firstEmpty()<0){sender.sendMessage("One empty inventory slot is required before recovery.");return;}var audit=characterBossEvents;var request=audit==null?null:audit.request(sender);var preparation=directory.prepareRecovery(dungeon,player.getUniqueId());preparation.whenComplete((prepared,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Character boss recovery could not be prepared; custody is retained.");return;}var operation=prepared.recovery().operation();org.bukkit.inventory.ItemStack sheet=null;for(var item:player.getInventory().getContents())if(sheets.hasRecovery(item,operation)||sheets.identify(item).filter(prepared.character().id()::equals).isPresent()){sheet=item;break;}if(sheet==null){int slot=player.getInventory().firstEmpty();if(slot<0){sender.sendMessage("Character boss recovery is prepared, but one empty inventory slot is required.");return;}sheet=sheets.create(prepared.character());sheets.markRecovery(sheet,operation);player.getInventory().setItem(slot,sheet);}var item=sheet;games.dmg.fantasystick.events.CharacterBossEventBridge.Facts deliveryFacts=null;if(audit!=null)try{var view=DungeonDirectory.publication(preparation).flatMap(publication->publication.after().dungeons().stream().filter(value->value.id().equals(dungeon)).findFirst()).orElse(definition);var originalRoom=view.rooms().stream().filter(value->value.boss()!=null&&value.boss().character()!=null&&value.boss().character().operation().equals(prepared.operation())).findFirst().orElseThrow();deliveryFacts=audit.custody(request,view,originalRoom.id(),prepared);}catch(RuntimeException ignored){/* Delivery does not depend on optional metadata. */}var facts=deliveryFacts;var completion=directory.completeRecovery(dungeon,operation);completion.whenComplete((completed,completeFailure)->{if(completeFailure==null&&Boolean.TRUE.equals(completed)&&facts!=null)try{DungeonDirectory.publication(completion).ifPresent(publication->audit.recovered(facts,operation,publication,"exact_authorized_sheet_delivered"));}catch(RuntimeException ignored){/* Durable completion stays authoritative independently of feedback. */}if(facts!=null&&(completeFailure!=null||!Boolean.TRUE.equals(completed)))try{audit.recoveryPending(facts,operation,completeFailure!=null?"failed_final_source_save":"completion_not_acknowledged");}catch(RuntimeException ignored){/* Pending recovery observation cannot change custody or native delivery. */}respond(sender,()->{if(completeFailure!=null||!completed){sender.sendMessage("Recovery sheet delivery is retained, but durable custody completion must be retried.");return;}sheets.clearRecovery(item);sender.sendMessage("Recovered character sheet for "+prepared.character().name()+" from dungeon "+dungeon+"; configure another boss before enabling it.");});});}));}
private void teleport(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}if(!(sender instanceof Player)){sender.sendMessage("Dungeon teleport requires a player.");return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}var dungeon=directory.find(id).orElse(null);if(dungeon==null){sender.sendMessage("Dungeon not found.");return;}teleports.teleport(sender,"Dungeon",target(dungeon),()->directory.find(id).map(DungeonCommands::target));}
private static AdventureAdminTeleport.Target target(DungeonState.Dungeon dungeon){var entrance=dungeon.entrance();return new AdventureAdminTeleport.Target(dungeon.id(),dungeon.name(),"dungeon entrance",dungeon.world(),entrance.x(),entrance.y(),entrance.z(),entrance.yaw(),entrance.pitch(),false);}
private void enable(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.enable(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Enabled dungeon "+id+" for later adventure catalogs.":"Dungeon enable refused: validate completion reward, rooms, boss, overlap and persistence.")));}
private void disable(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.disable(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure!=null?"Dungeon disable failed; retained state is authoritative.":value?"Disabled and closed dungeon "+id+".":"Dungeon not found.")));}
private void open(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.open(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Opened dungeon "+id+" in generation "+value.revision()+".":"Dungeon open refused: it is invalid, disabled, defeated or the offer limit was reached.")));}
private void close(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.closeOffer(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure!=null?"Dungeon close failed; retained state is authoritative.":value?"Closed dungeon "+id+" for this generation.":"Dungeon was not open.")));}
private void enable(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}observedSave(sender,"administrator_enable",()->controller.enable(id)).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Enabled dungeon "+id+" for later adventure catalogs.":"Dungeon enable refused: validate completion reward, rooms, boss, overlap and persistence.")));}
private void disable(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}var request=request(sender);controller.disableWithEvidence(id,publication->{if(request.events()!=null)try{request.events().definitionSaved(request.operation(),request.actor(),publication,"administrator_disable_saved");}catch(RuntimeException ignored){}}).whenComplete((value,failure)->{respond(sender,()->sender.sendMessage(failure!=null?"Dungeon disable failed; retained state is authoritative.":value.applied()?"Disabled and closed dungeon "+id+".":"Dungeon not found."));});}
private void open(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}var request=request(sender);controller.openWithEvidence(id).whenComplete((value,failure)->{if(failure==null&&value.publication().changed()&&request.events()!=null)try{request.events().offer("dungeon.offer.opened",request.operation(),request.actor(),value.dungeon(),value.publication().after(),"administrator_open");}catch(RuntimeException ignored){}respond(sender,()->sender.sendMessage(failure==null?"Opened dungeon "+id+" in generation "+value.publication().after().revision()+".":"Dungeon open refused: it is invalid, disabled, defeated or the offer limit was reached."));});}
private void close(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}var request=request(sender);var definition=directory.find(id).orElse(null);var catalog=adventures.snapshot().catalog();controller.closeOffer(id).whenComplete((value,failure)->{if(failure==null&&Boolean.TRUE.equals(value)&&definition!=null&&catalog!=null&&request.events()!=null)try{request.events().offer("dungeon.offer.closed",request.operation(),request.actor(),definition,catalog,"administrator_close");}catch(RuntimeException ignored){}respond(sender,()->sender.sendMessage(failure!=null?"Dungeon close failed; retained state is authoritative.":value?"Closed dungeon "+id+" for this generation.":"Dungeon was not open."));});}
private record AuditRequest(games.dmg.fantasystick.events.DungeonAuditEventBridge events,games.dmg.fantasystick.events.CampEventBridge.Actor actor,UUID operation){}
private AuditRequest request(CommandSender sender){var events=ownerEvents;return new AuditRequest(events,events==null?null:events.actor(sender),UUID.randomUUID());}
private <T>java.util.concurrent.CompletionStage<T> observedSave(CommandSender sender,String reason,java.util.function.Supplier<? extends java.util.concurrent.CompletionStage<T>> work){return observedSave(request(sender),reason,work);}
private <T>java.util.concurrent.CompletionStage<T> observedSave(AuditRequest request,String reason,java.util.function.Supplier<? extends java.util.concurrent.CompletionStage<T>> work){var source=work.get();return source.whenComplete((value,failure)->{if(failure==null&&request.events()!=null)try{DungeonDirectory.publication(source).ifPresent(publication->request.events().definitionSaved(request.operation(),request.actor(),publication,reason));}catch(RuntimeException ignored){/* Audit is never authoring authority. */}});}
public List<String> complete(CommandSender sender,String[] args){if(!sender.hasPermission(PERMISSION))return List.of();if(args.length==1)return matching(List.of("dungeon"),args[0]);if(args.length==2&&args[0].equals("dungeon"))return matching(sender instanceof Player?List.of("boss","close","create","disable","edit","enable","entrance","list","mode","open","remove","reward","room","teleport"):List.of("close","disable","enable","list","mode","open","remove","reward"),args[1]);if(!args[0].equals("dungeon"))return List.of();if(args.length==3&&args[1].equals("boss"))return matching(List.of("recover-sheet"),args[2]);if(args.length==4&&args[1].equals("boss")&&args[2].equals("recover-sheet"))return matching(directory.snapshot().dungeons().stream().filter(value->recoverable(value.id())).map(value->value.id().toString()).toList(),args[3]);if(args.length==3&&args[1].equals("create"))return matching(List.of("--mode"),args[2]);if(args.length==4&&args[1].equals("create")&&args[2].equals("--mode"))return matching(modeValues(),args[3]);if(args.length==3&&Set.of("close","disable","enable","mode","open","remove","reward","teleport").contains(args[1]))return matching(directory.snapshot().dungeons().stream().map(v->v.id().toString()).toList(),args[2]);if(args.length==4&&args[1].equals("mode"))return matching(modeValues(),args[3]);if((args.length==4||args.length==5)&&args[1].equals("reward")){UUID dungeon=id(args[2]);var current=dungeon==null?null:directory.find(dungeon).orElse(null);if(current==null)return List.of();var values=new ArrayList<>(List.of("0","10","100","2147483647"));values.add((args.length==4?current.completionReward().xp():current.completionReward().coins()).toString());return matching(values,args[args.length-1]);}if(args.length==4&&Set.of("close","disable","remove").contains(args[1]))return matching(List.of("confirm"),args[3]);if(args.length==3&&args[1].equals("edit"))return matching(List.of("done","start"),args[2]);if(args.length==4&&args[1].equals("edit")&&args[2].equals("start"))return matching(directory.snapshot().dungeons().stream().map(v->v.id().toString()).toList(),args[3]);if(args.length==5&&args[1].equals("edit")&&args[2].equals("start"))return matching(List.of("confirm"),args[4]);if(args.length==3&&args[1].equals("entrance"))return matching(List.of("set"),args[2]);if(args.length==3&&args[1].equals("room"))return matching(List.of("boss","create","effect","list","mob","remove"),args[2]);if(args.length==4&&args[1].equals("room")&&args[2].equals("create"))return matching(List.of("corner","radius"),args[3]);if(args.length>=5&&args.length<=7&&args[1].equals("room")&&args[2].equals("create"))return matching(List.of("1","3","8","128","256"),args[args.length-1]);if(args.length==4&&args[1].equals("room")&&args[2].equals("remove")&&sender instanceof Player player){var id=controller.editing(player.getUniqueId()).orElse(null);return id==null?List.of():matching(directory.find(id).map(DungeonState.Dungeon::rooms).orElse(List.of()).stream().map(v->v.id().toString()).toList(),args[3]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("mob"))return matching(List.of("add","list","remove"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("mob")&&args[3].equals("add"))return matching(entityNames(),args[4]);if(args.length==5&&args[1].equals("room")&&args[2].equals("mob")&&args[3].equals("remove")&&sender instanceof Player player){var room=controller.standingRoom(player).orElse(null);return room==null?List.of():matching(room.mobs().stream().map(v->v.id().toString()).toList(),args[4]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("effect"))return matching(List.of("add","remove"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("both","mobs","players"),args[4]);if(args.length==6&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(effectNames(),args[5]);if(args.length==7&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("1","2","64","256"),args[6]);if(args.length==8&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("false","true"),args[7]);if(args.length==5&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("remove")&&sender instanceof Player player){var room=controller.standingRoom(player).orElse(null);return room==null?List.of():matching(room.effects().stream().map(v->v.id().toString()).toList(),args[4]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("boss"))return matching(List.of("assign-sheet","clear","mob"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("boss")&&args[3].equals("mob"))return matching(entityNames(),args[4]);if(args.length==5&&args[1].equals("room")&&args[2].equals("boss")&&args[3].equals("assign-sheet"))return matching(List.of("0.1","1","1.5","10","100"),args[4]);return List.of();}
private long physicalCopies(UUID character){return plugin.getServer().getOnlinePlayers().stream().flatMap(holder->Arrays.stream(holder.getInventory().getContents())).filter(Objects::nonNull).filter(item->sheets.identify(item).filter(character::equals).isPresent()).mapToLong(org.bukkit.inventory.ItemStack::getAmount).sum();}
private boolean recoverable(UUID dungeon){var definition=directory.find(dungeon).orElse(null);var catalog=adventures.snapshot().catalog();return definition!=null&&!definition.enabled()&&directory.custodyForDungeon(dungeon).isPresent()&&!controller.beingEdited(dungeon)&&!controller.occupied(dungeon)&&(catalog==null||!adventures.offerOpen(dungeon))&&adventures.active().stream().noneMatch(quest->quest.site().equals(dungeon));}
@@ -32,45 +32,89 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
private record Edit(UUID dungeon,Map<UUID,Color> colors){}
private static final String ENEMY="dungeon_enemy",PROJECTILE="dungeon_projectile";
private record AttemptKey(UUID dungeon,UUID room){}
private record PendingKill(UUID dungeon,UUID room,UUID generation,UUID slot){}
private static final class Attempt {final UUID dungeon,room,generation;final Map<UUID,UUID> entities=new HashMap<>();final Set<UUID> represented=new HashSet<>();final Map<UUID,Double> threat=new HashMap<>();final Map<String,Long> cooldowns=new HashMap<>();final Map<String,Long> effects=new HashMap<>();UUID boss,target;long lastCombat=System.nanoTime();Attempt(UUID dungeon,UUID room,UUID generation){this.dungeon=dungeon;this.room=room;this.generation=generation;}}
private games.dmg.fantasystick.events.DungeonAuditEventBridge ownerEvents;
private games.dmg.fantasystick.events.CharacterBossEventBridge characterBossEvents;
private final Map<UUID,games.dmg.fantasystick.events.CharacterBossEventBridge.Facts> characterBossShellFacts=new HashMap<>();
private CharacterBossEffectAudit characterBossEffects;
private record ObservedCharacterDamage(games.dmg.fantasystick.events.CharacterBossEventBridge.Facts facts,EntityDamageByEntityEvent event){}
private static final class CharacterCast {
final games.dmg.fantasystick.events.CharacterBossEventBridge.Facts facts;final UUID shell;final List<ObservedCharacterDamage> damage=new ArrayList<>();final List<CharacterBossEffectAudit.NativeCall> effects=new ArrayList<>();String nativeOutcome;
CharacterCast(games.dmg.fantasystick.events.CharacterBossEventBridge.Facts facts,UUID shell){this.facts=facts;this.shell=shell;}
}
private final ThreadLocal<Deque<CharacterCast>> characterCasts=ThreadLocal.withInitial(ArrayDeque::new);
private record CharacterPotion(CharacterCast cast,games.dmg.fantasystick.events.CharacterBossEventBridge.Facts facts,LivingEntity target,PotionEffect requested,PotionEffect prior,List<EntityPotionEffectEvent> nativeEvents){}
private final ThreadLocal<Deque<CharacterPotion>> characterPotions=ThreadLocal.withInitial(ArrayDeque::new);
private record NativeKnockback(org.bukkit.event.entity.EntityKnockbackEvent event) {}
private final ThreadLocal<List<NativeKnockback>> characterMeleeKnockback=new ThreadLocal<>();
@EventHandler(priority=EventPriority.MONITOR)public void characterNativeKnockback(org.bukkit.event.entity.EntityKnockbackEvent event){
try{var calls=characterMeleeKnockback.get();if(calls!=null)calls.add(new NativeKnockback(event));}catch(RuntimeException unavailable){/* Native knockback remains authoritative. */}
}
public void attachCharacterBossEvents(games.dmg.fantasystick.events.CharacterBossEventBridge events){characterBossEvents=Objects.requireNonNull(events);characterBossEffects=new CharacterBossEffectAudit(events,nextTick);}
public void attachOwnerEvents(games.dmg.fantasystick.events.DungeonAuditEventBridge events){ownerEvents=Objects.requireNonNull(events);}
private record PendingKill(UUID entity,UUID dungeon,UUID room,UUID generation,UUID slot,games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts observation){}
private static final class Attempt {final UUID dungeon,room,generation;final Map<UUID,UUID> entities=new HashMap<>();final Set<UUID> represented=new HashSet<>();final Map<UUID,Double> threat=new HashMap<>();final Map<String,Long> cooldowns=new HashMap<>();final Map<String,Long> effects=new HashMap<>();UUID boss,target,defeatedBoss;games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts observation,deathObservation;games.dmg.fantasystick.events.CharacterBossEventBridge.Facts characterDeathObservation;Map<String,String> defeatAudit=Map.of();long lastCombat=System.nanoTime();Attempt(UUID dungeon,UUID room,UUID generation){this.dungeon=dungeon;this.room=room;this.generation=generation;}}
private record Context(UUID character,UUID party,Set<UUID> members){}
private record OwnedEffect(UUID dungeon,UUID player,PotionEffectType type,int amplifier,PotionEffect prior){}
private final Plugin plugin;private final DungeonDirectory dungeons;private final AdventureQuestDirectory adventures;private final EquipmentSessions sessions;private final PartyRoster parties;private final CharacterStorage characters;private final ProgressionRules progression;private final Consumer<Runnable> nextTick;private final java.util.random.RandomGenerator random;private final AbilityRegistry abilities;private final PassiveRegistry passives;private final java.util.function.Supplier<games.dmg.fantasystick.session.OutOfCombatRecovery.Settings> recoverySettings;private final java.util.function.Function<games.dmg.fantasystick.persistence.CharacterRecord,BigInteger> recoveryBonus;private final java.util.function.Supplier<BigInteger> healthStealHealing;private final games.dmg.fantasystick.presentation.ParticleDelivery particles;private FishingController fishing;
private record OwnedEffect(UUID dungeon,UUID player,PotionEffectType type,int amplifier,PotionEffect prior,games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts observation){}
private final Plugin plugin;private final DungeonDirectory dungeons;private final AdventureQuestDirectory adventures;private final EquipmentSessions sessions;private final PartyRoster parties;private final CharacterStorage characters;private final ProgressionRules progression;private final Consumer<Runnable> nextTick;private final java.util.random.RandomGenerator random;private final AbilityRegistry abilities;private final PassiveRegistry passives;private final java.util.function.Supplier<games.dmg.fantasystick.session.OutOfCombatRecovery.Settings> recoverySettings;private final java.util.function.Function<games.dmg.fantasystick.persistence.CharacterRecord,BigInteger> recoveryBonus;private final java.util.function.Supplier<BigInteger> healthStealHealing;private final games.dmg.fantasystick.presentation.ParticleDelivery particles;private FishingController fishing;private games.dmg.fantasystick.events.DungeonEventBridge auditEvents;
private final NamespacedKey typeKey,dungeonKey,roomKey,slotKey,generationKey,bossKey;
private final Map<UUID,Edit> playerEdits=new HashMap<>();private final Map<UUID,UUID> dungeonEditors=new HashMap<>();private final Map<AttemptKey,Attempt> attempts=new HashMap<>();private final Set<String> startingRuns=new HashSet<>();private final Set<AttemptKey> completing=new HashSet<>();private final Set<UUID> defeatingDungeons=new HashSet<>();private final Set<CompletableFuture<?>> inFlight=ConcurrentHashMap.newKeySet();private final Map<String,OwnedEffect> ownedEffects=new HashMap<>();private final Map<UUID,PendingKill> pendingKills=new ConcurrentHashMap<>();private final Set<Entity> relocating=Collections.newSetFromMap(new IdentityHashMap<>());private org.bukkit.scheduler.BukkitTask pulse;private boolean closed;private long ticks=9;
private final Map<UUID,Edit> playerEdits=new HashMap<>();private final Map<UUID,UUID> dungeonEditors=new HashMap<>();private final Map<AttemptKey,Attempt> attempts=new HashMap<>();private final Set<String> startingRuns=new HashSet<>();private final Set<AttemptKey> completing=new HashSet<>();private final Set<UUID> defeatingDungeons=new HashSet<>();private final Set<CompletableFuture<?>> inFlight=ConcurrentHashMap.newKeySet();private final Map<String,OwnedEffect> ownedEffects=new HashMap<>();private final Map<UUID,PendingKill> pendingKills=new ConcurrentHashMap<>();private final Map<UUID,UUID> pendingBosses=new ConcurrentHashMap<>();private final Set<Entity> relocating=Collections.newSetFromMap(new IdentityHashMap<>());private org.bukkit.scheduler.BukkitTask pulse;private boolean closed;private long ticks=9;
public DungeonController(Plugin plugin,DungeonDirectory dungeons,AdventureQuestDirectory adventures,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random){this(plugin,dungeons,adventures,sessions,parties,characters,progression,nextTick,random,new AbilityRegistry(),new PassiveRegistry(),games.dmg.fantasystick.session.OutOfCombatRecovery.Settings::defaults,ignored->BigInteger.ZERO,()->BigInteger.ONE);}
public DungeonController(Plugin plugin,DungeonDirectory dungeons,AdventureQuestDirectory adventures,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random,AbilityRegistry abilities,PassiveRegistry passives){this(plugin,dungeons,adventures,sessions,parties,characters,progression,nextTick,random,abilities,passives,games.dmg.fantasystick.session.OutOfCombatRecovery.Settings::defaults,ignored->BigInteger.ZERO,()->BigInteger.ONE);}
public DungeonController(Plugin plugin,DungeonDirectory dungeons,AdventureQuestDirectory adventures,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random,AbilityRegistry abilities,PassiveRegistry passives,java.util.function.Supplier<games.dmg.fantasystick.session.OutOfCombatRecovery.Settings> recoverySettings,java.util.function.Function<games.dmg.fantasystick.persistence.CharacterRecord,BigInteger> recoveryBonus,java.util.function.Supplier<BigInteger> healthStealHealing){this(plugin,dungeons,adventures,sessions,parties,characters,progression,nextTick,random,abilities,passives,recoverySettings,recoveryBonus,healthStealHealing,games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());}
public DungeonController(Plugin plugin,DungeonDirectory dungeons,AdventureQuestDirectory adventures,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random,AbilityRegistry abilities,PassiveRegistry passives,java.util.function.Supplier<games.dmg.fantasystick.session.OutOfCombatRecovery.Settings> recoverySettings,java.util.function.Function<games.dmg.fantasystick.persistence.CharacterRecord,BigInteger> recoveryBonus,java.util.function.Supplier<BigInteger> healthStealHealing,games.dmg.fantasystick.presentation.ParticleDelivery particles){this.plugin=Objects.requireNonNull(plugin);this.dungeons=Objects.requireNonNull(dungeons);this.adventures=Objects.requireNonNull(adventures);this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.characters=Objects.requireNonNull(characters);this.progression=Objects.requireNonNull(progression);this.nextTick=Objects.requireNonNull(nextTick);this.random=Objects.requireNonNull(random);this.abilities=Objects.requireNonNull(abilities);this.passives=Objects.requireNonNull(passives);this.recoverySettings=Objects.requireNonNull(recoverySettings);this.recoveryBonus=Objects.requireNonNull(recoveryBonus);this.healthStealHealing=Objects.requireNonNull(healthStealHealing);this.particles=Objects.requireNonNull(particles);typeKey=new NamespacedKey(plugin,"managed_npc_type");dungeonKey=new NamespacedKey(plugin,"dungeon_id");roomKey=new NamespacedKey(plugin,"dungeon_room_id");slotKey=new NamespacedKey(plugin,"dungeon_spawn_id");generationKey=new NamespacedKey(plugin,"dungeon_generation_id");bossKey=new NamespacedKey(plugin,"dungeon_boss");}
public void attachEvents(games.dmg.fantasystick.events.DungeonEventBridge events){this.auditEvents=Objects.requireNonNull(events);}
public void registerFishing(FishingController fishing){if(closed||this.fishing!=null)throw new IllegalStateException("Fishing already attached or dungeon controller closed");this.fishing=Objects.requireNonNull(fishing);}
public void install(){synchronizeEnabled();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(managed(entity))entity.remove();pulse=plugin.getServer().getScheduler().runTaskTimer(plugin,this::tick,1,1);}
public boolean managed(Entity entity){var data=entity.getPersistentDataContainer();if(data==null)return false;var type=data.get(typeKey,PersistentDataType.STRING);return ENEMY.equals(type)||PROJECTILE.equals(type);}
public Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin> encounterOrigin(Entity entity){if(!managedEnemy(entity))return Optional.empty();UUID dungeon=id(entity,dungeonKey),room=id(entity,roomKey),generation=id(entity,generationKey);if(dungeon==null||room==null||generation==null)return Optional.empty();var attempt=attempts.get(new AttemptKey(dungeon,room));if(attempt!=null&&!attempt.generation.equals(generation))return Optional.empty();UUID slot=id(entity,slotKey);if(slot==null&&(!Boolean.TRUE.equals(entity.getPersistentDataContainer().get(bossKey,PersistentDataType.BOOLEAN))||dungeons.room(dungeon,room).map(value->value.boss()==null).orElse(true)))return Optional.empty();if(slot!=null&&(dungeons.room(dungeon,room).stream().flatMap(value->value.mobs().stream()).noneMatch(value->value.id().equals(slot))||dungeons.runView(dungeon,generation).map(run->run.defeatedSlots().contains(slot)||run.defeated()).orElse(true)))return Optional.empty();String encounter=slot==null?"boss":slot.toString();UUID source=UUID.nameUUIDFromBytes((dungeon+":"+room+":"+generation+":"+encounter).getBytes(java.nio.charset.StandardCharsets.UTF_8));if(slot!=null&&attempt==null&&!pendingKills.containsKey(source))return Optional.empty();return Optional.of(new games.dmg.fantasystick.progression.ManagedEncounterOrigin(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,source));}
public void settleKill(games.dmg.fantasystick.progression.ManagedEncounterOrigin origin){if(origin.kind()!=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON)return;var pending=pendingKills.remove(origin.source());if(pending==null)return;dungeons.defeatSlot(pending.dungeon(),pending.generation(),pending.slot()).whenComplete((run,failure)->schedule(()->{if(failure!=null)return;var room=dungeons.room(pending.dungeon(),pending.room()).orElse(null);var attempt=attempts.get(new AttemptKey(pending.dungeon(),pending.room()));var dungeon=dungeons.find(pending.dungeon()).orElse(null);if(room!=null&&attempt!=null&&dungeon!=null&&room.boss()==null&&room.mobs().stream().allMatch(value->run.defeatedSlots().contains(value.id())))clearRoom(dungeon,room,attempt,false);}));}
public Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin> encounterOrigin(Entity entity){if(!managedEnemy(entity))return Optional.empty();UUID dungeon=id(entity,dungeonKey),room=id(entity,roomKey),generation=id(entity,generationKey);if(dungeon==null||room==null||generation==null)return Optional.empty();var attempt=attempts.get(new AttemptKey(dungeon,room));if(attempt!=null&&!attempt.generation.equals(generation))return Optional.empty();UUID slot=id(entity,slotKey);if(slot==null&&(!Boolean.TRUE.equals(entity.getPersistentDataContainer().get(bossKey,PersistentDataType.BOOLEAN))||dungeons.room(dungeon,room).map(value->value.boss()==null).orElse(true)))return Optional.empty();if(slot!=null&&(dungeons.room(dungeon,room).stream().flatMap(value->value.mobs().stream()).noneMatch(value->value.id().equals(slot))||dungeons.runView(dungeon,generation).map(run->run.defeatedSlots().contains(slot)||run.defeated()).orElse(true)))return Optional.empty();String encounter=slot==null?"boss":slot.toString();UUID source=UUID.nameUUIDFromBytes((dungeon+":"+room+":"+generation+":"+encounter).getBytes(java.nio.charset.StandardCharsets.UTF_8));if(slot!=null){var pending=pendingKills.get(source);if(!slot.equals(attempt==null?null:attempt.entities.get(entity.getUniqueId()))&&(pending==null||!entity.getUniqueId().equals(pending.entity())))return Optional.empty();}else if(!entity.getUniqueId().equals(attempt==null?null:attempt.boss)&&!entity.getUniqueId().equals(pendingBosses.get(source)))return Optional.empty();return Optional.of(new games.dmg.fantasystick.progression.ManagedEncounterOrigin(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,source));}
public Map<String,Object> killEvidence(LivingEntity entity){
var origin=encounterOrigin(entity).orElse(null);
if(origin==null)return Map.of();
UUID dungeon=id(entity,dungeonKey),room=id(entity,roomKey),generation=id(entity,generationKey);
if(dungeon==null||room==null||generation==null)return Map.of();
var definition=dungeons.find(dungeon).orElse(null);
var roomDefinition=dungeons.room(dungeon,room).orElse(null);
if(definition==null||roomDefinition==null)return Map.of();
var slot=id(entity,slotKey);
var result=new LinkedHashMap<String,Object>();
result.put("managed_source_id",origin.source().toString());
result.put("dungeon_id",dungeon.toString());result.put("dungeon_name",definition.name());
result.put("room_id",room.toString());result.put("room_name",roomDefinition.displayName());
result.put("generation_id",generation.toString());
result.put("spawn_slot_or_boss",slot==null?"boss":slot.toString());
return Map.copyOf(result);
}
public void settleKill(games.dmg.fantasystick.progression.ManagedEncounterOrigin origin){if(origin.kind()!=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON)return;pendingBosses.remove(origin.source());var pending=pendingKills.remove(origin.source());if(pending==null)return;var saved=dungeons.defeatSlot(pending.dungeon(),pending.generation(),pending.slot());saved.whenComplete((run,failure)->schedule(()->{if(failure!=null)return;if(DungeonDirectory.publication(saved).isPresent())observe("dungeon.entity.defeated",pending.observation(),Map.of("settlement_status","acknowledged_slot_save","defeated_slot",true,"reason","native_kill_settled"));var room=dungeons.room(pending.dungeon(),pending.room()).orElse(null);var attempt=attempts.get(new AttemptKey(pending.dungeon(),pending.room()));var dungeon=dungeons.find(pending.dungeon()).orElse(null);if(room!=null&&attempt!=null&&dungeon!=null&&room.boss()==null&&room.mobs().stream().allMatch(value->run.defeatedSlots().contains(value.id())))clearRoom(dungeon,room,attempt,false);}));}
private void synchronizeEnabled(){for(var site:adventures.snapshot().sites())if(site.type()==AdventureQuestState.SiteType.DUNGEON)adventures.removeDungeonSite(site.id());}
public CompletionStage<EditStatus> start(Player player,UUID dungeonId,boolean confirm){if(closed)return CompletableFuture.completedFuture(EditStatus.BUSY);UUID playerId=player.getUniqueId();var dungeon=dungeons.find(dungeonId).orElse(null);if(dungeon==null)return CompletableFuture.completedFuture(EditStatus.NOT_FOUND);synchronized(this){if(playerEdits.containsKey(playerId)||dungeonEditors.containsKey(dungeonId))return CompletableFuture.completedFuture(EditStatus.BUSY);}var catalog=adventures.snapshot().catalog();boolean offered=catalog!=null&&adventures.offerOpen(dungeonId);boolean work=offered||adventures.active().stream().anyMatch(q->q.site().equals(dungeonId))||dungeons.snapshot().runs().stream().anyMatch(run->run.dungeon().equals(dungeonId));if(work&&!confirm)return CompletableFuture.completedFuture(EditStatus.CONFIRM_REQUIRED);CompletionStage<?> transition=settleAwards().thenCompose(ignored->dungeons.disable(dungeonId)).thenCompose(ignored->adventures.closeOffer(dungeonId)).thenCompose(ignored->adventures.removeDungeonSite(dungeonId)).thenCompose(ignored->cleanupOnMain(dungeonId));return transition.thenApply(ignored->{synchronized(this){if(closed||playerEdits.containsKey(playerId)||dungeonEditors.containsKey(dungeonId))return EditStatus.BUSY;var colors=new HashMap<UUID,Color>();for(var room:dungeons.find(dungeonId).orElseThrow().rooms())colors.put(room.id(),randomColor());playerEdits.put(playerId,new Edit(dungeonId,colors));dungeonEditors.put(dungeonId,playerId);return EditStatus.STARTED;}});}
public record EditPublication(EditStatus status,DungeonState.Dungeon dungeon){}
public CompletionStage<EditStatus> start(Player player,UUID dungeonId,boolean confirm){return startWithEvidence(player,dungeonId,confirm).thenApply(EditPublication::status);}
public CompletionStage<EditPublication> startWithEvidence(Player player,UUID dungeonId,boolean confirm){if(closed)return CompletableFuture.completedFuture(new EditPublication(EditStatus.BUSY,null));UUID playerId=player.getUniqueId();var dungeon=dungeons.find(dungeonId).orElse(null);if(dungeon==null)return CompletableFuture.completedFuture(new EditPublication(EditStatus.NOT_FOUND,null));synchronized(this){if(playerEdits.containsKey(playerId)||dungeonEditors.containsKey(dungeonId))return CompletableFuture.completedFuture(new EditPublication(EditStatus.BUSY,null));}var catalog=adventures.snapshot().catalog();boolean offered=catalog!=null&&adventures.offerOpen(dungeonId);boolean work=offered||adventures.active().stream().anyMatch(q->q.site().equals(dungeonId))||dungeons.snapshot().runs().stream().anyMatch(run->run.dungeon().equals(dungeonId));if(work&&!confirm)return CompletableFuture.completedFuture(new EditPublication(EditStatus.CONFIRM_REQUIRED,null));CompletionStage<?> transition=settleAwards().thenCompose(ignored->dungeons.disable(dungeonId)).thenCompose(ignored->adventures.closeOffer(dungeonId)).thenCompose(ignored->adventures.removeDungeonSite(dungeonId)).thenCompose(ignored->cleanupOnMain(dungeonId));return transition.thenApply(ignored->{synchronized(this){if(closed||playerEdits.containsKey(playerId)||dungeonEditors.containsKey(dungeonId))return new EditPublication(EditStatus.BUSY,null);var leased=dungeons.find(dungeonId).orElseThrow();var colors=new HashMap<UUID,Color>();for(var room:leased.rooms())colors.put(room.id(),randomColor());playerEdits.put(playerId,new Edit(dungeonId,colors));dungeonEditors.put(dungeonId,playerId);return new EditPublication(EditStatus.STARTED,leased);}});}
public synchronized boolean done(UUID player){var edit=playerEdits.remove(player);if(edit==null)return false;dungeonEditors.remove(edit.dungeon(),player);return true;}
public synchronized Optional<UUID> editing(UUID player){var edit=playerEdits.get(player);return edit==null?Optional.empty():Optional.of(edit.dungeon());}
public synchronized boolean beingEdited(UUID dungeon){return dungeonEditors.containsKey(dungeon);}
public boolean occupied(UUID dungeon){var definition=dungeons.find(dungeon).orElse(null);if(definition==null)return false;return plugin.getServer().getOnlinePlayers().stream().anyMatch(player->definition.rooms().stream().anyMatch(room->inside(player.getLocation(),definition,room)));}
public Optional<DungeonState.Room> standingRoom(Player player){var dungeon=editing(player.getUniqueId()).orElse(null);if(dungeon==null)return Optional.empty();var location=player.getLocation();return dungeons.roomAt(dungeon,location.getBlockX(),location.getBlockY(),location.getBlockZ());}
public CompletionStage<DungeonState.Dungeon> enable(UUID id){synchronized(this){if(dungeonEditors.containsKey(id))return CompletableFuture.failedFuture(new IllegalStateException("Dungeon is being edited"));}return dungeons.enable(id,List.of());}
public CompletionStage<Boolean> disable(UUID id){return settleAwards().thenCompose(ignored->dungeons.disable(id)).thenCompose(value->adventures.closeOffer(id).thenCompose(ignored->adventures.removeDungeonSite(id)).thenCompose(ignored->cleanupOnMain(id)).thenApply(ignored->value.isPresent()));}
public CompletionStage<AdventureQuestState.Catalog> open(UUID id){var dungeon=dungeons.find(id).filter(DungeonState.Dungeon::enabled).orElseThrow(()->new IllegalArgumentException("Dungeon is not enabled"));var catalog=adventures.snapshot().catalog();if(catalog!=null&&dungeons.runView(id,catalog.revision()).map(DungeonState.Run::defeated).orElse(false))return CompletableFuture.failedFuture(new IllegalArgumentException("Dungeon is defeated"));return adventures.ensureToday().thenCompose(current->adventures.appendOffer(dungeon.id()));}
public record DisableResult(boolean applied,DungeonDirectory.Publication publication){}
public CompletionStage<Boolean> disable(UUID id){return disableWithEvidence(id).thenApply(DisableResult::applied);}
public CompletionStage<DisableResult> disableWithEvidence(UUID id){return disableWithEvidence(id,ignored->{});}
public CompletionStage<DisableResult> disableWithEvidence(UUID id,Consumer<DungeonDirectory.Publication> saved){return settleAwards().thenCompose(ignored->{var source=dungeons.disable(id);return source.thenCompose(value->{var publication=DungeonDirectory.publication(source).orElse(null);if(publication!=null)try{saved.accept(publication);}catch(RuntimeException unavailable){/* Saved source authority never depends on its observer. */}return adventures.closeOffer(id).thenCompose(closedOffer->adventures.removeDungeonSite(id)).thenCompose(removedSite->cleanupOnMain(id)).thenApply(cleaned->new DisableResult(value.isPresent(),publication));});});}
public record OpenResult(DungeonState.Dungeon dungeon,AdventureQuestDirectory.OfferPublication publication){}
public CompletionStage<AdventureQuestState.Catalog> open(UUID id){return openWithEvidence(id).thenApply(value->value.publication().after());}
public CompletionStage<OpenResult> openWithEvidence(UUID id){var dungeon=dungeons.find(id).filter(DungeonState.Dungeon::enabled).orElseThrow(()->new IllegalArgumentException("Dungeon is not enabled"));var catalog=adventures.snapshot().catalog();if(catalog!=null&&dungeons.runView(id,catalog.revision()).map(DungeonState.Run::defeated).orElse(false))return CompletableFuture.failedFuture(new IllegalArgumentException("Dungeon is defeated"));return adventures.ensureToday().thenCompose(current->adventures.appendOfferWithEvidence(dungeon.id())).thenApply(publication->new OpenResult(dungeon,publication));}
public CompletionStage<Boolean> closeOffer(UUID id){var catalog=adventures.snapshot().catalog();boolean defeated=catalog!=null&&dungeons.runView(id,catalog.revision()).map(DungeonState.Run::defeated).orElse(false);return settleAwards().thenCompose(ignored->adventures.closeOffer(id)).thenCompose(changed->{if(catalog==null)return CompletableFuture.completedFuture(changed);CompletionStage<?> progress=defeated?CompletableFuture.completedFuture(false):dungeons.clearRun(id,catalog.revision());return progress.thenCompose(ignored->cleanupOnMain(id)).thenApply(ignored->changed);});}
private CompletableFuture<Void> settleAwards(){return CompletableFuture.allOf(inFlight.stream().map(future->future.handle((ignored,failure)->null)).toArray(CompletableFuture[]::new));}
private CompletionStage<Void> cleanupOnMain(UUID dungeon){var result=new CompletableFuture<Void>();if(closed){result.completeExceptionally(new IllegalStateException("Dungeon controller closed"));return result;}try{nextTick.accept(()->{if(closed){result.completeExceptionally(new IllegalStateException("Dungeon controller closed"));return;}cleanupDungeon(dungeon);synchronized(this){var editor=dungeonEditors.remove(dungeon);if(editor!=null)playerEdits.remove(editor);}result.complete(null);});}catch(RuntimeException failure){result.completeExceptionally(failure);}return result;}
public void definitionChanged(UUID dungeon){synchronized(this){var editor=dungeonEditors.get(dungeon);if(editor==null)return;var edit=playerEdits.get(editor);if(edit==null)return;for(var room:dungeons.find(dungeon).map(DungeonState.Dungeon::rooms).orElse(List.of()))edit.colors().computeIfAbsent(room.id(),ignored->randomColor());}}
private void tick(){if(closed)return;if(++ticks%10==0)showBorders();runtime();}
private void runtime(){var catalog=adventures.snapshot().catalog();if(catalog==null||adventures.snapshot().reset()!=null){cleanupRuntime();return;}var activeDungeonIds=catalog.sites().stream().filter(adventures::offerOpen).filter(id->adventures.findSite(id).map(AdventureQuestState.Site::type).orElse(null)==AdventureQuestState.SiteType.DUNGEON).collect(java.util.stream.Collectors.toSet());for(var key:new ArrayList<>(attempts.keySet()))if(!activeDungeonIds.contains(key.dungeon()))cleanupAttempt(key);var activeEffects=new HashSet<String>();for(UUID dungeonId:activeDungeonIds){if(defeatingDungeons.contains(dungeonId))continue;var dungeon=dungeons.find(dungeonId).orElse(null);if(dungeon==null)continue;var run=dungeons.runView(dungeonId,catalog.revision()).orElse(null);if(run==null){String key=dungeonId+":"+catalog.revision();if(startingRuns.add(key))dungeons.run(dungeonId,catalog.revision()).whenComplete((ignored,failure)->schedule(()->startingRuns.remove(key)));continue;}if(run.defeated()){cleanupDungeon(dungeonId);continue;}for(var room:dungeon.rooms()){var players=eligibleInside(dungeon,room).toList();var key=new AttemptKey(dungeonId,room.id());if(players.isEmpty()){cleanupAttempt(key);continue;}applyPlayerEffects(dungeon.id(),room,players,activeEffects);if(run.clearedRooms().contains(room.id())){cleanupAttempt(key);continue;}if(completing.contains(key))continue;var attempt=attempts.computeIfAbsent(key,ignored->new Attempt(dungeonId,room.id(),catalog.revision()));contain(dungeon,room,attempt);if(attempt.entities.isEmpty()&&attempt.boss==null)spawnRoom(dungeon,room,run,attempt);if(room.boss()!=null&&room.boss().character()!=null)driveCharacterBoss(dungeon,room,attempt,players);if(room.mobs().isEmpty()&&room.boss()==null){attempt.represented.addAll(players.stream().map(this::eligibleQuest).filter(Objects::nonNull).map(AdventureQuestState.Quest::id).toList());clearRoom(dungeon,room,attempt,false);}}}cleanupOwnedEffects(activeEffects);}
private void runtime(){var catalog=adventures.snapshot().catalog();if(catalog==null||adventures.snapshot().reset()!=null){cleanupRuntime();return;}var activeDungeonIds=catalog.sites().stream().filter(adventures::offerOpen).filter(id->adventures.findSite(id).map(AdventureQuestState.Site::type).orElse(null)==AdventureQuestState.SiteType.DUNGEON).collect(java.util.stream.Collectors.toSet());for(var key:new ArrayList<>(attempts.keySet()))if(!activeDungeonIds.contains(key.dungeon()))cleanupAttempt(key);var activeEffects=new HashSet<String>();for(UUID dungeonId:activeDungeonIds){if(defeatingDungeons.contains(dungeonId))continue;var dungeon=dungeons.find(dungeonId).orElse(null);if(dungeon==null)continue;var run=dungeons.runView(dungeonId,catalog.revision()).orElse(null);if(run==null){String key=dungeonId+":"+catalog.revision();if(startingRuns.add(key)){var observation=facts(dungeon,null,catalog.revision(),null,Map.of("reason","offered_runtime_initialized"));var source=dungeons.run(dungeonId,catalog.revision());source.whenComplete((ignored,failure)->{if(failure==null&&DungeonDirectory.publication(source).isPresent())observe("dungeon.run.started",observation,Map.of("run_status","persisted_undefeated"));schedule(()->startingRuns.remove(key));});}continue;}if(run.defeated()){cleanupDungeon(dungeonId);continue;}for(var room:dungeon.rooms()){var players=eligibleInside(dungeon,room).toList();var key=new AttemptKey(dungeonId,room.id());if(players.isEmpty()){cleanupAttempt(key,"no_eligible_player_in_room");continue;}applyPlayerEffects(dungeon.id(),room,players,activeEffects);if(run.clearedRooms().contains(room.id())){cleanupAttempt(key);continue;}if(completing.contains(key))continue;var attempt=attempts.computeIfAbsent(key,ignored->{var created=new Attempt(dungeonId,room.id(),catalog.revision());var trigger=players.getFirst();created.observation=facts(dungeon,room,catalog.revision(),trigger,questData(eligible(trigger,dungeonId)));observe("dungeon.room.activated",created.observation,Map.of("attempt_capture_quality","native_transient_not_persisted","reason","eligible_room_entry"));return created;});contain(dungeon,room,attempt);if(attempt.entities.isEmpty()&&attempt.boss==null)spawnRoom(dungeon,room,run,attempt);if(room.boss()!=null&&room.boss().character()!=null)driveCharacterBoss(dungeon,room,attempt,players);if(room.mobs().isEmpty()&&room.boss()==null){attempt.represented.addAll(players.stream().map(this::eligibleQuest).filter(Objects::nonNull).map(AdventureQuestState.Quest::id).toList());clearRoom(dungeon,room,attempt,false);}}}cleanupOwnedEffects(activeEffects);}
private Context context(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return null;var record=sessions.currentRecord(active).orElse(null);if(record==null||record.dead())return null;var party=parties.partyOf(player.getUniqueId()).orElse(null);return party==null?new Context(record.id(),null,Set.of(record.id())):new Context(record.id(),party.id(),Set.copyOf(party.characters()));}
private AdventureQuestState.Quest eligibleQuest(Player player){var context=context(player);if(context==null)return null;var quest=adventures.activeFor(context.character(),context.party()).orElse(null);return quest!=null&&quest.snapshot().contains(context.character())?quest:null;}
private AdventureQuestState.Quest eligible(Player player,UUID dungeon){var quest=eligibleQuest(player);return quest!=null&&quest.site().equals(dungeon)?quest:null;}
private AdventureQuestState.Quest eligible(Player player,UUID dungeon,UUID room){var quest=eligible(player,dungeon);var definition=dungeons.find(dungeon).orElse(null);var roomDefinition=dungeons.room(dungeon,room).orElse(null);return quest!=null&&definition!=null&&roomDefinition!=null&&inside(player.getLocation(),definition,roomDefinition)?quest:null;}
private java.util.stream.Stream<Player> eligibleInside(DungeonState.Dungeon dungeon,DungeonState.Room room){return plugin.getServer().getOnlinePlayers().stream().map(Player.class::cast).filter(player->eligible(player,dungeon.id())!=null).filter(player->inside(player.getLocation(),dungeon,room));}
private static boolean inside(Location location,DungeonState.Dungeon dungeon,DungeonState.Room room){return location.getWorld()!=null&&location.getWorld().getUID().equals(dungeon.world())&&room.bounds().contains(location.getBlockX(),location.getBlockY(),location.getBlockZ());}
@SuppressWarnings({"unchecked","rawtypes"}) private void spawnRoom(DungeonState.Dungeon dungeon,DungeonState.Room room,DungeonState.Run run,Attempt attempt){var world=plugin.getServer().getWorld(dungeon.world());if(world==null)return;for(var slot:room.mobs())if(!run.defeatedSlots().contains(slot.id())&&loaded(world,slot.position()))world.spawn(location(world,slot.position()),(Class)slot.type().getEntityClass(),entity->configure((Mob)entity,dungeon,room,attempt,slot.id(),false,slot.name(),room.effects()));if(room.boss()!=null&&loaded(world,room.boss().position()))world.spawn(location(world,room.boss().position()),(Class)room.boss().type().getEntityClass(),entity->configure((Mob)entity,dungeon,room,attempt,null,true,room.boss().name(),room.effects()));}
@SuppressWarnings({"unchecked","rawtypes"}) private void spawnRoom(DungeonState.Dungeon dungeon,DungeonState.Room room,DungeonState.Run run,Attempt attempt){var world=plugin.getServer().getWorld(dungeon.world());if(world==null)return;for(var slot:room.mobs())if(!run.defeatedSlots().contains(slot.id())&&loaded(world,slot.position())){var spawned=world.spawn(location(world,slot.position()),(Class)slot.type().getEntityClass(),entity->configure((Mob)entity,dungeon,room,attempt,slot.id(),false,slot.name(),room.effects()));observeSpawn(dungeon,room,attempt,spawned,slot.id(),false);}if(room.boss()!=null&&loaded(world,room.boss().position())){var spawned=world.spawn(location(world,room.boss().position()),(Class)room.boss().type().getEntityClass(),entity->configure((Mob)entity,dungeon,room,attempt,null,true,room.boss().name(),room.effects()));if(room.boss().character()==null)observeSpawn(dungeon,room,attempt,spawned,null,true);else observeCharacterBossSpawn(dungeon,room,attempt,spawned);}}
private void configure(Mob mob,DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,UUID slot,boolean boss,String name,List<DungeonState.Effect> effects){var data=mob.getPersistentDataContainer();data.set(typeKey,PersistentDataType.STRING,ENEMY);data.set(dungeonKey,PersistentDataType.STRING,dungeon.id().toString());data.set(roomKey,PersistentDataType.STRING,room.id().toString());data.set(generationKey,PersistentDataType.STRING,attempt.generation.toString());data.set(bossKey,PersistentDataType.BOOLEAN,boss);if(slot!=null)data.set(slotKey,PersistentDataType.STRING,slot.toString());mob.setCanPickupItems(false);mob.setRemoveWhenFarAway(false);mob.setPersistent(true);mob.setFireTicks(0);if(mob instanceof Raider raider){raider.setPatrolLeader(false);raider.setCanJoinRaid(false);}if(mob instanceof Zombie zombie)zombie.setShouldBurnInDay(false);if(!name.isBlank()){mob.customName(net.kyori.adventure.text.Component.text(name));mob.setCustomNameVisible(true);}if(boss){attempt.boss=mob.getUniqueId();if(room.boss()!=null&&room.boss().character()!=null)configureCharacterBoss(mob,room.boss().character());}else attempt.entities.put(mob.getUniqueId(),slot);applyMobEffects(mob,effects);}
private void configureCharacterBoss(Mob mob,DungeonState.CharacterBoss custody){var stats=custody.effectiveStats();var maximum=new java.math.BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN)).min(games.dmg.fantasystick.session.NativeHealthLimit.read()).max(java.math.BigDecimal.ONE).doubleValue();var health=mob.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH);if(health!=null){health.setBaseValue(maximum);mob.setHealth(maximum);}var damage=mob.getAttribute(org.bukkit.attribute.Attribute.ATTACK_DAMAGE);if(damage!=null)damage.setBaseValue(Math.min(characterDamage(stats.get(Stat.STR)),nativeAttributeMaximum(org.bukkit.attribute.Attribute.ATTACK_DAMAGE)));var equipment=mob.getEquipment();if(equipment!=null){var gear=custody.character().gear();equipment.setChestplate(armor(org.bukkit.inventory.EquipmentSlot.CHEST,gear.chestplate()));equipment.setLeggings(armor(org.bukkit.inventory.EquipmentSlot.LEGS,gear.leggings()));equipment.setBoots(armor(org.bukkit.inventory.EquipmentSlot.FEET,gear.boots()));equipment.setChestplateDropChance(0);equipment.setLeggingsDropChance(0);equipment.setBootsDropChance(0);}for(var id:custody.character().passives())if(passives.find(id).isPresent()){if(id.equals("speed_i"))mob.addPotionEffect(new PotionEffect(PotionEffectType.SPEED,PotionEffect.INFINITE_DURATION,0,false,false));else if(id.equals("low_light_vision"))mob.addPotionEffect(new PotionEffect(PotionEffectType.NIGHT_VISION,PotionEffect.INFINITE_DURATION,0,false,false));}}
private static double nativeAttributeMaximum(org.bukkit.attribute.Attribute attribute){try{var handle=attribute.getClass().getMethod("getHandle").invoke(attribute);double maximum=((Number)handle.getClass().getMethod("getMaxValue").invoke(handle)).doubleValue();if(!Double.isFinite(maximum)||maximum<=0)throw new IllegalStateException("Invalid native attribute range");return maximum;}catch(ReflectiveOperationException|ClassCastException failure){throw new IllegalStateException("Cannot read native attribute range",failure);}}
@@ -79,8 +123,8 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
private static double abilityAmount(AbilityDefinition definition,DungeonState.CharacterBoss custody,boolean healing){var stats=custody.effectiveStats();Stat stat=healing?Stat.WIS:switch(definition.id()){case"great_sword","shield_bash"->Stat.STR;case"longbow"->Stat.DEX;case"hex"->Stat.CHR;default->Stat.INT;};var base=definition.parameters().get(healing?"base-healing":"base-damage");var exact=(base==null?BigInteger.ZERO:base.toBigInteger()).add(modifier(stats.get(stat))).max(BigInteger.ZERO);return exact.bitLength()>1023?Double.MAX_VALUE:exact.doubleValue();}
private static double range(AbilityDefinition definition){for(var key:List.of("range","initial-range","target-range","placement-range")){var value=definition.parameters().get(key);if(value!=null)return value.doubleValue();}return definition.id().equals("great_sword")?4d:16d;}
private static org.bukkit.inventory.ItemStack armor(org.bukkit.inventory.EquipmentSlot slot,games.dmg.fantasystick.persistence.GearTier tier){if(tier==games.dmg.fantasystick.persistence.GearTier.NONE)return null;String material=(tier==games.dmg.fantasystick.persistence.GearTier.GOLD?"GOLDEN":tier.name())+"_"+switch(slot){case CHEST->"CHESTPLATE";case LEGS->"LEGGINGS";case FEET->"BOOTS";default->throw new IllegalArgumentException("Not armor");};return new org.bukkit.inventory.ItemStack(Material.valueOf(material));}
private void driveCharacterBoss(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,List<Player> eligible){var world=plugin.getServer().getWorld(dungeon.world());if(world==null)return;Mob boss=null;for(var entity:safe(world.getEntities()))if(entity instanceof Mob mob&&Objects.equals(attempt.boss,mob.getUniqueId())){boss=mob;break;}if(boss==null)return;var actor=boss;long now=System.nanoTime();if(attempt.effects.getOrDefault("absorption",Long.MAX_VALUE)<=now){boss.setAbsorptionAmount(0);attempt.effects.remove("absorption");}var eligibleIds=eligible.stream().map(Player::getUniqueId).collect(java.util.stream.Collectors.toSet());attempt.threat.keySet().removeIf(id->!eligibleIds.contains(id));Player target=eligible.stream().filter(player->player.getUniqueId().equals(attempt.target)&&!player.isDead()&&actor.hasLineOfSight(player)).findFirst().orElse(null);if(target==null&&!attempt.threat.isEmpty()){target=eligible.stream().filter(player->!player.isDead()&&actor.hasLineOfSight(player)&&attempt.threat.containsKey(player.getUniqueId())).max(Comparator.<Player>comparingDouble(player->attempt.threat.getOrDefault(player.getUniqueId(),0d)).thenComparing(player->player.getUniqueId().toString(),Comparator.reverseOrder())).orElse(null);attempt.target=target==null?null:target.getUniqueId();}if(target==null){regenerateCharacterBoss(boss,room.boss().character(),attempt);return;}if(boss.getTarget()!=target)boss.setTarget(target);castCharacterAbility(boss,target,eligible,room.boss().character(),attempt);}
private void regenerateCharacterBoss(Mob boss,DungeonState.CharacterBoss custody,Attempt attempt){var settings=recoverySettings.get();long now=System.nanoTime();long ready=attempt.cooldowns.getOrDefault("recovery",attempt.lastCombat+settings.delay().toNanos());if(now<ready)return;var maximum=boss.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH);var amount=settings.amount().add(recoveryBonus.apply(custody.character()));if(maximum!=null&&amount.signum()>0&&boss.getHealth()<maximum.getValue())healBoss(boss,Math.min(Double.MAX_VALUE,amount.doubleValue()));attempt.cooldowns.put("recovery",now+settings.interval().toNanos());}
private void driveCharacterBoss(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,List<Player> eligible){var world=plugin.getServer().getWorld(dungeon.world());if(world==null)return;Mob boss=null;for(var entity:safe(world.getEntities()))if(entity instanceof Mob mob&&Objects.equals(attempt.boss,mob.getUniqueId())){boss=mob;break;}if(boss==null)return;var actor=boss;long now=System.nanoTime();if(attempt.effects.getOrDefault("absorption",Long.MAX_VALUE)<=now){boss.setAbsorptionAmount(0);attempt.effects.remove("absorption");}var eligibleIds=eligible.stream().map(Player::getUniqueId).collect(java.util.stream.Collectors.toSet());attempt.threat.keySet().removeIf(id->!eligibleIds.contains(id));Player target=eligible.stream().filter(player->player.getUniqueId().equals(attempt.target)&&!player.isDead()&&actor.hasLineOfSight(player)).findFirst().orElse(null);if(target==null&&!attempt.threat.isEmpty()){target=eligible.stream().filter(player->!player.isDead()&&actor.hasLineOfSight(player)&&attempt.threat.containsKey(player.getUniqueId())).max(Comparator.<Player>comparingDouble(player->attempt.threat.getOrDefault(player.getUniqueId(),0d)).thenComparing(player->player.getUniqueId().toString(),Comparator.reverseOrder())).orElse(null);attempt.target=target==null?null:target.getUniqueId();}if(target==null){regenerateCharacterBoss(boss,room.boss().character(),attempt);return;}var previous=boss.getTarget();if(previous!=target){var observation=characterTargetFacts(boss,target,previous,attempt);boss.setTarget(target);observeCharacterTarget(observation,boss,target);}castCharacterAbility(boss,target,eligible,room.boss().character(),attempt);}
private void regenerateCharacterBoss(Mob boss,DungeonState.CharacterBoss custody,Attempt attempt){var settings=recoverySettings.get();long now=System.nanoTime();long ready=attempt.cooldowns.getOrDefault("recovery",attempt.lastCombat+settings.delay().toNanos());if(now<ready)return;var maximum=boss.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH);var amount=settings.amount().add(recoveryBonus.apply(custody.character()));if(maximum!=null&&amount.signum()>0&&boss.getHealth()<maximum.getValue())healBoss(boss,Math.min(Double.MAX_VALUE,amount.doubleValue()),characterPassiveHealingFacts(boss,custody,"out_of_combat_recovery",settings.amount(),amount.subtract(settings.amount())));attempt.cooldowns.put("recovery",now+settings.interval().toNanos());}
private void castCharacterAbility(Mob boss,Player target,List<Player> eligible,DungeonState.CharacterBoss custody,Attempt attempt){
long now=System.nanoTime();
for(String id:custody.character().loadout()){
@@ -93,40 +137,135 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
double range=range(definition);
if(definition.category()==AbilityDefinition.Category.ATK&&(definition.targetPolicy()!=AbilityDefinition.TargetPolicy.AREA||id.equals("meteor"))
&&(!boss.hasLineOfSight(target)||boss.getLocation().distanceSquared(target.getLocation())>range*range))continue;
var observation=beginCharacterCast(boss,target,definition,custody.character().loadout().indexOf(id));
try{
boolean cast;
if(id.equals("purify")){
var harmful=boss.getActivePotionEffects().stream().filter(effect->effect.getType().getEffectCategory().name().equals("HARMFUL")).toList();
for(var effect:harmful)boss.removePotionEffect(effect.getType());
cast=!harmful.isEmpty();
}else if(id.equals("hex")){
target.addPotionEffect(new PotionEffect(PotionEffectType.WEAKNESS,Math.max(20,(int)Math.min(1200,definition.cooldown().toMillis()/50)),0,false,true),true);cast=true;
characterPotion(target,new PotionEffect(PotionEffectType.WEAKNESS,Math.max(20,(int)Math.min(1200,definition.cooldown().toMillis()/50)),0,false,true),true);cast=true;
}else cast=switch(definition.category()){
case HEAL->{yield healBoss(boss,abilityAmount(definition,custody,true));}
case BUF->{if(id.equals("quickstep")){var direction=target.getLocation().toVector().subtract(boss.getLocation().toVector());double distance=definition.parameters().getOrDefault("distance",java.math.BigDecimal.ONE).doubleValue();if(direction.lengthSquared()==0||!Double.isFinite(distance)||distance<=0)yield false;boss.setVelocity(direction.normalize().multiply(distance));yield true;}if(id.equals("inspire")){double amount=definition.parameters().getOrDefault("base-temporary-health",java.math.BigDecimal.ZERO).add(new java.math.BigDecimal(modifier(custody.effectiveStats().get(Stat.CHR)))).max(java.math.BigDecimal.ZERO).doubleValue();if(amount<=boss.getAbsorptionAmount())yield false;boss.setAbsorptionAmount(amount);attempt.effects.put("absorption",now+java.time.Duration.ofMillis(durationTicks(definition)*50L).toNanos());yield true;}var type=id.equals("stone_skin")?PotionEffectType.RESISTANCE:id.equals("rallying_cry")?PotionEffectType.SPEED:PotionEffectType.STRENGTH;boss.addPotionEffect(new PotionEffect(type,durationTicks(definition),0,false,false),true);yield true;}
case ATK->{var victims=abilityVictims(definition,boss,target,eligible,range);double amount=abilityAmount(definition,custody,false);if(amount<=0)yield false;for(int index=0;index<victims.size();index++){var victim=victims.get(index);double strike=id.equals("chain_lightning")?java.math.BigDecimal.valueOf(amount).multiply(definition.parameters().getOrDefault("jump-multiplier",java.math.BigDecimal.ONE).pow(index)).doubleValue():amount;victim.damage(strike,boss);if(id.equals("frost_shard")||id.equals("frost_nova"))victim.addPotionEffect(new PotionEffect(PotionEffectType.SLOWNESS,40,0,false,true),true);}yield !victims.isEmpty();}
case BUF->{if(id.equals("quickstep")){var direction=target.getLocation().toVector().subtract(boss.getLocation().toVector());double distance=definition.parameters().getOrDefault("distance",java.math.BigDecimal.ONE).doubleValue();if(direction.lengthSquared()==0||!Double.isFinite(distance)||distance<=0)yield false;boss.setVelocity(direction.normalize().multiply(distance));yield true;}if(id.equals("inspire")){double amount=definition.parameters().getOrDefault("base-temporary-health",java.math.BigDecimal.ZERO).add(new java.math.BigDecimal(modifier(custody.effectiveStats().get(Stat.CHR)))).max(java.math.BigDecimal.ZERO).doubleValue();if(amount<=boss.getAbsorptionAmount())yield false;boss.setAbsorptionAmount(amount);attempt.effects.put("absorption",now+java.time.Duration.ofMillis(durationTicks(definition)*50L).toNanos());yield true;}var type=id.equals("stone_skin")?PotionEffectType.RESISTANCE:id.equals("rallying_cry")?PotionEffectType.SPEED:PotionEffectType.STRENGTH;characterPotion(boss,new PotionEffect(type,durationTicks(definition),0,false,false),true);yield true;}
case ATK->{var victims=abilityVictims(definition,boss,target,eligible,range);double amount=abilityAmount(definition,custody,false);if(amount<=0)yield false;for(int index=0;index<victims.size();index++){var victim=victims.get(index);double strike=id.equals("chain_lightning")?java.math.BigDecimal.valueOf(amount).multiply(definition.parameters().getOrDefault("jump-multiplier",java.math.BigDecimal.ONE).pow(index)).doubleValue():amount;victim.damage(strike,boss);if(id.equals("frost_shard")||id.equals("frost_nova"))characterPotion(victim,new PotionEffect(PotionEffectType.SLOWNESS,40,0,false,true),true);}yield !victims.isEmpty();}
};
if(cast)attempt.cooldowns.put(id,now+definition.cooldown().toNanos());
completeCharacterCast(observation,cast,definition.category());
}finally{endCharacterCast(observation);}
}
}
private static int durationTicks(AbilityDefinition definition){var seconds=definition.parameters().get("duration-seconds");long ticks=seconds==null?Math.max(20,definition.cooldown().toMillis()/50):seconds.movePointRight(1).longValue();return (int)Math.max(1,Math.min(Integer.MAX_VALUE,ticks));}
private boolean healBoss(Mob boss,double requested){var maximum=boss.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH);if(maximum==null||!Double.isFinite(requested)||requested<=0)return false;double accepted=Math.min(requested,maximum.getValue()-boss.getHealth());if(accepted<=0)return false;var event=new EntityRegainHealthEvent(boss,accepted,EntityRegainHealthEvent.RegainReason.CUSTOM);plugin.getServer().getPluginManager().callEvent(event);if(event.isCancelled()||!Double.isFinite(event.getAmount())||event.getAmount()<=0)return false;double before=boss.getHealth();boss.setHealth(Math.min(maximum.getValue(),before+event.getAmount()));return boss.getHealth()>before;}
private boolean healBoss(Mob boss,double requested){return healBoss(boss,requested,null);}
private boolean healBoss(Mob boss,double requested,games.dmg.fantasystick.events.CharacterBossEventBridge.Facts passiveFacts){var maximum=boss.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH);if(maximum==null||!Double.isFinite(requested)||requested<=0)return false;double initial=boss.getHealth();double accepted=Math.min(requested,maximum.getValue()-initial);if(accepted<=0)return false;var event=new EntityRegainHealthEvent(boss,accepted,EntityRegainHealthEvent.RegainReason.CUSTOM);plugin.getServer().getPluginManager().callEvent(event);if(event.isCancelled()||!Double.isFinite(event.getAmount())||event.getAmount()<=0){observeCharacterHealing(boss,event,requested,initial,initial,passiveFacts);return false;}double before=boss.getHealth();boss.setHealth(Math.min(maximum.getValue(),before+event.getAmount()));double after=boss.getHealth();observeCharacterHealing(boss,event,requested,before,after,passiveFacts);return after>before;}
private static List<Player> abilityVictims(AbilityDefinition definition,Mob boss,Player target,List<Player> eligible,double range){if(definition.id().equals("chain_lightning")){var result=new ArrayList<Player>();result.add(target);int maximum=1+definition.parameters().getOrDefault("additional-targets",java.math.BigDecimal.ZERO).intValue();double jump=definition.parameters().getOrDefault("jump-range",java.math.BigDecimal.valueOf(range)).doubleValue();Player current=target;while(result.size()<maximum){var origin=current;var next=eligible.stream().filter(player->!result.contains(player)&&origin.hasLineOfSight(player)&&origin.getLocation().distanceSquared(player.getLocation())<=jump*jump).min(Comparator.comparingDouble((Player player)->origin.getLocation().distanceSquared(player.getLocation())).thenComparing(player->player.getUniqueId().toString())).orElse(null);if(next==null)break;result.add(next);current=next;}return List.copyOf(result);}double radius=definition.parameters().getOrDefault("radius",java.math.BigDecimal.valueOf(range)).doubleValue();var center=definition.id().equals("meteor")?target:boss;return definition.targetPolicy()==AbilityDefinition.TargetPolicy.AREA?eligible.stream().filter(player->center.hasLineOfSight(player)&&center.getLocation().distanceSquared(player.getLocation())<=radius*radius).toList():List.of(target);}
private void applyMobEffects(LivingEntity mob,List<DungeonState.Effect> effects){for(var effect:effects)if(effect.target()!=DungeonState.EffectTarget.PLAYERS){var type=effectType(effect.effect());if(type!=null)mob.addPotionEffect(new PotionEffect(type,PotionEffect.INFINITE_DURATION,effect.level()-1,false,effect.particles()));}}
private void applyPlayerEffects(UUID dungeon,DungeonState.Room room,List<Player> players,Set<String> active){for(var effect:room.effects())if(effect.target()!=DungeonState.EffectTarget.MOBS){var type=effectType(effect.effect());if(type==null)continue;for(var player:players){String key=player.getUniqueId()+":"+effect.id();active.add(key);var current=effect(player,type);int amplifier=effect.level()-1;if(current!=null&&current.getAmplifier()>amplifier)continue;ownedEffects.computeIfAbsent(key,ignored->new OwnedEffect(dungeon,player.getUniqueId(),type,amplifier,current));player.addPotionEffect(new PotionEffect(type,30,amplifier,false,effect.particles()),true);}}}
private final Set<String> observedEffectKeys=new HashSet<>();
private void applyPlayerEffects(UUID dungeon,DungeonState.Room room,List<Player> players,Set<String> active){for(var effect:room.effects())if(effect.target()!=DungeonState.EffectTarget.MOBS){var type=effectType(effect.effect());if(type==null)continue;for(var player:players){String key=player.getUniqueId()+":"+effect.id();active.add(key);var current=effect(player,type);int amplifier=effect.level()-1;if(current!=null&&current.getAmplifier()>amplifier)continue;var owned=ownedEffects.computeIfAbsent(key,ignored->new OwnedEffect(dungeon,player.getUniqueId(),type,amplifier,current,effectFacts(dungeon,room,player,effect)));player.addPotionEffect(new PotionEffect(type,30,amplifier,false,effect.particles()),true);if(ownerEvents!=null)try{var applied=effect(player,type);if(applied!=null&&applied.getAmplifier()==amplifier&&observedEffectKeys.add(key))observe("effect.applied",owned.observation(),Map.of("application_status","native_layer_confirmed","reason","eligible_room_effect"));}catch(RuntimeException unavailable){/* This optional confirmation cannot interrupt native application or room runtime. */}}}}
private void cleanupOwnedEffects(Set<String> active){for(var entry:new ArrayList<>(ownedEffects.entrySet()))if(!active.contains(entry.getKey()))restoreOwned(entry.getKey(),entry.getValue());}
private void cleanupOwnedDungeon(UUID dungeon){for(var entry:new ArrayList<>(ownedEffects.entrySet()))if(entry.getValue().dungeon().equals(dungeon))restoreOwned(entry.getKey(),entry.getValue());}
private void restoreOwned(String key,OwnedEffect owned){ownedEffects.remove(key);var player=plugin.getServer().getPlayer(owned.player());if(player==null)return;var current=effect(player,owned.type());if(current!=null&&current.getAmplifier()==owned.amplifier()){player.removePotionEffect(owned.type());if(owned.prior()!=null&&owned.prior().getDuration()>0)player.addPotionEffect(owned.prior(),true);}}
private void restoreOwned(String key,OwnedEffect owned){ownedEffects.remove(key);boolean observed=observedEffectKeys.remove(key);var player=plugin.getServer().getPlayer(owned.player());if(player==null){if(observed)observe("effect.ended",owned.observation(),Map.of("reason","owned_room_layer_released","cleanup_status","player_unavailable"));return;}var current=effect(player,owned.type());if(current!=null&&current.getAmplifier()==owned.amplifier()){player.removePotionEffect(owned.type());if(owned.prior()!=null&&owned.prior().getDuration()>0)player.addPotionEffect(owned.prior(),true);if(observed)observe("effect.ended",owned.observation(),Map.of("reason","owned_room_layer_released","cleanup_status","native_restore_returned","prior_layer_status",owned.prior()==null?"absent":"restored_request"));}else if(observed)observe("effect.ended",owned.observation(),Map.of("reason","owned_room_layer_released","cleanup_status","unrelated_current_layer_preserved"));}
private static PotionEffect effect(LivingEntity entity,PotionEffectType type){return entity.getActivePotionEffects().stream().filter(value->value.getType().equals(type)).findFirst().orElse(null);}
private static PotionEffectType effectType(String key){var value=NamespacedKey.fromString(key);return value==null?null:PotionEffectType.getByKey(value);}
private void contain(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt){var world=plugin.getServer().getWorld(dungeon.world());if(world==null)return;for(var entity:safe(world.getEntities()))if(managed(entity)&&dungeon.id().equals(id(entity,dungeonKey))&&room.id().equals(id(entity,roomKey))&&!inside(entity.getLocation(),dungeon,room)){relocating.add(entity);try{entity.teleport(new Location(world,(room.bounds().minX()+room.bounds().maxX()+1)/2d,room.bounds().minY()+1,(room.bounds().minZ()+room.bounds().maxZ()+1)/2d));if(entity instanceof LivingEntity living)living.setVelocity(new org.bukkit.util.Vector());}finally{relocating.remove(entity);}}}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void damage(EntityDamageEvent event){if(managedEnemy(event.getEntity())){if(!(event instanceof EntityDamageByEntityEvent by)||source(by)==null||eligible(source(by),id(event.getEntity(),dungeonKey),id(event.getEntity(),roomKey))==null)event.setCancelled(true);return;}if(event instanceof EntityDamageByEntityEvent by&&managed(by.getDamager())){UUID dungeon=id(by.getDamager(),dungeonKey),room=id(by.getDamager(),roomKey);if(!(event.getEntity() instanceof Player player)||dungeon==null||room==null||eligible(player,dungeon,room)==null)event.setCancelled(true);}}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void accepted(EntityDamageByEntityEvent event){if(!managedEnemy(event.getEntity())||event.getFinalDamage()<=0||!Double.isFinite(event.getFinalDamage()))return;var player=source(event);UUID dungeon=id(event.getEntity(),dungeonKey),room=id(event.getEntity(),roomKey);var quest=player==null||dungeon==null||room==null?null:eligible(player,dungeon,room);var attempt=room==null?null:attempts.get(new AttemptKey(dungeon,room));if(quest!=null&&attempt!=null){attempt.represented.add(quest.id());attempt.threat.merge(player.getUniqueId(),event.getFinalDamage(),Double::sum);attempt.lastCombat=System.nanoTime();attempt.cooldowns.remove("recovery");}}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void bossAccepted(EntityDamageByEntityEvent event){if(!(event.getDamager() instanceof Mob boss)||!managedEnemy(boss)||!(event.getEntity() instanceof Player player)||event.getFinalDamage()<=0)return;var room=dungeons.room(id(boss,dungeonKey),id(boss,roomKey)).orElse(null);var custody=room==null||room.boss()==null?null:room.boss().character();if(custody==null)return;if(custody.character().passives().contains("stick_knockback")&&passives.find("stick_knockback").isPresent()){double radians=Math.toRadians(boss.getLocation().getYaw()),x=Math.sin(radians),z=-Math.cos(radians);if(Double.isFinite(x)&&Double.isFinite(z))player.knockback(1,x,z);}}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void bossAccepted(EntityDamageByEntityEvent event){if(!(event.getDamager() instanceof Mob boss)||!managedEnemy(boss)||!(event.getEntity() instanceof Player player)||event.getFinalDamage()<=0)return;var room=dungeons.room(id(boss,dungeonKey),id(boss,roomKey)).orElse(null);var custody=room==null||room.boss()==null?null:room.boss().character();if(custody==null)return;var observation=characterMeleeFacts(boss,player,event);observeCharacterMeleeDamage(observation,event);if(custody.character().passives().contains("stick_knockback")&&passives.find("stick_knockback").isPresent()){double radians=Math.toRadians(boss.getLocation().getYaw()),x=Math.sin(radians),z=-Math.cos(radians);if(Double.isFinite(x)&&Double.isFinite(z)){var passive=characterMeleePassiveFacts(observation,boss,player,x,z);org.bukkit.util.Vector before=null;try{before=player.getVelocity().clone();}catch(RuntimeException unavailable){/* Optional observation only. */}var previous=characterMeleeKnockback.get();var calls=new ArrayList<NativeKnockback>();characterMeleeKnockback.set(calls);try{player.knockback(1,x,z);}finally{if(previous==null)characterMeleeKnockback.remove();else characterMeleeKnockback.set(previous);}observeCharacterMeleeKnockback(passive,player,before,calls);}}}
private games.dmg.fantasystick.events.CharacterBossEventBridge.Facts characterMeleeFacts(Mob boss,Player player,EntityDamageByEntityEvent event){
try{var events=characterBossEvents;var original=characterBossShellFacts.get(boss.getUniqueId());if(events==null||original==null)return null;var data=new LinkedHashMap<String,Object>();data.put("source_location",nativeLocation(boss.getLocation()));data.putAll(characterTargetData(player));data.put("damage_source",event.getCause()==EntityDamageEvent.DamageCause.ENTITY_ATTACK?"native_melee":"native_entity_damage");data.put("damage_cause",event.getCause().name());var cast=characterCasts.get().peek();if(cast==null)characterCasts.remove();else if(cast.shell.equals(boss.getUniqueId()))data.put("cast_id",cast.facts.request().action().toString());return events.transition(original,data);}catch(RuntimeException unavailable){return null;}
}
private void observeCharacterMeleeDamage(games.dmg.fantasystick.events.CharacterBossEventBridge.Facts facts,EntityDamageByEntityEvent event){
try{if(facts==null||characterBossEvents==null||facts.data().containsKey("cast_id"))return;nextTick.accept(()->{try{double amount=event.getFinalDamage();var data=new LinkedHashMap<String,Object>();data.put("outcome",event.isCancelled()?"cancelled":Double.isFinite(amount)&&amount>0?"accepted":"no_op");if(Double.isFinite(amount))data.put("final_damage",amount);else data.put("final_damage_status","non_finite_unquantified");data.put("native_cancelled",event.isCancelled());data.put("causation","original_native_character_shell_damage_event");data.put("confirmation_quality","final_native_event_decision_not_health_setter_proof");characterBossEvents.damage(facts,data);}catch(RuntimeException unavailable){/* Native-thread confirmation only; never gameplay authority. */}});}catch(RuntimeException unavailable){/* Optional metadata never affects original native damage. */}
}
private games.dmg.fantasystick.events.CharacterBossEventBridge.Facts characterMeleePassiveFacts(games.dmg.fantasystick.events.CharacterBossEventBridge.Facts melee,Mob boss,Player player,double x,double z){
try{if(melee==null||characterBossEvents==null)return null;return characterBossEvents.transition(melee,Map.of("passive_id","stick_knockback","melee_damage_operation_id",melee.request().action().toString(),"knockback_strength",1d,"direction_x",x,"direction_z",z,"target_location",nativeLocation(player.getLocation()),"source_location",nativeLocation(boss.getLocation())));}catch(RuntimeException unavailable){return null;}
}
private void observeCharacterMeleeKnockback(games.dmg.fantasystick.events.CharacterBossEventBridge.Facts facts,Player player,org.bukkit.util.Vector before,List<NativeKnockback> calls){
try{if(facts==null||before==null||characterBossEvents==null||calls.size()!=1)return;var nativeEvent=calls.getFirst().event();var after=player.getVelocity();if(nativeEvent.getEntity()!=player||nativeEvent.isCancelled()||after==null||!Double.isFinite(after.getX())||!Double.isFinite(after.getY())||!Double.isFinite(after.getZ())||after.equals(before))return;var data=new LinkedHashMap<String,Object>();data.put("outcome","accepted");data.put("effect_type","native_knockback");data.put("application_status","native_event_and_velocity_confirmed");data.put("velocity_before",Map.of("x",before.getX(),"y",before.getY(),"z",before.getZ()));data.put("velocity_after",Map.of("x",after.getX(),"y",after.getY(),"z",after.getZ()));data.put("native_final_cancelled",false);data.put("causation","original_native_character_shell_damage_passive");characterBossEvents.effectApplied(facts,facts.request().action(),data);}catch(RuntimeException unavailable){/* Native effect/velocity remain authoritative. */}
}
@EventHandler(priority=EventPriority.MONITOR)public void bossKill(org.bukkit.event.entity.PlayerDeathEvent event){if(!(event.getEntity().getLastDamageCause() instanceof EntityDamageByEntityEvent damage)||!(damage.getDamager() instanceof Mob boss)||!managedEnemy(boss))return;var room=dungeons.room(id(boss,dungeonKey),id(boss,roomKey)).orElse(null);var custody=room==null||room.boss()==null?null:room.boss().character();if(custody==null||!custody.character().passives().contains("health_steal")||passives.find("health_steal").isEmpty()||eligible(event.getPlayer(),id(boss,dungeonKey),id(boss,roomKey))==null)return;healBoss(boss,Math.min(Double.MAX_VALUE,healthStealHealing.get().doubleValue()));}
public void accept(AbilityReport report){if(report.type()==AbilityReport.Type.DAMAGE)return;var active=sessions.activeCharacter(report.sourceCharacter()).filter(sessions::current).orElse(null);if(active==null)return;var player=plugin.getServer().getPlayer(active.player());if(player==null)return;double value=report.amount().doubleValue();if(!Double.isFinite(value)||value<=0)return;for(var entry:attempts.entrySet())if(eligible(player,entry.getKey().dungeon(),entry.getKey().room())!=null)entry.getValue().threat.merge(player.getUniqueId(),value*.5d,Double::sum);}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void death(EntityDeathEvent event){if(!managedEnemy(event.getEntity()))return;UUID dungeonId=id(event.getEntity(),dungeonKey),roomId=id(event.getEntity(),roomKey),generation=id(event.getEntity(),generationKey);if(dungeonId==null||roomId==null||generation==null)return;var dungeon=dungeons.find(dungeonId).orElse(null);var room=dungeons.room(dungeonId,roomId).orElse(null);var attempt=attempts.get(new AttemptKey(dungeonId,roomId));if(dungeon==null||room==null||attempt==null)return;boolean boss=Boolean.TRUE.equals(event.getEntity().getPersistentDataContainer().get(bossKey,PersistentDataType.BOOLEAN));if(boss){attempt.boss=null;defeatingDungeons.add(dungeonId);cleanupDungeon(dungeonId);clearRoom(dungeon,room,attempt,true);return;}UUID slot=id(event.getEntity(),slotKey);attempt.entities.remove(event.getEntity().getUniqueId());if(slot==null)return;encounterOrigin(event.getEntity()).ifPresent(origin->pendingKills.putIfAbsent(origin.source(),new PendingKill(dungeonId,roomId,generation,slot)));}
private void clearRoom(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,boolean boss){var key=new AttemptKey(dungeon.id(),room.id());if(!completing.add(key))return;var quests=attempt.represented.stream().map(adventures::findQuest).flatMap(Optional::stream).toList();var awards=quests.stream().map(quest->awardQuest(dungeon,room,attempt.generation,quest,boss)).toArray(CompletableFuture[]::new);var completion=CompletableFuture.allOf(awards).thenCompose(ignored->dungeons.markCleared(dungeon.id(),attempt.generation,room.id())).thenCompose(run->boss?dungeons.defeat(dungeon.id(),attempt.generation):CompletableFuture.completedFuture(run)).thenCompose(run->boss?adventures.expireOfferQuests(dungeon.id()).thenApply(ignored->run):CompletableFuture.completedFuture(run)).whenComplete((run,failure)->schedule(()->{completing.remove(key);if(boss)defeatingDungeons.remove(dungeon.id());if(failure!=null){plugin.getLogger().log(java.util.logging.Level.WARNING,"Dungeon room completion failed",failure);return;}cleanupAttempt(key);if(boss)cleanupDungeon(dungeon.id());}));inFlight.add(completion);completion.whenComplete((ignored,failure)->inFlight.remove(completion));}
private CompletableFuture<Void> awardQuest(DungeonState.Dungeon dungeon,DungeonState.Room room,UUID generation,AdventureQuestState.Quest quest,boolean completeQuest){var run=dungeons.runView(dungeon.id(),generation).orElse(null);if(run==null||run.unitClaims().contains(new DungeonState.UnitClaim(room.id(),quest.id())))return CompletableFuture.completedFuture(null);var recipients=recipients(dungeon,room,quest,run);if(recipients.isEmpty())return CompletableFuture.completedFuture(null);if(!completeQuest)return dungeons.clearRoom(dungeon.id(),generation,room.id(),quest.id(),Set.copyOf(recipients)).thenApply(ignored->null);if(fishing==null)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon trophy persistence unavailable"));var unit=new RewardAllocator.Unit<UUID>(quest.unit(),recipients);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(UUID character:recipients)rewards.put(character,new FishingState.Reward(xp.get(character),coins.get(character)));UUID carrier=quest.unitType()==AdventureQuestState.UnitType.SOLO?quest.unit():recipients.stream().map(parties::partyOfCharacter).flatMap(Optional::stream).filter(value->value.id().equals(quest.unit())).map(PartyRoster.Party::leaderCharacter).findFirst().orElse(null);if(carrier==null)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon trophy carrier unavailable"));UUID trophy=UUID.nameUUIDFromBytes(("dungeon:"+quest.id()).getBytes(StandardCharsets.UTF_8));var plan=new FishingState.Trophy(trophy,FishingState.TrophyType.DUNGEON,carrier,quest.catalog(),dungeon.id(),quest.id(),generation,"Trophy of "+dungeon.name(),dungeon.name(),rewards);return fishing.earnTrophy(plan).thenCompose(earned->{if(!earned)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon trophy persistence refused"));return dungeons.clearRoom(dungeon.id(),generation,room.id(),quest.id(),Set.copyOf(recipients));}).thenApply(ignored->(Void)null).toCompletableFuture();}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void death(EntityDeathEvent event){if(!managedEnemy(event.getEntity()))return;UUID dungeonId=id(event.getEntity(),dungeonKey),roomId=id(event.getEntity(),roomKey),generation=id(event.getEntity(),generationKey);if(dungeonId==null||roomId==null||generation==null)return;var dungeon=dungeons.find(dungeonId).orElse(null);var room=dungeons.room(dungeonId,roomId).orElse(null);var attempt=attempts.get(new AttemptKey(dungeonId,roomId));if(dungeon==null||room==null||attempt==null)return;boolean boss=Boolean.TRUE.equals(event.getEntity().getPersistentDataContainer().get(bossKey,PersistentDataType.BOOLEAN));if(boss){var origin=encounterOrigin(event.getEntity());if(origin.isEmpty())return;endNativeEffects(event.getEntity(),"accepted_native_death");observeCharacterShellEffectsEnded(event.getEntity(),"accepted_native_death");captureBossDefeat(attempt,event.getEntity(),room);if(room.boss().character()==null)attempt.deathObservation=nativeFacts(dungeon,room,attempt,event.getEntity(),null,true,nativeTrigger(event),"death_location");else if(attempt.characterDeathObservation==null){attempt.characterDeathObservation=characterShellTransition(attempt,event.getEntity(),"death_location",nativeTrigger(event));characterBossShellFacts.remove(event.getEntity().getUniqueId());}pendingBosses.putIfAbsent(origin.orElseThrow().source(),event.getEntity().getUniqueId());attempt.boss=null;defeatingDungeons.add(dungeonId);cleanupDungeon(dungeonId);clearRoom(dungeon,room,attempt,true);return;}UUID slot=id(event.getEntity(),slotKey);var origin=encounterOrigin(event.getEntity());if(slot==null||origin.isEmpty())return;endNativeEffects(event.getEntity(),"accepted_native_death");attempt.entities.remove(event.getEntity().getUniqueId());pendingKills.putIfAbsent(origin.orElseThrow().source(),new PendingKill(event.getEntity().getUniqueId(),dungeonId,roomId,generation,slot,nativeFacts(dungeon,room,attempt,event.getEntity(),slot,false,nativeTrigger(event),"death_location")));}
private void captureBossDefeat(Attempt attempt,LivingEntity entity,DungeonState.Room room){
if(attempt.defeatedBoss!=null)return;
attempt.defeatedBoss=entity.getUniqueId();var location=entity.getLocation();
var audit=new LinkedHashMap<String,String>();audit.put("boss_id",attempt.defeatedBoss.toString());
audit.put("room_id",room.id().toString());
audit.put("defeated_at",java.time.Instant.now().truncatedTo(java.time.temporal.ChronoUnit.SECONDS).toString());
if(location!=null&&location.getWorld()!=null){var world=location.getWorld();
audit.put("defeat_world_id",world.getUID().toString());
audit.put("defeat_world_name",Objects.toString(world.getName(),"unavailable"));
audit.put("defeat_x",Double.toString(location.getX()));
audit.put("defeat_y",Double.toString(location.getY()));
audit.put("defeat_z",Double.toString(location.getZ()));
}
attempt.defeatAudit=Map.copyOf(audit);
}
private void clearRoom(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,boolean boss){
var key=new AttemptKey(dungeon.id(),room.id());if(!completing.add(key))return;
var quests=attempt.represented.stream().map(adventures::findQuest).flatMap(Optional::stream).toList();
var awards=quests.stream().map(quest->awardQuest(dungeon,room,attempt,quest,boss))
.toArray(CompletableFuture[]::new);
var completion=CompletableFuture.allOf(awards)
.thenCompose(ignored->dungeons.markCleared(dungeon.id(),attempt.generation,room.id()))
.thenCompose(run->boss?dungeons.defeat(dungeon.id(),attempt.generation,
completionAudit(dungeon,attempt,quests)):CompletableFuture.completedFuture(run))
.thenCompose(run->boss?adventures.expireOfferQuests(dungeon.id()).thenApply(ignored->run):CompletableFuture.completedFuture(run))
.whenComplete((run,failure)->schedule(()->{
completing.remove(key);if(boss)defeatingDungeons.remove(dungeon.id());
if(failure!=null){plugin.getLogger().log(java.util.logging.Level.WARNING,"Dungeon room completion failed",failure);return;}
observe("dungeon.room.cleared",attempt.observation,Map.of("reason",boss?"boss_room_progress_committed":"ordinary_or_empty_room_progress_committed","room_cleared",run.clearedRooms().contains(room.id()),"represented_quest_ids",quests.stream().map(value->value.id().toString()).sorted().toList(),"represented_units",quests.stream().sorted(Comparator.comparing(AdventureQuestState.Quest::id)).map(DungeonController::questData).toList(),"reward_status","no_room_reward"));
cleanupAttempt(key);
if(boss){observeCharacterBossDefeat(attempt.characterDeathObservation);observe("dungeon.entity.defeated",attempt.deathObservation,Map.of("reason","native_boss_completion_committed","settlement_status","acknowledged_boss_completion","defeated_slot",false));observe("dungeon.run.closed",attempt.deathObservation==null?attempt.observation:attempt.deathObservation,Map.of("reason","boss_completion_global_close","run_defeated",run.defeated(),"reward_owner","US-085","completion_status","acknowledged_global_close"));cleanupDungeon(dungeon.id());
if(auditEvents!=null)try{auditEvents.completed(run);}catch(RuntimeException error){
plugin.getLogger().log(java.util.logging.Level.WARNING,
"Committed dungeon completion audit publication failed",error);
}
}
}));
inFlight.add(completion);completion.whenComplete((ignored,failure)->inFlight.remove(completion));
}
private Map<String,String> completionAudit(DungeonState.Dungeon dungeon,Attempt attempt,
List<AdventureQuestState.Quest> quests){
if(attempt.defeatedBoss==null||!attempt.defeatAudit.containsKey("defeated_at"))
throw new IllegalStateException("Native dungeon boss proof unavailable");
var audit=new LinkedHashMap<>(attempt.defeatAudit);
audit.put("dungeon_name",dungeon.name());
var represented=quests.stream().filter(quest->fishing.earnedTrophy(UUID.nameUUIDFromBytes(
("dungeon:"+quest.id()).getBytes(StandardCharsets.UTF_8))))
.sorted(Comparator.comparing(AdventureQuestState.Quest::id)).toList();
audit.put("represented_count",Integer.toString(represented.size()));
for(int index=0;index<represented.size();index++){
var quest=represented.get(index);
var prefix="represented_"+index+"_";
audit.put(prefix+"quest_id",quest.id().toString());
audit.put(prefix+"unit_kind",quest.unitType().name().toLowerCase(Locale.ROOT));
audit.put(prefix+"unit_id",quest.unit().toString());
audit.put(prefix+"accepted_roster",quest.snapshot().stream().sorted()
.map(UUID::toString).collect(java.util.stream.Collectors.joining(",")));
audit.put(prefix+"receipt_id",UUID.nameUUIDFromBytes(("dungeon:"+quest.id())
.getBytes(StandardCharsets.UTF_8)).toString());
}
return audit;
}
public void replayCompletions(){
var events=auditEvents;if(events==null)return;
dungeons.pendingCompletions(run->{try{return games.dmg.fantasystick.events.DungeonEventBridge.exported(
plugin.getDataFolder().toPath(),
games.dmg.fantasystick.events.DungeonEventBridge.completionEventId(run),
run.dungeon(),run.generation(),UUID.fromString(run.completionAudit().get("boss_id")));}
catch(java.io.IOException error){throw new java.io.UncheckedIOException(error);}})
.whenComplete((pending,failure)->{
if(failure!=null){plugin.getLogger().log(java.util.logging.Level.WARNING,
"Committed dungeon completion export reconciliation failed",failure);return;}
for(var run:pending)try{events.completed(run);}catch(RuntimeException error){
plugin.getLogger().log(java.util.logging.Level.WARNING,
"Committed dungeon completion audit replay failed",error);
}
});
}
private CompletableFuture<Void> awardQuest(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,AdventureQuestState.Quest quest,boolean completeQuest){UUID generation=attempt.generation;var run=dungeons.runView(dungeon.id(),generation).orElse(null);if(run==null||run.unitClaims().contains(new DungeonState.UnitClaim(room.id(),quest.id())))return CompletableFuture.completedFuture(null);var recipients=recipients(dungeon,room,quest,run);if(recipients.isEmpty())return CompletableFuture.completedFuture(null);if(!completeQuest)return dungeons.clearRoom(dungeon.id(),generation,room.id(),quest.id(),Set.copyOf(recipients)).thenApply(ignored->null);if(fishing==null)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon trophy persistence unavailable"));var unit=new RewardAllocator.Unit<UUID>(quest.unit(),recipients);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(UUID character:recipients)rewards.put(character,new FishingState.Reward(xp.get(character),coins.get(character)));UUID carrier=quest.unitType()==AdventureQuestState.UnitType.SOLO?quest.unit():recipients.stream().map(parties::partyOfCharacter).flatMap(Optional::stream).filter(value->value.id().equals(quest.unit())).map(PartyRoster.Party::leaderCharacter).findFirst().orElse(null);if(carrier==null)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon trophy carrier unavailable"));UUID trophy=UUID.nameUUIDFromBytes(("dungeon:"+quest.id()).getBytes(StandardCharsets.UTF_8));var audit=new LinkedHashMap<String,String>(attempt.defeatAudit);audit.put("unit_kind",quest.unitType().name().toLowerCase(Locale.ROOT));audit.put("unit_id",quest.unit().toString());audit.put("accepted_roster",quest.snapshot().stream().sorted().map(UUID::toString).collect(java.util.stream.Collectors.joining(",")));var plan=new FishingState.Trophy(trophy,FishingState.TrophyType.DUNGEON,carrier,quest.catalog(),dungeon.id(),quest.id(),generation,"Trophy of "+dungeon.name(),dungeon.name(),rewards,FishingState.TrophyPhase.PREPARED,audit);return fishing.earnTrophy(plan).thenCompose(earned->{if(!earned)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon trophy persistence refused"));return dungeons.clearRoom(dungeon.id(),generation,room.id(),quest.id(),Set.copyOf(recipients));}).thenApply(ignored->(Void)null).toCompletableFuture();}
private List<UUID> recipients(DungeonState.Dungeon dungeon,DungeonState.Room room,AdventureQuestState.Quest quest,DungeonState.Run run){var result=new ArrayList<UUID>();for(UUID character:quest.snapshot()){if(run.characterClaims().contains(new DungeonState.CharacterClaim(room.id(),character)))continue;var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);if(active==null)continue;var player=plugin.getServer().getPlayer(active.player());if(player==null||!inside(player.getLocation(),dungeon,room))continue;if(quest.unitType()==AdventureQuestState.UnitType.PARTY){var party=parties.partyOf(player.getUniqueId()).orElse(null);if(party==null||!party.id().equals(quest.unit())||!party.characters().contains(character))continue;}result.add(character);}return result.stream().distinct().sorted().toList();}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void target(EntityTargetLivingEntityEvent event){if(managedEnemy(event.getEntity())&&event.getTarget()!=null){UUID dungeon=id(event.getEntity(),dungeonKey);UUID room=id(event.getEntity(),roomKey);var attempt=dungeon==null||room==null?null:attempts.get(new AttemptKey(dungeon,room));if(!(event.getTarget() instanceof Player player)||dungeon==null||room==null||eligible(player,dungeon,room)==null||attempt!=null&&attempt.target!=null&&!attempt.target.equals(player.getUniqueId()))event.setCancelled(true);}}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void projectile(ProjectileLaunchEvent event){if(event.getEntity().getShooter() instanceof Entity shooter&&managedEnemy(shooter)){var data=event.getEntity().getPersistentDataContainer();data.set(typeKey,PersistentDataType.STRING,PROJECTILE);copy(data,dungeonKey,shooter);copy(data,roomKey,shooter);copy(data,generationKey,shooter);}}
@@ -140,7 +279,69 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void teleport(EntityTeleportEvent event){if(managed(event.getEntity())&&!relocating.contains(event.getEntity()))event.setCancelled(true);}
private boolean managedEnemy(Entity entity){return entity!=null&&ENEMY.equals(entity.getPersistentDataContainer().get(typeKey,PersistentDataType.STRING));}
private Player source(EntityDamageByEntityEvent event){if(event.getDamager() instanceof Player player)return player;if(event.getDamager() instanceof Projectile projectile&&projectile.getShooter() instanceof Player player)return player;return null;}
private void cleanupAttempt(AttemptKey key){var attempt=attempts.remove(key);if(attempt==null)return;for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(managed(entity)&&key.dungeon().equals(id(entity,dungeonKey))&&key.room().equals(id(entity,roomKey)))entity.remove();attempt.entities.clear();attempt.boss=null;}
private games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts facts(DungeonState.Dungeon dungeon,DungeonState.Room room,UUID generation,Player trigger,Map<String,Object> details){var events=ownerEvents;if(events==null)return null;try{return events.runtime(UUID.randomUUID(),trigger==null?null:events.actor(trigger),dungeon,room,generation,details);}catch(RuntimeException unavailable){return null;}}
private void observe(String type,games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts facts,Map<String,Object> details){var events=ownerEvents;if(events==null||facts==null)return;try{events.observed(type,facts,details);}catch(RuntimeException failure){plugin.getLogger().log(java.util.logging.Level.WARNING,"Dungeon observation unavailable",failure);}}
private static Map<String,Object> questData(AdventureQuestState.Quest quest){if(quest==null)return Map.of("trigger_quest_status","unavailable");return Map.of("quest_id",quest.id().toString(),"unit_id",quest.unit().toString(),"unit_kind",quest.unitType().name().toLowerCase(Locale.ROOT),"accepted_mode",quest.participationMode().value(),"accepted_roster",quest.snapshot().stream().sorted().map(UUID::toString).toList());}
private static Player nativeTrigger(EntityDeathEvent event){try{return event.getDamageSource()!=null&&event.getDamageSource().getCausingEntity() instanceof Player player?player:null;}catch(RuntimeException unavailable){return null;}}
private static Map<String,Object> nativeLocation(Location at){if(at==null||at.getWorld()==null)return Map.of("location_status","unavailable");return Map.of("world_id",at.getWorld().getUID().toString(),"world_name",Objects.toString(at.getWorld().getName(),"unavailable"),"x",at.getX(),"y",at.getY(),"z",at.getZ(),"yaw",(double)at.getYaw(),"pitch",(double)at.getPitch(),"captured_at",java.time.Instant.now().toString(),"quality","native_original");}
private games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts nativeFacts(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,Entity entity,UUID slot,boolean boss,Player trigger,String locationField){try{if(ownerEvents==null)return null;var data=new LinkedHashMap<String,Object>();data.put("native_entity_id",entity.getUniqueId().toString());data.put("entity_type",entity.getType().name());data.put("entity_role",boss?"native_boss":"ordinary_slot");data.put("entity_name",boss?room.boss().name():room.mobs().stream().filter(value->value.id().equals(slot)).map(DungeonState.Spawn::name).findFirst().orElse("unavailable"));if(slot!=null)data.put("spawn_slot_id",slot.toString());data.put(locationField,nativeLocation(entity.getLocation()));data.put("managed_source_id",UUID.nameUUIDFromBytes((dungeon.id()+":"+room.id()+":"+attempt.generation+":"+(slot==null?"boss":slot.toString())).getBytes(StandardCharsets.UTF_8)).toString());data.put("damage_action_capture_quality",locationField.equals("death_location")?"unavailable_not_in_native_death":"not_applicable_native_observation");if(attempt.observation!=null)data.put("activation_operation_id",attempt.observation.operation().toString());if(trigger==null&&!locationField.equals("death_location")&&attempt.observation!=null){var original=new LinkedHashMap<>(attempt.observation.data());original.putAll(data);var actor=attempt.observation.actor();if(actor!=null&&actor.context()!=null)original.put("triggering_player_id",actor.context().playerId().toString());return new games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts(UUID.randomUUID(),actor,original);}data.putAll(trigger==null?Map.of("trigger_quest_status","unavailable"):questData(eligible(trigger,dungeon.id())));if(trigger!=null)data.put("triggering_player_id",trigger.getUniqueId().toString());return facts(dungeon,room,attempt.generation,trigger,data);}catch(RuntimeException unavailable){return null;}}
private void observeSpawn(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,Entity entity,UUID slot,boolean boss){try{if(ownerEvents==null||entity==null||!entity.isValid()||!managedEnemy(entity)||!attempt.generation.equals(id(entity,generationKey))||!loaded(entity.getWorld(),new DungeonState.Position(entity.getLocation().getX(),entity.getLocation().getY(),entity.getLocation().getZ(),entity.getLocation().getYaw(),entity.getLocation().getPitch())))return;var observation=nativeFacts(dungeon,room,attempt,entity,slot,boss,null,"spawn_location");observe("dungeon.entity.spawned",observation,Map.of("materialization_status","loaded_valid_registered_entity","attempt_capture_quality","native_transient_not_persisted"));observeNativeEffects(room,entity,observation);}catch(RuntimeException unavailable){/* Observation cannot undo returned native calls. */}}
private void observeCharacterBossSpawn(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,Entity entity){try{var events=characterBossEvents;if(events==null||entity==null||!entity.isValid()||!managedEnemy(entity)||!attempt.generation.equals(id(entity,generationKey)))return;var at=entity.getLocation();if(at==null||!loaded(entity.getWorld(),new DungeonState.Position(at.getX(),at.getY(),at.getZ(),at.getYaw(),at.getPitch())))return;var facts=events.runtime(dungeon,room,attempt.generation,entity.getUniqueId(),entity.getType().name(),nativeLocation(at));characterBossShellFacts.put(entity.getUniqueId(),facts);events.spawned(facts);if(entity instanceof LivingEntity living)observeCharacterPassives(room,living,facts,at);}catch(RuntimeException unavailable){/* Optional confirmation cannot undo or interrupt character-boss native materialization. */}}
private void observeCharacterPassives(DungeonState.Room room,LivingEntity shell,games.dmg.fantasystick.events.CharacterBossEventBridge.Facts facts,Location at){
try{var audit=characterBossEffects;if(audit==null)return;for(var id:room.boss().character().character().passives()){
PotionEffectType type=switch(id){case "speed_i"->PotionEffectType.SPEED;case "low_light_vision"->PotionEffectType.NIGHT_VISION;default->null;};
try{if(type!=null&&passives.find(id).isPresent())audit.passive(facts,shell,id,new PotionEffect(type,PotionEffect.INFINITE_DURATION,0,false,false),nativeLocation(at));}catch(RuntimeException unavailable){/* One optional layer cannot hide another independent equipped passive. */}
}}catch(RuntimeException unavailable){/* Native spawn and passive state remain authoritative. */}
}
private games.dmg.fantasystick.events.CharacterBossEventBridge.Facts characterShellTransition(Attempt attempt,Entity entity,String locationField,Player trigger){
try{var events=characterBossEvents;var original=characterBossShellFacts.get(entity.getUniqueId());if(events==null||original==null||!entity.isValid()||!managedEnemy(entity)||!entity.getUniqueId().equals(attempt.boss)||!attempt.generation.equals(id(entity,generationKey)))return null;var at=entity.getLocation();var data=new LinkedHashMap<String,Object>();data.put(locationField,nativeLocation(at));data.put("damage_action_capture_quality",locationField.equals("death_location")?"unavailable_not_in_native_death":"not_applicable_native_observation");if(trigger!=null){data.put("triggering_player_id",trigger.getUniqueId().toString());data.put("triggering_player_location",nativeLocation(trigger.getLocation()));data.putAll(questData(eligible(trigger,attempt.dungeon)));}return events.transition(original,data);}catch(RuntimeException unavailable){return null;}
}
private Map<String,Object> characterTargetData(Player target){var data=new LinkedHashMap<String,Object>();data.put("target_kind","accepted_fantasy_player");data.put("target_player_id",target.getUniqueId().toString());data.put("target_location",nativeLocation(target.getLocation()));sessions.active(target.getUniqueId()).ifPresent(active->data.put("target_character_id",active.character().toString()));return data;}
private CharacterCast beginCharacterCast(Mob boss,Player target,AbilityDefinition definition,int slot){
try{var events=characterBossEvents;var original=characterBossShellFacts.get(boss.getUniqueId());if(events==null||original==null)return null;var data=new LinkedHashMap<String,Object>();data.put("ability_id",definition.id());data.put("ability_category",definition.category().name());data.put("ability_slot_index",Integer.toString(slot));data.put("cooldown_millis",Long.toString(definition.cooldown().toMillis()));data.put("cooldown_status","ready_at_original_native_attempt");data.put("source_location",nativeLocation(boss.getLocation()));if(definition.category()==AbilityDefinition.Category.HEAL||definition.category()==AbilityDefinition.Category.BUF||definition.id().equals("purify")){data.put("target_kind","native_character_shell");data.put("target_entity_id",boss.getUniqueId().toString());data.put("target_location",nativeLocation(boss.getLocation()));}else data.putAll(characterTargetData(target));var facts=events.cast(original,data);var observation=new CharacterCast(facts,boss.getUniqueId());events.casting(facts);characterCasts.get().push(observation);return observation;}catch(RuntimeException unavailable){return null;}
}
@EventHandler(priority=EventPriority.MONITOR)public void characterAbilityDamage(EntityDamageByEntityEvent event){
try{var stack=characterCasts.get();var cast=stack.peek();if(cast==null){characterCasts.remove();return;}if(characterBossEvents==null||!cast.shell.equals(event.getDamager().getUniqueId())||!(event.getEntity() instanceof Player player))return;cast.damage.add(new ObservedCharacterDamage(characterBossEvents.transition(cast.facts,characterTargetData(player)),event));}catch(RuntimeException unavailable){/* Retain no gameplay authority or asynchronous native event reference. */}
}
private boolean characterPotion(LivingEntity target,PotionEffect requested,boolean force){
CharacterPotion call=null;
try{var cast=characterCasts.get().peek();if(cast==null)characterCasts.remove();if(cast!=null&&characterBossEffects!=null&&characterBossEvents!=null){var data=new LinkedHashMap<String,Object>();data.put("target_entity_id",target.getUniqueId().toString());data.put("target_location",nativeLocation(target.getLocation()));if(target instanceof Player player)data.putAll(characterTargetData(player));else data.put("target_kind","native_character_shell");data.put("native_call_capture_quality","exact_synchronous_target_and_requested_effect");call=new CharacterPotion(cast,characterBossEvents.transition(cast.facts,data),target,requested,CharacterBossEffectAudit.layer(target,requested.getType()),new ArrayList<>());}}catch(RuntimeException unavailable){/* New metadata cannot suppress the original add call. */}
if(call!=null)characterPotions.get().push(call);
try{boolean returned=target.addPotionEffect(requested,force);try{if(call!=null)call.cast().effects.add(new CharacterBossEffectAudit.NativeCall(call.facts(),target,requested,call.prior(),returned,call.nativeEvents()));}catch(RuntimeException unavailable){/* Audit never changes the returned native decision. */}return returned;}
finally{if(call!=null){var stack=characterPotions.get();stack.remove(call);if(stack.isEmpty())characterPotions.remove();}}
}
@EventHandler(priority=EventPriority.MONITOR)public void characterAbilityPotion(EntityPotionEffectEvent event){
try{var audit=characterBossEffects;if(audit==null)return;if(event.getAction()==EntityPotionEffectEvent.Action.REMOVED||event.getAction()==EntityPotionEffectEvent.Action.CHANGED)audit.removal(event);
var call=characterPotions.get().peek();if(call==null){characterPotions.remove();return;}
if(event.getCause()!=EntityPotionEffectEvent.Cause.PLUGIN||event.getEntity()!=call.target()||!call.requested().equals(event.getNewEffect())
||event.getSource()!=null&&!call.cast().shell.equals(event.getSource().getUniqueId()))return;
call.nativeEvents().add(event);
}catch(RuntimeException unavailable){/* Native potion decisions remain authoritative. */}
}
private games.dmg.fantasystick.events.CharacterBossEventBridge.Facts characterPassiveHealingFacts(Mob boss,DungeonState.CharacterBoss custody,String source,java.math.BigInteger base,java.math.BigInteger bonus){
try{var events=characterBossEvents;var original=characterBossShellFacts.get(boss.getUniqueId());if(events==null||original==null)return null;var at=nativeLocation(boss.getLocation());var data=new LinkedHashMap<String,Object>();data.put("healing_source",source);data.put("recovery_base_amount",base.toString());data.put("recovery_bonus_amount",bonus.toString());var ids=new ArrayList<String>();if(custody.character().passives().contains("quicker_regeneration_i")){ids.add("quicker_regeneration_i");if(custody.character().passives().contains("quicker_regeneration_ii"))ids.add("quicker_regeneration_ii");}data.put("recovery_bonus_passive_ids",List.copyOf(ids));data.put("bonus_component_amounts_status","unavailable_from_combined_native_bonus_port");data.put("source_location",at);data.put("target_location",at);data.put("target_entity_id",boss.getUniqueId().toString());data.put("target_kind","native_character_shell");if(custody.character().passives().contains("quicker_regeneration_i")&&passives.find("quicker_regeneration_i").isPresent())data.put("passive_id","quicker_regeneration_i");return events.transition(original,data);}catch(RuntimeException unavailable){return null;}
}
private void observeCharacterHealing(Mob boss,EntityRegainHealthEvent event,double requested,double before,double after,games.dmg.fantasystick.events.CharacterBossEventBridge.Facts passiveFacts){
try{var cast=passiveFacts==null?characterCasts.get().peek():null;if(passiveFacts==null&&cast==null){characterCasts.remove();return;}if(characterBossEvents==null||cast!=null&&!cast.shell.equals(boss.getUniqueId()))return;var facts=passiveFacts==null?cast.facts:passiveFacts;double applied=Math.max(0,after-before);var outcome=event.isCancelled()?"cancelled":applied>0?"accepted":"no_op";if(cast!=null)cast.nativeOutcome=outcome;var data=new LinkedHashMap<String,Object>();data.put("outcome",outcome);data.put("requested_amount",requested);if(Double.isFinite(event.getAmount()))data.put("native_event_amount",event.getAmount());else data.put("native_event_amount_status","non_finite_refused");data.put("applied_amount",applied);data.put("health_before",before);data.put("health_after",after);data.put("native_cancelled",event.isCancelled());data.put("causation","original_character_boss_native_regain_event");characterBossEvents.healed(characterBossEvents.transition(facts,Map.of("target_entity_id",boss.getUniqueId().toString(),"target_kind","native_character_shell")),data);}catch(RuntimeException unavailable){/* Actual native event/health setter remains authority. */}
}
private void completeCharacterCast(CharacterCast cast,boolean cooldown,AbilityDefinition.Category category){
try{if(cast==null||characterBossEvents==null)return;String outcome=cast.nativeOutcome;for(var observation:cast.damage){var event=observation.event();double amount=event.getFinalDamage();String result=event.isCancelled()?"cancelled":Double.isFinite(amount)&&amount>0?"accepted":"no_op";var data=new LinkedHashMap<String,Object>();data.put("outcome",result);if(Double.isFinite(amount))data.put("final_damage",amount);else data.put("final_damage_status","non_finite_refused");if(Double.isFinite(event.getDamage()))data.put("native_requested_damage",event.getDamage());data.put("native_cancelled",event.isCancelled());data.put("damage_cause",event.getCause().name());data.put("causation","original_character_boss_native_damage_event");characterBossEvents.damage(observation.facts(),data);if(outcome==null||result.equals("accepted"))outcome=result;}String effectOutcome=characterBossEffects==null?null:characterBossEffects.resolve(cast.effects);if(effectOutcome!=null){var details=new LinkedHashMap<String,Object>();details.put("native_effect_outcome",effectOutcome);var original=new LinkedHashMap<>(cast.facts.data());original.putAll(details);var resolved=new games.dmg.fantasystick.events.CharacterBossEventBridge.Facts(cast.facts.request(),cast.facts.custody(),original);if(outcome==null)outcome=!cooldown?"no_op":category==AbilityDefinition.Category.ATK?"native_damage_unobserved":"native_call_returned";characterBossEvents.resolved(resolved,cooldown,outcome);}else{if(outcome==null)outcome=!cooldown?"no_op":category==AbilityDefinition.Category.ATK?"native_damage_unobserved":"native_call_returned";characterBossEvents.resolved(cast.facts,cooldown,outcome);}}catch(RuntimeException unavailable){/* Observation cannot commit/refund/change a cooldown. */}
}
private void endCharacterCast(CharacterCast cast){if(cast==null)return;var stack=characterCasts.get();stack.remove(cast);if(stack.isEmpty())characterCasts.remove();}
private games.dmg.fantasystick.events.CharacterBossEventBridge.Facts characterTargetFacts(Mob boss,Player target,LivingEntity previous,Attempt attempt){
try{var events=characterBossEvents;var original=characterBossShellFacts.get(boss.getUniqueId());if(events==null||original==null)return null;var data=new LinkedHashMap<String,Object>();data.put("source_location",nativeLocation(boss.getLocation()));data.put("target_player_id",target.getUniqueId().toString());data.put("target_location",nativeLocation(target.getLocation()));data.put("target_kind","accepted_fantasy_player");data.put("accepted_threat",attempt.threat.getOrDefault(target.getUniqueId(),0d));data.put("reason",previous==null?"highest_accepted_threat":"fixation_invalidated_retarget");sessions.active(target.getUniqueId()).ifPresent(active->data.put("target_character_id",active.character().toString()));if(previous instanceof Player player)data.put("previous_target_player_id",player.getUniqueId().toString());data.putAll(questData(eligible(target,attempt.dungeon)));return events.transition(original,data);}catch(RuntimeException unavailable){return null;}
}
private void observeCharacterTarget(games.dmg.fantasystick.events.CharacterBossEventBridge.Facts facts,Mob boss,Player target){try{if(characterBossEvents!=null&&facts!=null&&boss.getTarget()==target)characterBossEvents.targetChanged(facts);}catch(RuntimeException unavailable){/* Optional confirmation cannot interrupt native targeting or subsequent ability execution. */}}
private void observeCharacterBossDefeat(games.dmg.fantasystick.events.CharacterBossEventBridge.Facts facts){try{if(characterBossEvents!=null&&facts!=null)characterBossEvents.defeated(facts);}catch(RuntimeException unavailable){/* Optional publication cannot affect acknowledged native settlement. */}}
private void observeCharacterShellEffectsEnded(Entity entity,String reason){var audit=characterBossEffects;if(audit==null)return;boolean confirmed;try{confirmed="accepted_native_death".equals(reason)||!entity.isValid();}catch(RuntimeException unavailable){try{audit.shellEndUnconfirmed(entity,reason,"remove_returned_validity_unavailable");}catch(RuntimeException optional){/* No native layer mutation or cleanup authority. */}return;}try{if(confirmed)audit.shellEnded(entity,reason);else audit.shellEndUnconfirmed(entity,reason,"remove_returned_still_valid");}catch(RuntimeException unavailable){/* No native layer mutation or cleanup authority. */}}
private void observeCharacterBossRemoval(games.dmg.fantasystick.events.CharacterBossEventBridge.Facts facts,Entity entity,String reason){try{if(characterBossEvents==null||facts==null)return;String status;try{status=entity.isValid()?"remove_returned_still_valid":"native_no_longer_valid";}catch(RuntimeException unavailable){status="remove_returned_validity_unavailable";}characterBossEvents.removed(facts,reason,status);}catch(RuntimeException unavailable){/* Optional publication cannot affect native cleanup. */}}
private final Map<UUID,List<games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts>> observedNativeEffects=new HashMap<>();
private void observeNativeEffects(DungeonState.Room room,Entity entity,games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts spawn){try{if(spawn==null||!(entity instanceof LivingEntity living)||observedNativeEffects.containsKey(entity.getUniqueId()))return;var layers=new ArrayList<games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts>();for(var definition:room.effects()){if(definition.target()==DungeonState.EffectTarget.PLAYERS)continue;var type=effectType(definition.effect());var current=type==null?null:effect(living,type);if(current==null||current.getAmplifier()!=definition.level()-1)continue;var data=new LinkedHashMap<>(spawn.data());data.put("effect_id",definition.id().toString());data.put("effect",definition.effect());data.put("level",Integer.toString(definition.level()));data.put("particles",definition.particles());data.put("target_kind","managed_mob");data.put("action_location",data.get("spawn_location"));var operation=UUID.nameUUIDFromBytes((spawn.operation()+":"+definition.id()).getBytes(StandardCharsets.UTF_8));var layer=new games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts(operation,spawn.actor(),data);layers.add(layer);observe("effect.applied",layer,Map.of("application_status","loaded_valid_native_layer_confirmed","reason","configured_room_mob_effect"));}if(!layers.isEmpty())observedNativeEffects.put(entity.getUniqueId(),List.copyOf(layers));}catch(RuntimeException unavailable){/* Native application and ownership never depend on observation. */}}
private void endNativeEffects(Entity entity,String reason){var layers=observedNativeEffects.remove(entity.getUniqueId());if(layers!=null)for(var layer:layers)observe("effect.ended",layer,Map.of("reason",reason,"cleanup_status","native_entity_lifecycle_ended","reward_status","no_effect_reward"));}
private games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts effectFacts(UUID dungeon,DungeonState.Room room,Player player,DungeonState.Effect effect){try{if(ownerEvents==null)return null;var definition=dungeons.find(dungeon).orElse(null);var quest=eligible(player,dungeon);if(definition==null||quest==null)return null;var data=new LinkedHashMap<String,Object>(questData(quest));data.put("effect_id",effect.id().toString());data.put("effect",effect.effect());data.put("target_kind","player");data.put("target_player_id",player.getUniqueId().toString());data.put("level",Integer.toString(effect.level()));data.put("particles",effect.particles());data.put("action_location",nativeLocation(player.getLocation()));return facts(definition,room,quest.catalog(),player,data);}catch(RuntimeException unavailable){return null;}}
private void cleanupAttempt(AttemptKey key){cleanupAttempt(key,"runtime_room_cleanup");}
private void cleanupAttempt(AttemptKey key,String reason){var attempt=attempts.remove(key);if(attempt==null)return;for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(managed(entity)&&key.dungeon().equals(id(entity,dungeonKey))&&key.room().equals(id(entity,roomKey))){var observation=removalFacts(key,attempt,entity);var characterObservation=characterShellTransition(attempt,entity,"removal_location",null);entity.remove();observeCharacterBossRemoval(characterObservation,entity,reason);observeCharacterShellEffectsEnded(entity,reason);characterBossShellFacts.remove(entity.getUniqueId());endNativeEffects(entity,reason);if(observation!=null){String status;try{status=entity.isValid()?"remove_returned_still_valid":"native_no_longer_valid";}catch(RuntimeException unavailable){status="remove_returned_validity_unavailable";}observe("dungeon.entity.removed",observation,Map.of("reason",reason,"reward_status","non_reward_cleanup","cleanup_status",status));}}attempt.entities.clear();attempt.boss=null;}
private games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts removalFacts(AttemptKey key,Attempt attempt,Entity entity){try{if(ownerEvents==null||!entity.isValid()||!managedEnemy(entity)||!attempt.generation.equals(id(entity,generationKey)))return null;var dungeon=dungeons.find(key.dungeon()).orElse(null);var room=dungeons.room(key.dungeon(),key.room()).orElse(null);if(dungeon==null||room==null)return null;boolean boss=entity.getUniqueId().equals(attempt.boss);UUID slot=attempt.entities.get(entity.getUniqueId());if(!boss&&slot==null||boss&&(room.boss()==null||room.boss().character()!=null))return null;return nativeFacts(dungeon,room,attempt,entity,slot,boss,null,"removal_location");}catch(RuntimeException unavailable){return null;}}
private void cleanupDungeon(UUID dungeon){cleanupOwnedDungeon(dungeon);for(var key:new ArrayList<>(attempts.keySet()))if(key.dungeon().equals(dungeon))cleanupAttempt(key);for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(managed(entity)&&dungeon.equals(id(entity,dungeonKey)))entity.remove();}
private void cleanupRuntime(){for(var key:new ArrayList<>(attempts.keySet()))cleanupAttempt(key);cleanupOwnedEffects(Set.of());completing.clear();defeatingDungeons.clear();startingRuns.clear();}
private void schedule(Runnable task){if(closed)return;try{nextTick.accept(()->{if(!closed)task.run();});}catch(RuntimeException ignored){}}
@@ -154,7 +355,7 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
private static void line(List<double[]> out,double from,double to,double fixedA,double fixedB,int axis){for(double value=from;value<to;value+=2)out.add(axis==0?new double[]{value,fixedA,fixedB}:axis==1?new double[]{fixedB,fixedA,value}:new double[]{fixedA,value,fixedB});out.add(axis==0?new double[]{to,fixedA,fixedB}:axis==1?new double[]{fixedB,fixedA,to}:new double[]{fixedA,to,fixedB});}
private static Color randomColor(){var random=java.util.concurrent.ThreadLocalRandom.current();return Color.fromRGB(64+random.nextInt(192),64+random.nextInt(192),64+random.nextInt(192));}
@EventHandler(priority=EventPriority.MONITOR)public void chunkLoad(org.bukkit.event.world.ChunkLoadEvent event){for(var entity:event.getChunk().getEntities())if(managed(entity))entity.remove();}
@EventHandler(priority=EventPriority.MONITOR)public void chunkUnload(org.bukkit.event.world.ChunkUnloadEvent event){for(var entity:event.getChunk().getEntities())if(managed(entity)){UUID dungeon=id(entity,dungeonKey),room=id(entity,roomKey);if(dungeon!=null&&room!=null)cleanupAttempt(new AttemptKey(dungeon,room));}}
@EventHandler(priority=EventPriority.MONITOR)public void chunkUnload(org.bukkit.event.world.ChunkUnloadEvent event){for(var entity:event.getChunk().getEntities())if(managed(entity)){UUID dungeon=id(entity,dungeonKey),room=id(entity,roomKey);if(dungeon!=null&&room!=null)cleanupAttempt(new AttemptKey(dungeon,room),"chunk_unload");}}
@Override public CompletionStage<Void> reset(AdventureQuestState.Reset reset){cleanupRuntime();var pending=inFlight.stream().map(future->future.handle((ignored,failure)->null)).toArray(CompletableFuture[]::new);return CompletableFuture.allOf(pending).thenCompose(ignored->dungeons.reset(reset));}
@EventHandler(priority=EventPriority.MONITOR)public void quit(PlayerQuitEvent event){done(event.getPlayer().getUniqueId());}
@Override public synchronized void close(){if(closed)return;if(pulse!=null)pulse.cancel();cleanupRuntime();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(managed(entity))entity.remove();closed=true;playerEdits.clear();dungeonEditors.clear();startingRuns.clear();completing.clear();defeatingDungeons.clear();}
@@ -25,8 +25,10 @@ public final class DungeonDirectory implements AutoCloseable,AdventureResetParti
public Optional<DungeonState.Room> roomAt(UUID dungeon,int x,int y,int z){return find(dungeon).flatMap(value->{var matches=value.rooms().stream().filter(room->room.bounds().contains(x,y,z)).toList();return matches.size()==1?Optional.of(matches.getFirst()):Optional.empty();});}
public CompletableFuture<DungeonState.Dungeon> create(String name,UUID world,double x,double y,double z,float yaw,float pitch){return create(name,world,x,y,z,yaw,pitch,AdventureQuestState.ParticipationMode.PARTY_AVAILABLE);}
public CompletableFuture<DungeonState.Dungeon> create(String name,UUID world,double x,double y,double z,float yaw,float pitch,AdventureQuestState.ParticipationMode mode){var value=new DungeonState.Dungeon(UUID.randomUUID(),name,world,new DungeonState.Position(x,y,z,yaw,pitch),false,UUID.randomUUID(),List.of(),mode,DungeonState.Reward.empty(),false);return mutate(state->{var values=new ArrayList<>(state.dungeons());values.add(value);return result(new DungeonState(values,state.runs()),value);});}
public CompletableFuture<DungeonState.Dungeon> setMode(UUID id,UUID expectedRevision,AdventureQuestState.ParticipationMode mode){Objects.requireNonNull(expectedRevision);Objects.requireNonNull(mode);return mutate(state->{var current=find(state,id).orElseThrow(()->new IllegalArgumentException("Dungeon not found"));if(!current.revision().equals(expectedRevision))throw new IllegalArgumentException("Dungeon changed");if(current.participationMode()==mode)return result(state,current);var changed=new DungeonState.Dungeon(current.id(),current.name(),current.world(),current.entrance(),current.enabled(),UUID.randomUUID(),current.rooms(),mode,current.completionReward(),current.rewardConfigured());return result(replace(state,changed),changed);});}
public CompletableFuture<Optional<DungeonState.Dungeon>> remove(UUID id){return mutate(state->{var found=find(state,id);if(found.isEmpty())return result(state,Optional.empty());if(found.orElseThrow().rooms().stream().map(DungeonState.Room::boss).filter(Objects::nonNull).anyMatch(boss->boss.character()!=null))throw new IllegalArgumentException("Recover character boss custody first");return result(new DungeonState(state.dungeons().stream().filter(v->!v.id().equals(id)).toList(),state.runs().stream().filter(v->!v.dungeon().equals(id)).toList()),found);});}
public record ModeChange(DungeonState.Dungeon before,DungeonState.Dungeon after){}
public CompletableFuture<DungeonState.Dungeon> setMode(UUID id,UUID expectedRevision,AdventureQuestState.ParticipationMode mode){return setModeWithEvidence(id,expectedRevision,mode).thenApply(ModeChange::after);}
public CompletableFuture<ModeChange> setModeWithEvidence(UUID id,UUID expectedRevision,AdventureQuestState.ParticipationMode mode){Objects.requireNonNull(expectedRevision);Objects.requireNonNull(mode);return mutate(state->{var current=find(state,id).orElseThrow(()->new IllegalArgumentException("Dungeon not found"));if(!current.revision().equals(expectedRevision))throw new IllegalArgumentException("Dungeon changed");if(current.participationMode()==mode)return result(state,new ModeChange(current,current));var changed=new DungeonState.Dungeon(current.id(),current.name(),current.world(),current.entrance(),current.enabled(),UUID.randomUUID(),current.rooms(),mode,current.completionReward(),current.rewardConfigured());return result(replace(state,changed),new ModeChange(current,changed));});}
public CompletableFuture<Optional<DungeonState.Dungeon>> remove(UUID id){return mutate("definition_removed",state->{var found=find(state,id);if(found.isEmpty())return result(state,Optional.empty());if(found.orElseThrow().rooms().stream().map(DungeonState.Room::boss).filter(Objects::nonNull).anyMatch(boss->boss.character()!=null))throw new IllegalArgumentException("Recover character boss custody first");return result(new DungeonState(state.dungeons().stream().filter(v->!v.id().equals(id)).toList(),state.runs().stream().filter(v->!v.dungeon().equals(id)).toList()),found);});}
public CompletableFuture<DungeonState.Dungeon> setEntrance(UUID id,double x,double y,double z,float yaw,float pitch){return change(id,dungeon->definition(dungeon,dungeon.rooms(),new DungeonState.Position(x,y,z,yaw,pitch),false));}
public CompletableFuture<DungeonState.Room> addCornerRoom(UUID id,String name,int x,int y,int z,int xSize,int zSize,int height){if(xSize==0||Math.abs((long)xSize)>256||zSize==0||Math.abs((long)zSize)>256||height<1||height>128)return CompletableFuture.failedFuture(new IllegalArgumentException("Room dimensions out of range"));int maxX=x+(xSize>0?xSize-1:0),minX=x+(xSize<0?xSize+1:0),maxZ=z+(zSize>0?zSize-1:0),minZ=z+(zSize<0?zSize+1:0);return addRoom(id,name,new DungeonState.Bounds(Math.min(x,minX),y,Math.min(z,minZ),Math.max(x,maxX),y+height-1,Math.max(z,maxZ)));}
public CompletableFuture<DungeonState.Room> addRadiusRoom(UUID id,String name,int x,int y,int z,int radius,int height){if(radius<1||radius>128||height<1||height>128)return CompletableFuture.failedFuture(new IllegalArgumentException("Room dimensions out of range"));return addRoom(id,name,new DungeonState.Bounds(x-radius,y,z-radius,x+radius,y+height-1,z+radius));}
@@ -36,7 +38,9 @@ public final class DungeonDirectory implements AutoCloseable,AdventureResetParti
public CompletableFuture<Optional<DungeonState.Spawn>> removeMob(UUID id,UUID roomId,UUID spawn){var found=room(id,roomId).flatMap(value->value.mobs().stream().filter(v->v.id().equals(spawn)).findFirst());if(found.isEmpty())return CompletableFuture.completedFuture(Optional.empty());return updateRoom(id,roomId,room->new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs().stream().filter(v->!v.id().equals(spawn)).toList(),room.effects(),room.reward(),room.boss()),found);}
public CompletableFuture<DungeonState.Effect> addEffect(UUID id,UUID roomId,DungeonState.EffectTarget target,String effect,int level,boolean particles){var value=new DungeonState.Effect(UUID.randomUUID(),target,effect,level,particles);return updateRoom(id,roomId,room->{var effects=new ArrayList<>(room.effects());if(effects.size()>=32)throw new IllegalArgumentException("Effect limit reached");effects.add(value);return new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),effects,room.reward(),room.boss());},value);}
public CompletableFuture<Optional<DungeonState.Effect>> removeEffect(UUID id,UUID roomId,UUID effect){var found=room(id,roomId).flatMap(value->value.effects().stream().filter(v->v.id().equals(effect)).findFirst());if(found.isEmpty())return CompletableFuture.completedFuture(Optional.empty());return updateRoom(id,roomId,room->new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects().stream().filter(v->!v.id().equals(effect)).toList(),room.reward(),room.boss()),found);}
public CompletableFuture<DungeonState.Reward> setCompletionReward(UUID id,BigInteger xp,BigInteger coins){var value=new DungeonState.Reward(xp,coins);return mutate(state->{var dungeon=editable(state,id);var changed=new DungeonState.Dungeon(dungeon.id(),dungeon.name(),dungeon.world(),dungeon.entrance(),false,UUID.randomUUID(),dungeon.rooms(),dungeon.participationMode(),value,true);return result(replace(state,changed),value);});}
public record CompletionRewardChange(DungeonState.Dungeon before,DungeonState.Dungeon after){}
public CompletableFuture<DungeonState.Reward> setCompletionReward(UUID id,BigInteger xp,BigInteger coins){return setCompletionRewardWithEvidence(id,xp,coins).thenApply(change->change.after().completionReward());}
public CompletableFuture<CompletionRewardChange> setCompletionRewardWithEvidence(UUID id,BigInteger xp,BigInteger coins){var value=new DungeonState.Reward(xp,coins);return mutate(state->{var dungeon=editable(state,id);var changed=new DungeonState.Dungeon(dungeon.id(),dungeon.name(),dungeon.world(),dungeon.entrance(),false,UUID.randomUUID(),dungeon.rooms(),dungeon.participationMode(),value,true);return result(replace(state,changed),new CompletionRewardChange(dungeon,changed));});}
public CompletableFuture<DungeonState.Boss> setBoss(UUID id,UUID roomId,EntityType type,String name,double x,double y,double z,float yaw,float pitch){var value=new DungeonState.Boss(type,name,new DungeonState.Position(x,y,z,yaw,pitch));return updateRoom(id,roomId,room->{if(room.boss()!=null&&room.boss().character()!=null)throw new IllegalArgumentException("Recover character boss custody first");return new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects(),room.reward(),value);},value);}
public CompletableFuture<Boolean> clearBoss(UUID id,UUID roomId){return updateRoom(id,roomId,room->{if(room.boss()!=null&&room.boss().character()!=null)throw new IllegalArgumentException("Recover character boss custody first");return new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects(),room.reward(),null);},true);}
public CompletableFuture<DungeonState.CharacterBoss> assignCharacterBoss(UUID id,UUID roomId,CharacterRecord character,BigDecimal multiplier){Objects.requireNonNull(character);var template=new DungeonState.CharacterBoss(UUID.randomUUID(),character,multiplier,null);return mutate(state->{var dungeon=editable(state,id);if(state.dungeons().stream().flatMap(v->v.rooms().stream()).map(DungeonState.Room::boss).filter(Objects::nonNull).map(DungeonState.Boss::character).filter(Objects::nonNull).anyMatch(custody->custody.character().id().equals(character.id())))throw new IllegalArgumentException("Character already assigned");var room=dungeon.rooms().stream().filter(v->v.id().equals(roomId)).findFirst().orElseThrow(()->new IllegalArgumentException("Room not found"));if(room.boss()==null||room.boss().character()!=null)throw new IllegalArgumentException("Configure one native boss room first");var value=new DungeonState.CharacterBoss(template.operation(),character,template.multiplier(),room.boss().type(),room.boss().name(),null);var boss=new DungeonState.Boss(EntityType.VINDICATOR,character.name(),room.boss().position(),value);var updated=new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects(),room.reward(),boss);var rooms=dungeon.rooms().stream().map(v->v.id().equals(roomId)?updated:v).toList();return result(replace(state,definition(dungeon,rooms,dungeon.entrance(),false)),value);});}
@@ -44,15 +48,23 @@ public final class DungeonDirectory implements AutoCloseable,AdventureResetParti
public CompletableFuture<DungeonState.CharacterBoss> prepareRecovery(UUID id,UUID administrator){return mutate(state->{var dungeon=editable(state,id);var target=dungeon.rooms().stream().filter(room->room.boss()!=null&&room.boss().character()!=null).findFirst().orElseThrow(()->new IllegalArgumentException("Character boss not found"));var custody=target.boss().character();if(custody.recovery()!=null){if(!custody.recovery().administrator().equals(administrator))throw new IllegalArgumentException("Recovery belongs to another administrator");return result(state,custody);}var prepared=new DungeonState.CharacterBoss(custody.operation(),custody.character(),custody.multiplier(),custody.priorType(),custody.priorName(),new DungeonState.Recovery(UUID.randomUUID(),administrator));var boss=new DungeonState.Boss(target.boss().type(),target.boss().name(),target.boss().position(),prepared);var changed=new DungeonState.Room(target.id(),target.name(),target.bounds(),target.mobs(),target.effects(),target.reward(),boss);var rooms=dungeon.rooms().stream().map(room->room.id().equals(target.id())?changed:room).toList();return result(replace(state,definition(dungeon,rooms,dungeon.entrance(),false)),prepared);});}
public CompletableFuture<Boolean> completeRecovery(UUID id,UUID operation){return mutate(state->{var dungeon=editable(state,id);var target=dungeon.rooms().stream().filter(room->room.boss()!=null&&room.boss().character()!=null&&room.boss().character().recovery()!=null&&room.boss().character().recovery().operation().equals(operation)).findFirst().orElse(null);if(target==null)return result(state,false);var changed=new DungeonState.Room(target.id(),target.name(),target.bounds(),target.mobs(),target.effects(),target.reward(),null);var rooms=dungeon.rooms().stream().map(room->room.id().equals(target.id())?changed:room).toList();return result(replace(state,definition(dungeon,rooms,dungeon.entrance(),false)),true);});}
public CompletableFuture<DungeonState.Dungeon> enable(UUID id,List<DungeonState.Dungeon> externalEnabled){return mutate(state->{var dungeon=find(state,id).orElseThrow(()->new IllegalArgumentException("Dungeon not found"));validate(dungeon);var conflicts=new ArrayList<DungeonState.Dungeon>();conflicts.addAll(state.dungeons().stream().filter(v->v.enabled()&&!v.id().equals(id)).toList());conflicts.addAll(externalEnabled);for(var other:conflicts)if(other.world().equals(dungeon.world())&&dungeon.rooms().stream().anyMatch(a->other.rooms().stream().anyMatch(b->a.bounds().overlaps(b.bounds()))))throw new IllegalArgumentException("Dungeon overlaps an enabled dungeon");if(dungeon.enabled())return result(state,dungeon);var enabled=definition(dungeon,dungeon.rooms(),dungeon.entrance(),true);return result(replace(state,enabled),enabled);});}
public CompletableFuture<Optional<DungeonState.Dungeon>> disable(UUID id){return mutate(state->{var dungeon=find(state,id).orElse(null);if(dungeon==null)return result(state,Optional.empty());var disabled=definition(dungeon,dungeon.rooms(),dungeon.entrance(),false);return result(replace(new DungeonState(state.dungeons(),state.runs().stream().filter(v->!v.dungeon().equals(id)).toList()),disabled),Optional.of(disabled));});}
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<Optional<DungeonState.Dungeon>> disable(UUID id){return mutate("definition_disabled",state->{var dungeon=find(state,id).orElse(null);if(dungeon==null)return result(state,Optional.empty());var disabled=definition(dungeon,dungeon.rooms(),dungeon.entrance(),false);return result(replace(new DungeonState(state.dungeons(),state.runs().stream().filter(v->!v.dungeon().equals(id)).toList()),disabled),Optional.of(disabled));});}
public CompletableFuture<Boolean> clearRun(UUID dungeon,UUID generation){return mutate("offer_closed",state->{var runs=state.runs().stream().filter(v->!(v.dungeon().equals(dungeon)&&v.generation().equals(generation))).toList();if(runs.size()==state.runs().size())return result(state,false);return result(new DungeonState(state.dungeons(),runs),true);});}
public CompletableFuture<DungeonState.Run> run(UUID dungeon,UUID generation){return mutate(state->{requireGeneration(generation);var found=state.runs().stream().filter(v->v.dungeon().equals(dungeon)&&v.generation().equals(generation)).findFirst();if(found.isPresent())return result(state,found.orElseThrow());var value=new DungeonState.Run(dungeon,generation,Set.of(),Set.of(),Set.of(),Set.of(),false);var runs=new ArrayList<>(state.runs());runs.add(value);return result(new DungeonState(state.dungeons(),runs),value);});}
public Optional<DungeonState.Run> runView(UUID dungeon,UUID generation){return snapshot.runs().stream().filter(v->v.dungeon().equals(dungeon)&&v.generation().equals(generation)).findFirst();}
public CompletableFuture<DungeonState.Run> defeatSlot(UUID dungeon,UUID generation,UUID slot){return updateRun(dungeon,generation,run->{var defeated=new HashSet<>(run.defeatedSlots());defeated.add(slot);return new DungeonState.Run(run.dungeon(),run.generation(),defeated,run.clearedRooms(),run.unitClaims(),run.characterClaims(),run.defeated());});}
public CompletableFuture<DungeonState.Run> markCleared(UUID dungeon,UUID generation,UUID room){return updateRun(dungeon,generation,run->{var cleared=new HashSet<>(run.clearedRooms());cleared.add(room);return new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),cleared,run.unitClaims(),run.characterClaims(),run.defeated());});}
public CompletableFuture<DungeonState.Run> clearRoom(UUID dungeon,UUID generation,UUID room,UUID quest,Set<UUID> characters){return updateRun(dungeon,generation,run->{var cleared=new HashSet<>(run.clearedRooms());cleared.add(room);var units=new HashSet<>(run.unitClaims());units.add(new DungeonState.UnitClaim(room,quest));var claims=new HashSet<>(run.characterClaims());characters.forEach(value->claims.add(new DungeonState.CharacterClaim(room,value)));return new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),cleared,units,claims,run.defeated());});}
public CompletableFuture<DungeonState.Run> defeat(UUID dungeon,UUID generation){return updateRun(dungeon,generation,run->new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),run.clearedRooms(),run.unitClaims(),run.characterClaims(),true));}
@Override public CompletionStage<Void> reset(AdventureQuestState.Reset reset){currentGeneration=reset.generation();return mutate(state->result(new DungeonState(state.dungeons(),List.of()),null));}
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));});}
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("adventure_day_reset",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);});}
@@ -68,7 +80,15 @@ public final class DungeonDirectory implements AutoCloseable,AdventureResetParti
private void requireGeneration(UUID generation){var current=currentGeneration;if(current!=null&&!current.equals(generation))throw new IllegalStateException("Stale dungeon generation");}
private record Result<T>(DungeonState state,T value){}
private static <T>Result<T> result(DungeonState state,T value){return new Result<>(state,value);}
private <T>CompletableFuture<T> mutate(java.util.function.Function<DungeonState,Result<T>> operation){var future=new CompletableFuture<T>();synchronized(this){if(closed){future.completeExceptionally(new IOException("Dungeon directory closed"));return future;}pending++;}worker.execute(()->{try{var changed=operation.apply(snapshot);if(changed.state()!=snapshot){repository.save(changed.state());snapshot=changed.state();}future.complete(changed.value());}catch(Throwable failure){future.completeExceptionally(failure);}finally{finished();}});return future;}
/** Immutable source evidence belongs to its exact acknowledged mutation, not a later directory read. */
public record Publication(DungeonState before,DungeonState after){}
public record RunRemoval(UUID operation,String reason,Publication publication){}
private volatile java.util.function.Consumer<RunRemoval> runRemovalObserver=ignored->{};
public void attachRunRemovalEvents(java.util.function.Consumer<RunRemoval> observer){runRemovalObserver=Objects.requireNonNull(observer);}
private static final class PublishedMutation<T> extends CompletableFuture<T>{private volatile Publication publication;}
public static Optional<Publication> publication(CompletionStage<?> mutation){return mutation instanceof PublishedMutation<?> source?Optional.ofNullable(source.publication):Optional.empty();}
private <T>CompletableFuture<T> mutate(java.util.function.Function<DungeonState,Result<T>> operation){return mutate("authoritative_state_transition",operation);}
private <T>CompletableFuture<T> mutate(String reason,java.util.function.Function<DungeonState,Result<T>> operation){var future=new PublishedMutation<T>();UUID observation=UUID.randomUUID();synchronized(this){if(closed){future.completeExceptionally(new IOException("Dungeon directory closed"));return future;}pending++;}worker.execute(()->{try{var before=snapshot;var changed=operation.apply(before);if(changed.state()!=before){repository.save(changed.state());snapshot=changed.state();future.publication=new Publication(before,changed.state());if(before.runs().stream().anyMatch(run->changed.state().runs().stream().noneMatch(value->value.dungeon().equals(run.dungeon())&&value.generation().equals(run.generation()))))try{runRemovalObserver.accept(new RunRemoval(observation,reason,future.publication));}catch(RuntimeException ignored){/* A committed source transition cannot depend on its observer. */}}future.complete(changed.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);}
@@ -56,8 +56,10 @@ public record DungeonState(List<Dungeon> dungeons,List<Run> runs) {
}
public record UnitClaim(UUID room,UUID quest) {public UnitClaim {Objects.requireNonNull(room);Objects.requireNonNull(quest);}}
public record CharacterClaim(UUID room,UUID character) {public CharacterClaim {Objects.requireNonNull(room);Objects.requireNonNull(character);}}
public record Run(UUID dungeon,UUID generation,Set<UUID> defeatedSlots,Set<UUID> clearedRooms,Set<UnitClaim> unitClaims,Set<CharacterClaim> characterClaims,boolean defeated) {
public Run {Objects.requireNonNull(dungeon);Objects.requireNonNull(generation);defeatedSlots=Set.copyOf(defeatedSlots);clearedRooms=Set.copyOf(clearedRooms);unitClaims=Set.copyOf(unitClaims);characterClaims=Set.copyOf(characterClaims);}
public record Run(UUID dungeon,UUID generation,Set<UUID> defeatedSlots,Set<UUID> clearedRooms,Set<UnitClaim> unitClaims,Set<CharacterClaim> characterClaims,boolean defeated,Map<String,String> completionAudit,boolean completionPublished) {
public Run {Objects.requireNonNull(dungeon);Objects.requireNonNull(generation);defeatedSlots=Set.copyOf(defeatedSlots);clearedRooms=Set.copyOf(clearedRooms);unitClaims=Set.copyOf(unitClaims);characterClaims=Set.copyOf(characterClaims);completionAudit=Map.copyOf(completionAudit);if(completionPublished&&(!defeated||completionAudit.isEmpty()))throw new IllegalArgumentException("Unproven dungeon completion publication");}
public Run(UUID dungeon,UUID generation,Set<UUID> defeatedSlots,Set<UUID> clearedRooms,Set<UnitClaim> unitClaims,Set<CharacterClaim> characterClaims,boolean defeated,Map<String,String> completionAudit){this(dungeon,generation,defeatedSlots,clearedRooms,unitClaims,characterClaims,defeated,completionAudit,false);}
public Run(UUID dungeon,UUID generation,Set<UUID> defeatedSlots,Set<UUID> clearedRooms,Set<UnitClaim> unitClaims,Set<CharacterClaim> characterClaims,boolean defeated){this(dungeon,generation,defeatedSlots,clearedRooms,unitClaims,characterClaims,defeated,Map.of(),false);}
}
public DungeonState {dungeons=List.copyOf(dungeons);runs=List.copyOf(runs);unique(dungeons.stream().map(Dungeon::id).toList());var keys=runs.stream().map(v->v.dungeon()+":"+v.generation()).toList();if(new HashSet<>(keys).size()!=keys.size())throw new IllegalArgumentException("Duplicate dungeon run");}
public static DungeonState empty(){return new DungeonState(List.of(),List.of());}
@@ -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,164 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.shop.ArmorCatalog;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import java.util.function.Function;
/** Failure-isolated immutable records from acknowledged armor authorities, never gameplay replay. */
public final class ArmorEventBridge implements AutoCloseable {
private final FeatureEventOutbox outbox;
private final java.time.Clock clock = java.time.Clock.systemUTC();
public record Actor(String kind, UUID playerId, String playerName, EventInput.Context context) {
public Actor(String kind, EventInput.Context context) {
this(kind, context == null ? null : context.playerId(), context == null ? null : context.playerName(), context);
}
}
private static void actorData(Map<String, Object> values, Actor actor) {
values.put("actor_kind", actor == null ? "unavailable" : actor.kind());
values.put("request_actor_kind", actor == null ? "unavailable" : actor.kind());
values.put("actor_capture_quality", actor == null || actor.kind().equals("player") && actor.context() == null ? "unavailable" : "native");
if (actor != null && actor.playerId() != null) values.put("request_player_id", actor.playerId().toString());
if (actor != null && actor.playerName() != null) values.put("request_player_name", actor.playerName());
}
private final Set<UUID> catalogs = ConcurrentHashMap.newKeySet();
public ArmorEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Path data, Consumer<Throwable> failures) {
outbox = new FeatureEventOutbox(data, "armor-event-intents", Set.of("armor.catalog_refreshed", "armorer.placed", "armorer.removed", "armor.purchased", "config.read", "config.changed"), sink, failures);
}
public void catalogRefreshed(ArmorCatalog catalog) {
try {
if (!catalogs.add(catalog.revision())) return;
var products = new ArrayList<Map<String, String>>();
catalog.products().entrySet().stream().sorted(Comparator.comparing(entry ->
entry.getKey().slot().name() + "." + entry.getKey().tier().name())).forEach(entry -> {
var product = entry.getKey(); var offer = entry.getValue();
products.add(Map.of("product_id", product.slot().name().toLowerCase(Locale.ROOT) + "."
+ product.tier().name().toLowerCase(Locale.ROOT), "price", offer.price().toString(),
"stock", Integer.toString(offer.remaining())));
});
var values = new LinkedHashMap<String, Object>();
values.put("actor_kind", "system"); values.put("catalog_revision", catalog.revision().toString());
values.put("catalog_day", catalog.day().toString()); values.put("refreshed_at", catalog.refreshedAt().toString());
values.put("refresh_reason", catalog.reason().name().toLowerCase(Locale.ROOT));
values.put("next_midnight", catalog.day().plusDays(1).atStartOfDay(java.time.ZoneOffset.UTC).toInstant().toString());
values.put("products", java.util.List.copyOf(products)); values.put("outcome", "acknowledged");
var id = UUID.nameUUIDFromBytes(("fantasystick:armor:" + catalog.revision() + ":catalog-refreshed")
.getBytes(StandardCharsets.UTF_8));
outbox.accepted(new EventInput(id, catalog.revision(), catalog.refreshedAt(),
"armor.catalog_refreshed", null, Map.copyOf(values)));
} catch (RuntimeException ignored) { /* Audit cannot interrupt the already-saved catalog. */ }
}
public Actor actor(org.bukkit.command.CommandSender sender, games.dmg.fantasystick.session.EquipmentSessions sessions,
org.bukkit.Location input) {
String kind = sender instanceof org.bukkit.entity.Player ? "player"
: sender instanceof org.bukkit.command.BlockCommandSender ? "command_block" : "console";
if (!(sender instanceof org.bukkit.entity.Player player)) return new Actor(kind, null);
UUID id = null; String name = null; UUID character = null, session = null; String characterName = null;
EventInput.Location location = null;
try { id = player.getUniqueId(); } catch (RuntimeException ignored) { }
try { name = player.getName(); } catch (RuntimeException ignored) { }
try {
var active = id == null ? null : sessions.active(id).filter(sessions::current).orElse(null);
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
if (record != null) { character = record.id(); characterName = record.name(); session = active.session(); }
} catch (RuntimeException ignored) { }
try {
var nativeLocation = input == null ? player.getLocation() : input;
var world = nativeLocation.getWorld();
if (world != null) location = new EventInput.Location(world.getUID(), world.getName(), nativeLocation.getX(),
nativeLocation.getY(), nativeLocation.getZ(), clock.instant(), "native");
} catch (RuntimeException ignored) { }
try { return new Actor(kind, id, name, new EventInput.Context(id, name, character, characterName, session, location)); }
catch (RuntimeException unavailable) { return new Actor(kind, id, name, null); }
}
public void armorerChanged(String type, UUID operation, games.dmg.fantasystick.shop.FantasyArmorer shop,
Actor actor, games.dmg.fantasystick.shop.FantasyArmorerController.Observed entities,
String outcome) {
try {
var values = new LinkedHashMap<String, Object>();
actorData(values, actor);
values.put("operation_id", operation.toString()); values.put("armorer_id", shop.id().toString());
values.put("armorer_name", shop.name());
values.put("revision_capture_quality", shop.revision() == null ? "unavailable" : "persisted");
if (shop.revision() != null) values.put("armorer_revision", shop.revision().toString());
values.put("world_id", shop.world().toString()); values.put("x", shop.x()); values.put("y", shop.y());
values.put("z", shop.z()); values.put("yaw", (double) shop.yaw()); values.put("pitch", (double) shop.pitch());
values.put("chunk_status", entities.chunkStatus()); values.put("entity_capture_quality", entities.quality());
values.put("entity_ids", entities.ids().stream().map(UUID::toString).toList());
if (entities.ids().size() == 1) values.put("entity_id", entities.ids().getFirst().toString());
values.put("entity_outcome", outcome); values.put("outcome", "acknowledged");
var id = UUID.nameUUIDFromBytes(("fantasystick:armor:" + operation + ":" + type).getBytes(StandardCharsets.UTF_8));
outbox.accepted(new EventInput(id, operation, clock.instant(), type,
actor == null ? null : actor.context(), Map.copyOf(values)));
} catch (RuntimeException ignored) { /* Audit cannot undo native registration/reconciliation. */ }
}
public void purchased(games.dmg.fantasystick.shop.ArmorMarket.PurchaseProof proof, UUID catalog,
games.dmg.fantasystick.shop.FantasyArmorer shop, games.dmg.fantasystick.shop.ArmorProduct product,
Actor actor, games.dmg.fantasystick.persistence.CharacterRecord before,
games.dmg.fantasystick.persistence.CharacterRecord after) {
try {
// Bind the audit to the real consume-once receipt, never a reconstructed operation or duplicate resolution.
if (!after.rewardReceipts().contains(proof.operation()) || before.rewardReceipts().contains(proof.operation())) return;
var values = new LinkedHashMap<String, Object>();
actorData(values, actor);
values.put("purchase_operation_id", proof.operation().toString()); values.put("receipt_id", proof.operation().toString());
values.put("catalog_revision", catalog.toString()); values.put("armorer_id", shop.id().toString());
values.put("revision_capture_quality", shop.revision() == null ? "unavailable" : "persisted");
if (shop.revision() != null) values.put("armorer_revision", shop.revision().toString());
values.put("product_id", (product.slot().name() + "." + product.tier().name()).toLowerCase(Locale.ROOT));
values.put("slot", product.slot().name().toLowerCase(Locale.ROOT));
values.put("previous_tier", before.gear().tier(product.slot()).name().toLowerCase(Locale.ROOT));
values.put("new_tier", after.gear().tier(product.slot()).name().toLowerCase(Locale.ROOT));
values.put("price", proof.price().toString()); values.put("balance_before", before.coins().toString());
values.put("balance_after", after.coins().toString()); values.put("stock_before", Integer.toString(proof.stockBefore()));
values.put("stock_after", Integer.toString(proof.stockAfter()));
values.put("before_revision", before.revision().toString()); values.put("after_revision", after.revision().toString());
if (actor != null && actor.context() != null && actor.context().sessionId() != null)
values.put("session_id", actor.context().sessionId().toString());
values.put("location_capture_quality", actor != null && actor.context() != null && actor.context().location() != null ? "native" : "unavailable");
values.put("outcome", "acknowledged");
var id = UUID.nameUUIDFromBytes(("fantasystick:armor:" + proof.operation() + ":purchased").getBytes(StandardCharsets.UTF_8));
outbox.accepted(new EventInput(id, proof.operation(), clock.instant(), "armor.purchased",
actor == null ? null : actor.context(), Map.copyOf(values)));
} catch (RuntimeException ignored) { /* Audit cannot change saved coins, stock or native gear. */ }
}
public void configRead(Actor actor, String key, String value) {
configuration("config.read", actor, key, null, value);
}
public void configChanged(Actor actor, String key, String before, String after) {
configuration("config.changed", actor, key, before, after);
}
private void configuration(String type, Actor actor, String key, String before, String after) {
try {
var operation = UUID.randomUUID(); var values = new LinkedHashMap<String, Object>();
actorData(values, actor); values.put("key", key); values.put("after", after);
if (before != null) values.put("before", before);
values.put("application", "next_armor_restock"); values.put("application_scope", "next_catalog");
values.put("outcome", "acknowledged");
var id = UUID.nameUUIDFromBytes(("fantasystick:armor:" + operation + ":" + type).getBytes(StandardCharsets.UTF_8));
outbox.accepted(new EventInput(id, operation, clock.instant(), type,
actor == null ? null : actor.context(), Map.copyOf(values)));
} catch (RuntimeException ignored) { /* Audit cannot read/write effective gameplay policy or restock. */ }
}
public void replay() { outbox.replay(); }
public CompletionStage<Void> drained() { return outbox.drained(); }
@Override public void close() { outbox.close(); }
}
@@ -0,0 +1,315 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.adventure.AdventureQuestState;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Clock;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.Location;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
/** Immutable camp-owner observations. Audit availability and replay never authorize gameplay. */
public final class CampEventBridge implements AutoCloseable {
public record Actor(EventInput.Context context, Map<String,Object> captured) {
public Actor { captured=Map.copyOf(captured); }
}
private final FeatureEventOutbox outbox;
private final EquipmentSessions sessions;
private final Clock clock=Clock.systemUTC();
public CampEventBridge(Function<EventInput,CompletionStage<LocalEventLog.Delivery>> sink,
Path data,EquipmentSessions sessions,Consumer<Throwable> failures) {
this.sessions=java.util.Objects.requireNonNull(sessions);
outbox=new FeatureEventOutbox(data,"camp-event-intents",Set.of("adventure.site.registered","adventure.site.mode_changed","adventure.site.removed","adventure.quest_giver.placed","adventure.quest_giver.removed","adventure.catalog_refreshed","adventure.quest.accepted","adventure.quest.abandoned","adventure.quest.expired","adventure.camp.started","adventure.camp.cleared","adventure.camp.reset","config.read","config.changed"),sink,failures);
}
/** Capture independent request facts on the calling server thread, not on the persistence worker. */
public Actor actor(CommandSender sender,Location input) {
var values=new LinkedHashMap<String,Object>();
values.put("actor_kind",sender instanceof Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console");
if(!(sender instanceof Player player))return new Actor(null,values);
UUID id=null,character=null,session=null; String name=null,characterName=null;
EventInput.Location location=null;
try{id=player.getUniqueId();if(id!=null)values.put("request_player_id",id.toString());}catch(RuntimeException ignored){}
try{name=player.getName();if(name!=null)values.put("request_player_name",name);}catch(RuntimeException ignored){}
try{
var active=id==null?null:sessions.active(id).filter(sessions::current).orElse(null);
var record=active==null?null:sessions.currentRecord(active).orElse(null);
if(record!=null){character=record.id();characterName=record.name();session=active.session();
values.put("request_character_id",character.toString());values.put("request_session_id",session.toString());}
}catch(RuntimeException ignored){}
try{
var at=input==null?player.getLocation():input;var world=at.getWorld();
if(world!=null){
values.put("actor_location",Map.of("world_id",world.getUID().toString(),"x",at.getX(),"y",at.getY(),"z",at.getZ()));
location=new EventInput.Location(world.getUID(),world.getName(),at.getX(),at.getY(),at.getZ(),clock.instant(),"native");
}
}catch(RuntimeException ignored){}
EventInput.Context context=null;
try{context=new EventInput.Context(id,name,character,characterName,session,location);}catch(RuntimeException ignored){}
values.put("actor_capture_quality",context==null?"unavailable":"native");
if(!values.containsKey("actor_location"))values.put("actor_location_capture_quality","unavailable");
return new Actor(context,values);
}
public void siteRegistered(UUID operation,Actor actor,AdventureQuestState.Site site,AdventureQuestState.Arena arena) {
if(site.type()!=AdventureQuestState.SiteType.CAMP)return;
try{
var data=siteData(site,arena);
data.put("mode_before","not_applicable");data.put("mode_after",site.participationMode().value());
data.put("catalog_impact","unchanged_by_registration");data.put("active_quest_impact","unchanged");
accepted("adventure.site.registered",operation,actor,data);
}catch(RuntimeException ignored){/* Cannot undo acknowledged registration. */}
}
static LinkedHashMap<String,Object> siteData(AdventureQuestState.Site site,AdventureQuestState.Arena arena) {
var data=new LinkedHashMap<String,Object>();
data.put("site_kind",site.type().name().toLowerCase(java.util.Locale.ROOT));data.put("site_id",site.id().toString());
data.put("site_name",site.name());data.put("site_display_name",site.displayName());
data.put("revision_capture_quality",site.registrationRevision()==null?"unavailable":"persisted");
if(site.registrationRevision()!=null)data.put("site_revision",site.registrationRevision().toString());
data.put("world_id",site.world().toString());data.put("x",site.x());data.put("y",site.y());data.put("z",site.z());
data.putAll(arenaData(site,arena));
return data;
}
public static Map<String,Object> arenaData(AdventureQuestState.Site site,AdventureQuestState.Arena arena) {
var data=new LinkedHashMap<String,Object>();var geometry=new LinkedHashMap<String,Object>();
geometry.put("shape",arena.shape().name().toLowerCase(java.util.Locale.ROOT));
geometry.put("source",site.arena()==null?"configured_default":"explicit");
geometry.put("x_blocks",arena.xBlocks());geometry.put("z_blocks",arena.zBlocks());
if(arena.shape()==AdventureQuestState.ArenaShape.CIRCLE)geometry.put("radius",arena.xBlocks());
data.put("arena",Map.copyOf(geometry));
data.put("arena_revision_capture_quality",site.registrationRevision()==null?"unavailable":"persisted_site_definition");
if(site.registrationRevision()!=null)data.put("arena_revision",site.registrationRevision().toString());
data.put("arena_definition",site.arena()==null?"effective_legacy_fallback":"persisted");
return data;
}
public void siteModeChanged(UUID operation,Actor actor,AdventureQuestState.Site requestedBefore,
AdventureQuestState.Site acknowledged,AdventureQuestState.Arena arena) {
try{
if(acknowledged.type()!=AdventureQuestState.SiteType.CAMP)return;
var data=siteData(acknowledged,arena);
data.put("mode_before",requestedBefore.participationMode().value());data.put("mode_after",acknowledged.participationMode().value());
if(requestedBefore.registrationRevision()!=null)data.put("requested_site_revision",requestedBefore.registrationRevision().toString());
data.put("application_scope","future_acceptances");data.put("active_quest_impact","captured_authority_unchanged");
data.put("catalog_impact","same_offer_future_acceptances_use_new_mode");
accepted("adventure.site.mode_changed",operation,actor,data);
}catch(RuntimeException ignored){/* Never alter mode publication. */}
}
public void siteRemoved(UUID operation,Actor actor,AdventureQuestState.Site removed,
AdventureQuestState.Arena arena,AdventureQuestState.Catalog catalog) {
try{
if(removed.type()!=AdventureQuestState.SiteType.CAMP)return;
var data=siteData(removed,arena);
data.put("mode_before",removed.participationMode().value());data.put("mode_after","not_applicable");
boolean offered=catalog!=null&&catalog.sites().contains(removed.id());
data.put("offered_at_acknowledgement",offered);
if(catalog!=null)data.put("catalog_id",catalog.revision().toString());
data.put("catalog_impact",offered?"slot_unavailable_until_reset":"unoffered_registration_removed");
data.put("active_quest_impact","unfinished_site_quests_expired");data.put("earned_reward_impact","retained");
accepted("adventure.site.removed",operation,actor,data);
}catch(RuntimeException ignored){/* Never alter removal. */}
}
public void giverMutation(String type,UUID operation,Actor actor,AdventureQuestState.Giver giver,
games.dmg.fantasystick.adventure.AdventureQuestController.ObservedGiver before,
games.dmg.fantasystick.adventure.AdventureQuestController.ObservedGiver after,boolean nativeReturned) {
try{
var data=new LinkedHashMap<String,Object>();
data.put("giver_id",giver.id().toString());data.put("giver_name",giver.name());data.put("giver_display_name",giver.displayName());
data.put("revision_capture_quality",giver.registrationRevision()==null?"unavailable":"persisted");
if(giver.registrationRevision()!=null)data.put("giver_revision",giver.registrationRevision().toString());
data.put("world_id",giver.world().toString());data.put("x",giver.x());data.put("y",giver.y());data.put("z",giver.z());
data.put("yaw",(double)giver.yaw());data.put("pitch",(double)giver.pitch());
boolean placing=type.equals("adventure.quest_giver.placed");var observed=placing?after:before;
data.put("entity_ids",observed.ids().stream().map(UUID::toString).toList());
if(observed.ids().size()==1)data.put("entity_id",observed.ids().getFirst().toString());
data.put("chunk_status",observed.chunkStatus());data.put("entity_capture_quality",observed.quality());
data.put("entity_ids_after",after.ids().stream().map(UUID::toString).toList());
String outcome=!nativeReturned?"failed":!"loaded".equals(after.chunkStatus())?"not_loaded":!"native".equals(after.quality())?"unavailable":
placing?(after.ids().size()==1?"materialized":"not_materialized"):
("native".equals(before.quality())&&after.ids().isEmpty()?"removed":"not_confirmed_removed");
data.put("entity_outcome",outcome);data.put("catalog_impact","unchanged");data.put("active_quest_impact","unchanged");
accepted(type,operation,actor,data);
}catch(RuntimeException ignored){/* Registration acknowledgement and native outcomes remain separate. */}
}
public static boolean ownsSetting(String key){return switch(key){
case games.dmg.fantasystick.persistence.AdventureQuestSettings.DAILY_COUNT,games.dmg.fantasystick.persistence.AdventureQuestSettings.RADIUS,
games.dmg.fantasystick.persistence.AdventureQuestSettings.RESET_SECONDS,games.dmg.fantasystick.persistence.AdventureQuestSettings.CAMP_ENEMIES,
games.dmg.fantasystick.persistence.AdventureQuestSettings.CAMP_REWARD_XP,games.dmg.fantasystick.persistence.AdventureQuestSettings.CAMP_REWARD_COINS->true;
default->false;};}
private static String settingScope(String key){return switch(key){
case games.dmg.fantasystick.persistence.AdventureQuestSettings.DAILY_COUNT->"next_catalog";
case games.dmg.fantasystick.persistence.AdventureQuestSettings.RADIUS->"new_sites_and_future_legacy_geometry_attempts";
case games.dmg.fantasystick.persistence.AdventureQuestSettings.RESET_SECONDS->"future_attempts";
case games.dmg.fantasystick.persistence.AdventureQuestSettings.CAMP_ENEMIES->"future_uncaptured_catalog_day_rosters";
case games.dmg.fantasystick.persistence.AdventureQuestSettings.CAMP_REWARD_XP,games.dmg.fantasystick.persistence.AdventureQuestSettings.CAMP_REWARD_COINS->"future_acceptances";
default->throw new IllegalArgumentException("Not a camp/catalog setting");};}
private static LinkedHashMap<String,Object> settingData(String key,String defaults){var data=new LinkedHashMap<String,Object>();
data.put("key",key);data.put("default",defaults);data.put("feature","camp_catalog");data.put("application_scope",settingScope(key));return data;}
public void configRead(Actor actor,String key,String effective,String defaults){
try{if(!ownsSetting(key))return;var data=settingData(key,defaults);data.put("effective",effective);data.put("outcome","acknowledged_read");
accepted("config.read",UUID.randomUUID(),actor,data);}catch(RuntimeException ignored){/* Reporting cannot depend on audit. */}
}
public void configChanged(Actor actor,UUID operation,String key,String before,String after,String defaults){
try{if(!ownsSetting(key)||before.equals(after))return;var data=settingData(key,defaults);data.put("before",before);data.put("after",after);
data.put("effective",after);data.put("outcome","acknowledged_future_publication");accepted("config.changed",operation,actor,data);
}catch(RuntimeException ignored){/* Effective publication is never rolled back or duplicated by audit. */}
}
public record CampAttempt(UUID id,UUID catalog,AdventureQuestState.Site site,AdventureQuestState.Arena arena,int resetSeconds,
List<String> roster,List<Map<String,Object>> entities,List<Map<String,Object>> representedUnits,
int liveEntities,UUID finalEnemy,Map<String,Object> finalLocation,java.time.Instant defeatedAt,boolean currentGeneration,Map<String,Object> nativePresence,Map<String,Object> activation){
public CampAttempt(UUID id,UUID catalog,AdventureQuestState.Site site,AdventureQuestState.Arena arena,int resetSeconds,
List<String> roster,List<Map<String,Object>> entities,List<Map<String,Object>> representedUnits,
int liveEntities,UUID finalEnemy,Map<String,Object> finalLocation,java.time.Instant defeatedAt,boolean currentGeneration,Map<String,Object> nativePresence){
this(id,catalog,site,arena,resetSeconds,roster,entities,representedUnits,liveEntities,finalEnemy,finalLocation,defeatedAt,currentGeneration,nativePresence,Map.of("capture_quality","unavailable"));
}
public CampAttempt{roster=List.copyOf(roster);entities=entities.stream().map(Map::copyOf).toList();representedUnits=representedUnits.stream().map(Map::copyOf).toList();finalLocation=Map.copyOf(finalLocation);nativePresence=Map.copyOf(nativePresence);activation=Map.copyOf(activation);}
}
public void attemptTransition(String type,CampAttempt attempt,String reason,boolean succeeded){
try{
var site=attempt.site();if(site.type()!=AdventureQuestState.SiteType.CAMP)return;
var data=new LinkedHashMap<String,Object>();data.put("actor_kind","system");data.put("site_id",site.id().toString());data.put("site_kind","camp");
data.put("site_name",site.name());data.put("catalog_id",attempt.catalog().toString());data.put("generation_id",attempt.catalog().toString());
data.put("attempt_id",attempt.id().toString());data.put("attempt_capture_quality","native_transient_not_persisted");
data.put("roster_capture_quality","acknowledged_catalog_day_roster");data.put("site_mode_at_start",site.participationMode().value());
data.put("site_revision_capture_quality",site.registrationRevision()==null?"unavailable":"persisted_definition_at_start");
if(site.registrationRevision()!=null)data.put("site_revision",site.registrationRevision().toString());
data.put("encounter_location",Map.of("world_id",site.world().toString(),"x",site.x(),"y",site.y(),"z",site.z()));
data.put("arena_shape",attempt.arena().shape().name().toLowerCase(java.util.Locale.ROOT));data.put("arena_x_blocks",attempt.arena().xBlocks());data.put("arena_z_blocks",attempt.arena().zBlocks());
data.putAll(arenaData(site,attempt.arena()));data.put("activation",attempt.activation());
data.put("reset_seconds",attempt.resetSeconds());data.put("reason",reason);
String nativeOutcome=succeeded?"succeeded":"failed";
String outcome=succeeded?"native_observed":"native_failed";
if(type.equals("adventure.camp.started")){
data.put("native_materialization",attempt.nativePresence());
if(succeeded){var status=attempt.nativePresence().get("status");
nativeOutcome="loaded_observed".equals(status)?
((Number)attempt.nativePresence().get("remaining_native_entities")).intValue()==attempt.roster().size()
&&attempt.entities().size()==attempt.roster().size()?"succeeded":"not_materialized":
"not_loaded".equals(status)?"not_loaded":"unavailable";
if(!"succeeded".equals(nativeOutcome))outcome="native_"+nativeOutcome;}
}
if(type.equals("adventure.camp.reset")){
data.put("native_cleanup",attempt.nativePresence());
if(succeeded){var status=attempt.nativePresence().get("status");
nativeOutcome="loaded_observed".equals(status)?((Number)attempt.nativePresence().get("remaining_native_entities")).intValue()==0?"succeeded":"not_confirmed_removed":
"not_loaded".equals(status)?"not_loaded":"unavailable";}
}
data.put("native_outcome",nativeOutcome);
data.put("generation_status",attempt.currentGeneration()?"current":"stale");data.put("outcome",outcome);
data.put("roster_total",attempt.roster().size());data.put("native_entity_total",attempt.entities().size());data.put("remaining_entity_count",attempt.liveEntities());
data.put("represented_unit_total",attempt.representedUnits().size());data.put("reward_owner","existing_trophy_receipt_and_beneficiary_claim");
if(attempt.finalEnemy()!=null)data.put("final_enemy_id",attempt.finalEnemy().toString());
if(!attempt.finalLocation().isEmpty())data.put("final_enemy_location",attempt.finalLocation());
if(attempt.defeatedAt()!=null)data.put("defeated_at",attempt.defeatedAt().toString());
int parts=Math.max(1,(Math.max(attempt.roster().size(),Math.max(attempt.entities().size(),attempt.representedUnits().size()))+19)/20);
for(int part=0;part<parts;part++){
var values=new LinkedHashMap<String,Object>(data);int from=part*20;
var roster=new ArrayList<Map<String,Object>>();for(int index=from;index<Math.min(from+20,attempt.roster().size());index++)
roster.add(Map.of("roster_index",index,"entity_type",attempt.roster().get(index)));
values.put("roster_entries",roster);values.put("native_entities",slice(attempt.entities(),from));
var units=slice(attempt.representedUnits(),from);values.put("represented_units",units);
values.put("represented_quest_ids",units.stream().map(unit->unit.get("quest_id")).toList());
if(parts>1){values.put("part_index",part);values.put("part_count",parts);}
accepted(type,attempt.id(),null,values,parts==1?-1:part);
}
}catch(RuntimeException ignored){/* Immutable observations have no native/gameplay authority. */}
}
private static <T>List<T> slice(List<T> values,int from){return from>=values.size()?List.of():values.subList(from,Math.min(from+20,values.size()));}
public void questExpired(games.dmg.fantasystick.adventure.AdventureQuestDirectory.QuestExpiry expiry){
questTransition("adventure.quest.expired",expiry.operation(),expiry.actor(),expiry.quest(),expiry.site(),null,expiry.reason());
}
public void questTransition(String type,UUID operation,Actor actor,AdventureQuestState.Quest quest,
AdventureQuestState.Site site,UUID acceptingLeader,String reason){
try{
if(site==null||site.type()!=AdventureQuestState.SiteType.CAMP)return;
var data=new LinkedHashMap<String,Object>();data.put("site_kind","camp");data.put("quest_id",quest.id().toString());
data.put("catalog_id",quest.catalog().toString());data.put("site_id",quest.site().toString());data.put("site_name",site.name());
data.put("unit_kind",quest.unitType().name().toLowerCase(java.util.Locale.ROOT));data.put("unit_id",quest.unit().toString());
data.put("captured_mode",quest.participationMode().value());data.put("deadline",quest.expiresAt().toString());
data.put("reason",reason);data.put("reward_xp_pool",quest.rewardXp().toString());data.put("reward_coin_pool",quest.rewardCoins().toString());
data.put("site_location",Map.of("world_id",site.world().toString(),"x",site.x(),"y",site.y(),"z",site.z()));
data.put("site_revision_capture_quality",site.registrationRevision()==null?"unavailable":"persisted_definition_at_transition");
if(site.registrationRevision()!=null)data.put("site_revision",site.registrationRevision().toString());
boolean accepting=type.equals("adventure.quest.accepted");
data.put("accepting_actor_capture_quality",accepting?"captured_request":"unavailable_not_in_saved_quest");
if(acceptingLeader!=null)data.put("accepting_leader_character_id",acceptingLeader.toString());
if(quest.unitType()==AdventureQuestState.UnitType.PARTY)data.put("party_id",quest.unit().toString());
else data.put("solo_character_id",quest.unit().toString());
var roster=quest.snapshot().stream().sorted().map(UUID::toString).toList();int parts=Math.max(1,(roster.size()+19)/20);
for(int part=0;part<parts;part++){
var values=new LinkedHashMap<String,Object>(data);int from=part*20,to=Math.min(from+20,roster.size());
values.put("accepted_roster",roster.subList(from,to));
if(parts>1){values.put("roster_total",roster.size());values.put("part_index",part);values.put("part_count",parts);}
accepted(type,operation,actor,values,parts==1?-1:part);
}
}catch(RuntimeException ignored){/* Accepted authority and abandonment never depend on audit. */}
}
public void catalogPublished(games.dmg.fantasystick.adventure.AdventureQuestDirectory.CatalogPublication publication){
try{
var catalog=publication.catalog();var data=new LinkedHashMap<String,Object>();
data.put("catalog_id",catalog.revision().toString());data.put("catalog_revision",catalog.revision().toString());
data.put("utc_day",catalog.day().toString());data.put("created_at",catalog.createdAt().toString());data.put("next_midnight",catalog.expiresAt().toString());
data.put("reason",publication.reason());data.put("publication_boundary","complete_durable_catalog");
data.put("settings_capture_quality",publication.originalCapture()?"captured_roll":"unavailable_legacy_capture");
if(publication.settings()!=null){var rules=publication.settings();
data.put("offer_settings",Map.of("adventure-daily-site-count",rules.dailySiteCount(),"adventure-site-radius",rules.radius(),
"adventure-site-reset-seconds",rules.resetSeconds(),"adventure-camp-enemies",rules.campEnemies(),
"adventure-camp-reward-xp",rules.campRewardXp().toString(),"adventure-camp-reward-coins",rules.campRewardCoins().toString()));
}
var selected=new java.util.ArrayList<Map<String,Object>>();
for(var id:catalog.sites()){
var site=publication.selected().stream().filter(value->value.id().equals(id)).findFirst().orElse(null);
var row=new LinkedHashMap<String,Object>();row.put("site_id",id.toString());
if(site!=null&&publication.originalCapture()){
row.put("site_kind",site.type().name().toLowerCase(java.util.Locale.ROOT));row.put("site_name",site.name());
row.put("mode",site.participationMode().value());row.put("definition_capture_quality","captured_roll");
row.put("revision_capture_quality",site.registrationRevision()==null?"unavailable":"persisted");
if(site.registrationRevision()!=null)row.put("site_revision",site.registrationRevision().toString());
row.put("world_id",site.world().toString());row.put("x",site.x());row.put("y",site.y());row.put("z",site.z());
}else row.put("definition_capture_quality","unavailable_legacy_capture");
selected.add(Map.copyOf(row));
}
data.put("selected_total",selected.size());
int count=Math.max(1,(selected.size()+19)/20);
for(int part=0;part<count;part++){
var values=new LinkedHashMap<String,Object>(data);int from=part*20,to=Math.min(from+20,selected.size());
values.put("selected_sites",selected.subList(from,to));
if(count>1){values.put("part_index",part);values.put("part_count",count);}
accepted("adventure.catalog_refreshed",publication.operation(),publication.actor(),values,count==1?-1:part);
}
}catch(RuntimeException ignored){/* Catalog identity and sampled authority cannot be rerolled by audit. */}
}
private void accepted(String type,UUID operation,Actor actor,Map<String,Object> input){accepted(type,operation,actor,input,-1);}
private void accepted(String type,UUID operation,Actor actor,Map<String,Object> input,int part) {
try{
var data=new LinkedHashMap<String,Object>(input);
if(actor!=null)data.putAll(actor.captured());else data.putIfAbsent("actor_kind","unavailable");
data.put("operation_id",operation.toString());data.putIfAbsent("outcome","acknowledged");
UUID id=UUID.nameUUIDFromBytes(("fantasystick:camp:"+operation+":"+type+(type.startsWith("adventure.quest.")?":quest:"+input.get("quest_id"):"")+(part<0?"":":part:"+part)).getBytes(StandardCharsets.UTF_8));
outbox.accepted(new EventInput(id,operation,clock.instant(),type,actor==null?null:actor.context(),data));
}catch(RuntimeException ignored){/* Never interrupt or replay gameplay. */}
}
public void replay(){outbox.replay();}
public CompletionStage<Void> drained(){return outbox.drained();}
@Override public void close(){outbox.close();}
}
@@ -0,0 +1,161 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.adventure.AdventureQuestState;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Function;
/** Independent champion observations; only immutable audit facts enter the worker/outbox. */
public final class ChampionEventBridge implements AutoCloseable {
private final FeatureEventOutbox outbox;
private final Clock clock=Clock.systemUTC();
public ChampionEventBridge(Function<EventInput,CompletionStage<LocalEventLog.Delivery>> sink,
Path data,Consumer<Throwable> failures){
outbox=new FeatureEventOutbox(data,"champion-event-intents",Set.of("adventure.site.registered","adventure.site.mode_changed","adventure.site.removed",
"adventure.quest.accepted","adventure.quest.abandoned","adventure.quest.expired",
"adventure.champion.spawned","adventure.champion.defeated","adventure.champion.reset","config.read","config.changed"),sink,failures);
}
public void siteRegistered(UUID operation,CampEventBridge.Actor actor,AdventureQuestState.Site site,AdventureQuestState.Arena arena){
try{
if(site.type()!=AdventureQuestState.SiteType.CHAMPION)return;
var data=CampEventBridge.siteData(site,arena);
data.put("mode_before","not_applicable");data.put("mode_after",site.participationMode().value());
data.put("catalog_impact","unchanged_by_registration");data.put("active_quest_impact","unchanged");
accepted("adventure.site.registered",operation,actor,data);
}catch(RuntimeException ignored){/* Acknowledged authority is independent of audit availability. */}
}
public void siteModeChanged(UUID operation,CampEventBridge.Actor actor,AdventureQuestState.Site requestedBefore,
AdventureQuestState.Site acknowledged,AdventureQuestState.Arena arena){
try{
if(acknowledged.type()!=AdventureQuestState.SiteType.CHAMPION)return;
var data=CampEventBridge.siteData(acknowledged,arena);
data.put("mode_before",requestedBefore.participationMode().value());data.put("mode_after",acknowledged.participationMode().value());
if(requestedBefore.registrationRevision()!=null)data.put("requested_site_revision",requestedBefore.registrationRevision().toString());
data.put("application_scope","future_acceptances");data.put("active_quest_impact","captured_authority_unchanged");
data.put("catalog_impact","same_offer_future_acceptances_use_new_mode");
accepted("adventure.site.mode_changed",operation,actor,data);
}catch(RuntimeException ignored){/* Mode publication never depends on audit. */}
}
public void siteRemoved(UUID operation,CampEventBridge.Actor actor,AdventureQuestState.Site removed,
AdventureQuestState.Arena arena,AdventureQuestState.Catalog catalog){
try{
if(removed.type()!=AdventureQuestState.SiteType.CHAMPION)return;
var data=CampEventBridge.siteData(removed,arena);
data.put("mode_before",removed.participationMode().value());data.put("mode_after","not_applicable");
boolean offered=catalog!=null&&catalog.sites().contains(removed.id());data.put("offered_at_acknowledgement",offered);
if(catalog!=null)data.put("catalog_id",catalog.revision().toString());
data.put("catalog_impact",offered?"slot_unavailable_until_reset":"unoffered_registration_removed");
data.put("active_quest_impact","unfinished_site_quests_expired");data.put("earned_reward_impact","retained");
accepted("adventure.site.removed",operation,actor,data);
}catch(RuntimeException ignored){/* Durable removal cannot be undone or duplicated by audit. */}
}
public static boolean ownsSetting(String key){return Set.of(
games.dmg.fantasystick.persistence.AdventureQuestSettings.CHAMPION_HEALTH,games.dmg.fantasystick.persistence.AdventureQuestSettings.CHAMPION_DAMAGE,
games.dmg.fantasystick.persistence.AdventureQuestSettings.CHAMPION_REWARD_XP,games.dmg.fantasystick.persistence.AdventureQuestSettings.CHAMPION_REWARD_COINS).contains(key);}
private static LinkedHashMap<String,Object> settingData(String key,String defaults){
var data=new LinkedHashMap<String,Object>();data.put("key",key);data.put("default",defaults);data.put("feature","champion");
data.put("application_scope",key.equals(games.dmg.fantasystick.persistence.AdventureQuestSettings.CHAMPION_HEALTH)
||key.equals(games.dmg.fantasystick.persistence.AdventureQuestSettings.CHAMPION_DAMAGE)?"future_attempts":"future_acceptances");return data;
}
public void configRead(CampEventBridge.Actor actor,String key,String effective,String defaults){
try{if(!ownsSetting(key))return;var data=settingData(key,defaults);data.put("effective",effective);data.put("outcome","acknowledged_read");
accepted("config.read",UUID.randomUUID(),actor,data);}catch(RuntimeException ignored){/* Reporting cannot depend on audit. */}
}
public void configChanged(CampEventBridge.Actor actor,UUID operation,String key,String before,String after,String defaults){
try{if(!ownsSetting(key)||before.equals(after))return;var data=settingData(key,defaults);data.put("before",before);data.put("after",after);
data.put("effective",after);data.put("outcome","acknowledged_future_publication");accepted("config.changed",operation,actor,data);
}catch(RuntimeException ignored){/* Effective publication cannot be rolled back or duplicated by audit. */}
}
public void attemptTransition(String type,CampEventBridge.CampAttempt attempt,Map<String,Object> configuration,String reason,boolean succeeded){
try{
var site=attempt.site();if(site.type()!=AdventureQuestState.SiteType.CHAMPION)return;
var data=new LinkedHashMap<String,Object>();data.put("actor_kind","system");data.put("site_id",site.id().toString());data.put("site_kind","champion");
data.put("site_name",site.name());data.put("catalog_id",attempt.catalog().toString());data.put("generation_id",attempt.catalog().toString());
data.put("attempt_id",attempt.id().toString());data.put("attempt_capture_quality","native_transient_not_persisted");
data.put("roster_capture_quality","registered_single_vindicator_definition");data.put("site_mode_at_start",site.participationMode().value());
data.put("site_revision_capture_quality",site.registrationRevision()==null?"unavailable":"persisted_definition_at_start");
if(site.registrationRevision()!=null)data.put("site_revision",site.registrationRevision().toString());
data.put("encounter_location",Map.of("world_id",site.world().toString(),"x",site.x(),"y",site.y(),"z",site.z()));
data.put("arena_shape",attempt.arena().shape().name().toLowerCase(java.util.Locale.ROOT));data.put("arena_x_blocks",attempt.arena().xBlocks());data.put("arena_z_blocks",attempt.arena().zBlocks());
data.putAll(CampEventBridge.arenaData(site,attempt.arena()));data.put("activation",attempt.activation());
data.put("reset_seconds",attempt.resetSeconds());data.put("reason",reason);data.putAll(configuration);
String nativeOutcome=succeeded?"succeeded":"failed";String outcome=succeeded?"native_observed":"native_failed";
if(type.equals("adventure.champion.spawned")){
data.put("native_materialization",attempt.nativePresence());
if(succeeded){var status=attempt.nativePresence().get("status");
nativeOutcome="loaded_observed".equals(status)?
((Number)attempt.nativePresence().get("remaining_native_entities")).intValue()==1&&attempt.entities().size()==1?"succeeded":"not_materialized":
"not_loaded".equals(status)?"not_loaded":"unavailable";
if(!"succeeded".equals(nativeOutcome))outcome="native_"+nativeOutcome;
}
}
if(type.equals("adventure.champion.reset")){
data.put("native_cleanup",attempt.nativePresence());
if(succeeded){var status=attempt.nativePresence().get("status");
nativeOutcome="loaded_observed".equals(status)?((Number)attempt.nativePresence().get("remaining_native_entities")).intValue()==0?"succeeded":"not_confirmed_removed":
"not_loaded".equals(status)?"not_loaded":"unavailable";}
}
data.put("native_outcome",nativeOutcome);data.put("generation_status",attempt.currentGeneration()?"current":"stale");data.put("outcome",outcome);
data.put("roster_total",attempt.roster().size());data.put("native_entity_total",attempt.entities().size());data.put("remaining_entity_count",attempt.liveEntities());
data.put("represented_unit_total",attempt.representedUnits().size());data.put("reward_owner","existing_trophy_receipt_and_beneficiary_claim");
if(attempt.finalEnemy()!=null)data.put("final_enemy_id",attempt.finalEnemy().toString());
if(!attempt.finalLocation().isEmpty())data.put("final_enemy_location",attempt.finalLocation());if(attempt.defeatedAt()!=null)data.put("defeated_at",attempt.defeatedAt().toString());
int parts=Math.max(1,(Math.max(attempt.entities().size(),attempt.representedUnits().size())+19)/20);
for(int part=0;part<parts;part++){
var values=new LinkedHashMap<String,Object>(data);int from=part*20;
values.put("native_entities",slice(attempt.entities(),from));var units=slice(attempt.representedUnits(),from);values.put("represented_units",units);
values.put("represented_quest_ids",units.stream().map(unit->unit.get("quest_id")).toList());
if(parts>1){values.put("part_index",part);values.put("part_count",parts);}
accepted(type,attempt.id(),null,values,parts==1?-1:part);
}
}catch(RuntimeException ignored){/* Audit facts cannot materialize, clear, reset or reward an encounter. */}
}
private static <T>java.util.List<T> slice(java.util.List<T> values,int from){return from>=values.size()?java.util.List.of():values.subList(from,Math.min(from+20,values.size()));}
public void questExpired(games.dmg.fantasystick.adventure.AdventureQuestDirectory.QuestExpiry expiry){
questTransition("adventure.quest.expired",expiry.operation(),expiry.actor(),expiry.quest(),expiry.site(),null,expiry.reason());
}
public void questTransition(String type,UUID operation,CampEventBridge.Actor actor,AdventureQuestState.Quest quest,
AdventureQuestState.Site site,UUID acceptingLeader,String reason){
try{
if(site==null||site.type()!=AdventureQuestState.SiteType.CHAMPION)return;
var data=new LinkedHashMap<String,Object>();data.put("site_kind","champion");data.put("quest_id",quest.id().toString());
data.put("catalog_id",quest.catalog().toString());data.put("site_id",quest.site().toString());data.put("site_name",site.name());
data.put("unit_kind",quest.unitType().name().toLowerCase(java.util.Locale.ROOT));data.put("unit_id",quest.unit().toString());
data.put("captured_mode",quest.participationMode().value());data.put("deadline",quest.expiresAt().toString());
data.put("reason",reason);data.put("reward_xp_pool",quest.rewardXp().toString());data.put("reward_coin_pool",quest.rewardCoins().toString());
data.put("site_location",Map.of("world_id",site.world().toString(),"x",site.x(),"y",site.y(),"z",site.z()));
data.put("site_revision_capture_quality",site.registrationRevision()==null?"unavailable":"persisted_definition_at_transition");
if(site.registrationRevision()!=null)data.put("site_revision",site.registrationRevision().toString());
data.put("accepting_actor_capture_quality",type.equals("adventure.quest.accepted")?"captured_request":"unavailable_not_in_saved_quest");
if(acceptingLeader!=null)data.put("accepting_leader_character_id",acceptingLeader.toString());
if(quest.unitType()==AdventureQuestState.UnitType.PARTY)data.put("party_id",quest.unit().toString());else data.put("solo_character_id",quest.unit().toString());
var roster=quest.snapshot().stream().sorted().map(UUID::toString).toList();int parts=Math.max(1,(roster.size()+19)/20);
for(int part=0;part<parts;part++){
var values=new LinkedHashMap<String,Object>(data);int from=part*20,to=Math.min(from+20,roster.size());values.put("accepted_roster",roster.subList(from,to));
if(parts>1){values.put("roster_total",roster.size());values.put("part_index",part);values.put("part_count",parts);}
accepted(type,operation,actor,values,parts==1?-1:part);
}
}catch(RuntimeException ignored){/* Quest publication never authorizes reward or native replay. */}
}
private void accepted(String type,UUID operation,CampEventBridge.Actor actor,Map<String,Object> input){accepted(type,operation,actor,input,-1);}
private void accepted(String type,UUID operation,CampEventBridge.Actor actor,Map<String,Object> input,int part){
try{
var data=new LinkedHashMap<String,Object>(input);
if(actor!=null)data.putAll(actor.captured());else data.putIfAbsent("actor_kind","unavailable");
data.put("operation_id",operation.toString());data.putIfAbsent("outcome","acknowledged");
String discriminator=type.startsWith("adventure.quest.")?":"+data.get("quest_id"):"";
UUID id=UUID.nameUUIDFromBytes(("fantasystick:champion:"+operation+":"+type+discriminator+(part<0?"":":"+part)).getBytes(StandardCharsets.UTF_8));
outbox.accepted(new EventInput(id,operation,clock.instant(),type,actor==null?null:actor.context(),data));
}catch(RuntimeException ignored){/* Never interrupt or replay gameplay. */}
}
public void replay(){outbox.replay();}
public CompletionStage<Void> drained(){return outbox.drained();}
@Override public void close(){outbox.close();}
}
@@ -0,0 +1,89 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.dungeon.DungeonDirectory;
import games.dmg.fantasystick.dungeon.DungeonState;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.command.CommandSender;
/** Original character-boss observations only; no character, inventory or encounter authority. */
public final class CharacterBossEventBridge implements AutoCloseable {
private final FeatureEventOutbox outbox;
private final Function<CommandSender,CampEventBridge.Actor> capture;
public CharacterBossEventBridge(Function<EventInput,CompletionStage<LocalEventLog.Delivery>> sink,Path data,
Function<CommandSender,CampEventBridge.Actor> capture,Consumer<Throwable> failures){
this.capture=java.util.Objects.requireNonNull(capture);
outbox=new FeatureEventOutbox(data,"character-boss-event-intents",Set.of("dungeon.character_boss.assigned","dungeon.character_boss.recovered","action.resolved","dungeon.entity.spawned","dungeon.entity.removed","dungeon.entity.defeated","dungeon.character_boss.target_changed","ability.cast","ability.resolved","combat.damage","combat.healed","effect.applied","effect.ended"),sink,failures);
}
public record Request(UUID action,Instant observedAt,CampEventBridge.Actor actor){}
public Request request(CommandSender sender){CampEventBridge.Actor actor;try{actor=capture.apply(sender);}catch(RuntimeException unavailable){actor=null;}if(actor==null)actor=unavailableActor(sender);return new Request(UUID.randomUUID(),Instant.now(),actor);}
private static CampEventBridge.Actor unavailableActor(CommandSender sender){
var data=new LinkedHashMap<String,Object>();data.put("actor_details_status","optional_capture_unavailable");data.put("location_status","unavailable");
if(sender instanceof org.bukkit.entity.Player player){data.put("actor_kind","player");try{var id=player.getUniqueId();String name;try{name=player.getName();}catch(RuntimeException unavailable){name="unavailable";data.put("actor_name_status","unavailable");}if(name==null){name="unavailable";data.put("actor_name_status","unavailable");}return new CampEventBridge.Actor(new EventInput.Context(id,name,null,null,null,null),data);}catch(RuntimeException unavailable){data.put("actor_identity_status","unavailable");}}
else data.put("actor_kind",sender instanceof org.bukkit.command.ConsoleCommandSender?"console":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"unavailable");
return new CampEventBridge.Actor(null,data);
}
public record Facts(Request request,UUID custody,Map<String,Object> data){public Facts{data=Map.copyOf(data);}}
public Facts custody(Request request,DungeonState.Dungeon definition,UUID room,DungeonState.CharacterBoss custody){
var subject=custody.character();var data=new LinkedHashMap<String,Object>();data.put("dungeon_id",definition.id().toString());data.put("dungeon_name",definition.name());data.put("definition_revision",definition.revision().toString());data.put("room_id",room.toString());data.put("custody_operation_id",custody.operation().toString());data.put("subject_character_id",subject.id().toString());data.put("subject_character_name",subject.name());data.put("subject_character_revision",subject.revision().toString());data.put("subject_sheet_generation",subject.sheetGeneration().toString());data.put("subject_role","custody_template_not_active_actor");data.put("multiplier",custody.multiplier().toPlainString());data.put("prior_entity_type",custody.priorType().name());data.put("prior_entity_name",custody.priorName());var stats=new LinkedHashMap<String,Object>();custody.effectiveStats().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry->stats.put(entry.getKey().name(),entry.getValue().toString()));data.put("multiplied_stats",Map.copyOf(stats));if(custody.recovery()!=null)data.put("recovery_operation_id",custody.recovery().operation().toString());return new Facts(request,custody.operation(),data);
}
public Facts assignment(Request request,DungeonDirectory.Publication publication,UUID dungeon,UUID room,DungeonState.CharacterBoss custody){
var after=publication.after().dungeons().stream().filter(value->value.id().equals(dungeon)).findFirst().orElseThrow();var before=publication.before().dungeons().stream().filter(value->value.id().equals(dungeon)).findFirst().orElseThrow();var facts=custody(request,after,room,custody);var data=new LinkedHashMap<>(facts.data());data.put("before_revision",before.revision().toString());data.put("after_revision",after.revision().toString());var boss=before.rooms().stream().filter(value->value.id().equals(room)).findFirst().orElseThrow().boss();if(boss!=null)data.put("prior_spawn_location",position(before.world(),boss.position()));return new Facts(request,custody.operation(),data);
}
private static Map<String,Object> position(UUID world,DungeonState.Position at){return Map.of("world_id",world.toString(),"x",at.x(),"y",at.y(),"z",at.z(),"yaw",(double)at.yaw(),"pitch",(double)at.pitch());}
/** Native shell subject and original immutable source facts, never an activating-player actor. */
public Facts runtime(DungeonState.Dungeon dungeon,DungeonState.Room room,UUID generation,UUID shell,String type,Map<String,Object> location){
var request=new Request(UUID.randomUUID(),Instant.now(),null);var facts=custody(request,dungeon,room.id(),room.boss().character());var data=new LinkedHashMap<>(facts.data());data.put("actor_kind","system");data.put("location_status","not_applicable");data.put("generation_id",generation.toString());data.put("catalog_id",generation.toString());data.put("native_entity_id",shell.toString());data.put("entity_type",type);data.put("entity_role","character_boss");data.put("entity_name",room.boss().name());data.put("spawn_location",Map.copyOf(location));data.put("managed_source_id",UUID.nameUUIDFromBytes((dungeon.id()+":"+room.id()+":"+generation+":boss").getBytes(StandardCharsets.UTF_8)).toString());data.put("run_id",UUID.nameUUIDFromBytes(("dungeon-run:"+dungeon.id()+":"+generation).getBytes(StandardCharsets.UTF_8)).toString());data.put("run_identity_quality","persisted_dungeon_generation_composite");return new Facts(request,facts.custody(),data);
}
public void spawned(Facts facts){emit("dungeon.entity.spawned",UUID.fromString(facts.data().get("native_entity_id").toString()),facts,Map.of("outcome","native_observed","materialization_status","loaded_valid_registered_entity","attempt_capture_quality","native_transient_not_persisted","causation","accepted_dungeon_native_spawn"));}
/** Freeze a new original native transition while retaining the original shell's immutable source/subject. */
public Facts transition(Facts original,Map<String,Object> details){
var data=new LinkedHashMap<>(original.data());data.putIfAbsent("spawn_operation_id",original.request().action().toString());data.putAll(details);return new Facts(new Request(UUID.randomUUID(),Instant.now(),null),original.custody(),data);
}
public void removed(Facts facts,String reason,String status){emit("dungeon.entity.removed",UUID.fromString(facts.data().get("native_entity_id").toString()),facts,Map.of("reason",reason,"outcome","native_cleanup_observed","cleanup_status",status,"reward_status","non_reward_cleanup","causation","character_shell_native_cleanup"));}
public void defeated(Facts facts){emit("dungeon.entity.defeated",UUID.fromString(facts.data().get("native_entity_id").toString()),facts,Map.of("reason","native_boss_completion_committed","outcome","acknowledged","settlement_status","acknowledged_boss_completion","reward_owner","US-085","causation","accepted_native_character_shell_death"));}
public void targetChanged(Facts facts){emit("dungeon.character_boss.target_changed",facts.request().action(),facts,Map.of("outcome","accepted","target_status","native_target_confirmed","causation","accepted_character_shell_threat_target"));}
public Facts cast(Facts original,Map<String,Object> details){var facts=transition(original,details);var data=new LinkedHashMap<>(facts.data());data.put("cast_id",facts.request().action().toString());return new Facts(facts.request(),facts.custody(),data);}
public void casting(Facts facts){emit("ability.cast",facts.request().action(),facts,Map.of("outcome","attempted","causation","ready_equipped_character_boss_slot"));}
public void resolved(Facts facts,boolean cooldown,String outcome){emit("ability.resolved",facts.request().action(),facts,Map.of("outcome",outcome,"cooldown_committed",cooldown,"causation","original_native_character_boss_cast"));}
public void effectApplied(Facts facts,UUID application,Map<String,Object> details){emit("effect.applied",application,facts,details);}
public void effectEnded(Facts facts,UUID application,Map<String,Object> details){emit("effect.ended",application,facts,details);}
public void effectEndPending(Facts facts,UUID application,Map<String,Object> details){emit("action.resolved",application,facts,details);}
public void damage(Facts facts,Map<String,Object> details){emit("combat.damage",facts.request().action(),facts,details);}
public void healed(Facts facts,Map<String,Object> details){emit("combat.healed",facts.request().action(),facts,details);}
/** Source acknowledgement alone is not physical consumption; no late sender/native lookup. */
public void custodySaved(Facts facts){var phase=UUID.nameUUIDFromBytes((facts.custody()+":custody_saved").getBytes(StandardCharsets.UTF_8));emit("action.resolved",phase,facts,Map.of("phase","custody_saved","outcome","pending_native_consumption","custody_status","durably_assigned","sheet_status","consumption_not_acknowledged","causation","registered_assign_sheet"));}
public void assigned(Facts facts){emit("dungeon.character_boss.assigned",facts.custody(),facts,Map.of("outcome","acknowledged","custody_status","durably_assigned","sheet_status","exact_original_consumed","causation","registered_assign_sheet"));}
public void rolledBack(Facts facts,boolean acknowledged,DungeonDirectory.Publication publication,String reason){
var phase=acknowledged?"assignment_cancelled":"assignment_rollback_pending";var data=new LinkedHashMap<String,Object>();data.put("phase",phase);data.put("outcome",acknowledged?"cancelled":"pending_rollback");data.put("reason",reason);data.put("sheet_status","consumption_not_acknowledged");data.put("custody_status",acknowledged?"assignment_rollback_acknowledged":"rollback_unacknowledged");data.put("rollback_publication_status",publication==null?"unavailable_not_acknowledged":"original_source_publication");data.put("causation","registered_assign_sheet");if(publication!=null){var dungeon=facts.data().get("dungeon_id").toString();publication.after().dungeons().stream().filter(value->value.id().toString().equals(dungeon)).findFirst().ifPresent(value->data.put("rollback_after_revision",value.revision().toString()));}var transition=UUID.nameUUIDFromBytes((facts.custody()+":"+phase+":"+facts.request().action()).getBytes(StandardCharsets.UTF_8));emit("action.resolved",transition,facts,data);
}
public void recoveryPending(Facts facts,UUID recovery,String reason){var phase=UUID.nameUUIDFromBytes((recovery+":recovery_completion_pending:"+facts.request().action()).getBytes(StandardCharsets.UTF_8));emit("action.resolved",phase,facts,Map.of("phase","recovery_completion_pending","outcome","pending_durable_completion","recovery_operation_id",recovery.toString(),"custody_status","recovery_prepared_completion_unacknowledged","sheet_status","exact_authorized_sheet_delivered","reason",reason,"causation","registered_recover_sheet"));}
public void recovered(Facts facts,UUID recovery,DungeonDirectory.Publication publication,String delivery){
var data=new LinkedHashMap<String,Object>();data.put("outcome","acknowledged");data.put("custody_status","durably_completed");data.put("sheet_status",delivery);data.put("causation","registered_recover_sheet");data.put("recovery_operation_id",recovery.toString());var dungeon=facts.data().get("dungeon_id").toString();publication.after().dungeons().stream().filter(value->value.id().toString().equals(dungeon)).findFirst().ifPresent(value->data.put("after_revision",value.revision().toString()));emit("dungeon.character_boss.recovered",recovery,facts,data);
}
private void emit(String type,UUID transition,Facts facts,Map<String,Object> details){
var data=new LinkedHashMap<>(facts.data());data.putAll(details);var request=facts.request();data.put("operation_id",request.action().toString());var actor=request.actor();if(actor!=null)data.putAll(actor.captured());else data.putIfAbsent("actor_kind","unavailable");if(actor==null||actor.context()==null){var original=data.get("actor_kind");if(!"console".equals(original)&&!"command_block".equals(original)){data.put("original_actor_kind",java.util.Objects.toString(original,"unavailable"));data.put("actor_kind","system");}data.putIfAbsent("location_status","unavailable");}var id=UUID.nameUUIDFromBytes(("fantasystick:character-boss:"+transition+":"+type).getBytes(StandardCharsets.UTF_8));emitBounded(new EventInput(id,request.action(),request.observedAt(),type,actor==null?null:actor.context(),data));
}
/** Only large original decimal stats are fragmented; envelope/codec/bound remain unchanged.
* Part zero is the sole logical outcome; other rows carry exact linked original digits only.
* Independent files are not a cross-crash atomic bundle: consumers must require all parts.
*/
private void emitBounded(EventInput event){
var value=event.data().get("multiplied_stats");if(!(value instanceof Map<?,?> stats)||stats.values().stream().mapToLong(part->part.toString().length()).sum()<=4096){outbox.accepted(event);return;}
var fragments=new java.util.ArrayList<Map<String,Object>>();stats.entrySet().stream().sorted(java.util.Comparator.comparing(entry->entry.getKey().toString())).forEach(entry->{var digits=entry.getValue().toString();for(int offset=0;offset<digits.length();offset+=1024)fragments.add(Map.of("stat",entry.getKey().toString(),"offset",offset,"total_digits",digits.length(),"digits",digits.substring(offset,Math.min(digits.length(),offset+1024))));});
var base=new LinkedHashMap<>(event.data());base.remove("multiplied_stats");base.put("multiplied_stats_status","exact_linked_original_decimal_parts");base.put("multiplied_stats_count",stats.size());base.put("logical_event_id",event.id().toString());base.put("event_part_count",fragments.size()+1);base.put("parts_required_for_complete_stats",true);
var continuation=new LinkedHashMap<String,Object>();for(var key:java.util.List.of("operation_id","logical_event_id","event_part_count","multiplied_stats_status","multiplied_stats_count","parts_required_for_complete_stats","subject_character_id","subject_character_revision","subject_sheet_generation","custody_operation_id","dungeon_id","room_id","generation_id","native_entity_id","multiplier","actor_kind","original_actor_kind"))if(base.containsKey(key))continuation.put(key,base.get(key));
for(int index=0;index<=fragments.size();index++){var data=new LinkedHashMap<>(index==0?base:continuation);data.put("event_part_index",index);data.put("payload_part_kind",index==0?"original_facts_and_outcome":"multiplied_stat_digits");if(index>0)data.put("multiplied_stats_fragment",fragments.get(index-1));var id=index==0?event.id():UUID.nameUUIDFromBytes((event.id()+":part:"+index).getBytes(StandardCharsets.UTF_8));outbox.accepted(new EventInput(id,event.actionId(),event.time(),event.type(),event.context(),data));}
}
public void replay(){outbox.replay();}
public CompletionStage<Void> drained(){return outbox.drained();}
@Override public void close(){outbox.close();}
}
@@ -12,15 +12,25 @@ import java.util.UUID;
import java.util.function.Consumer;
/** Failure-isolated character coin initialization, final-death loss and configuration records. */
public final class CoinEventBridge {
public final class CoinEventBridge implements AutoCloseable {
private final Consumer<EventInput> sink;private final Clock clock;
public CoinEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
private final FeatureEventOutbox outbox;
public CoinEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);outbox=null;}
public CoinEventBridge(java.util.function.Function<EventInput,java.util.concurrent.CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock,java.nio.file.Path data,Consumer<Throwable> failures){
this.sink=event->sink.apply(event);this.clock=Objects.requireNonNull(clock);
outbox=new FeatureEventOutbox(data,"coin-event-intents",java.util.Set.of("character.coins_initialized","character.coins_lost","config.read","config.changed"),sink,failures);
}
public void replay(){if(outbox!=null)outbox.replay();}
public java.util.concurrent.CompletionStage<Void> drained(){return outbox==null?java.util.concurrent.CompletableFuture.completedFuture(null):outbox.drained();}
@Override public void close(){if(outbox!=null)outbox.close();}
private void publish(EventInput event){if(outbox!=null)outbox.accepted(event);else sink.accept(event);}
public void initialized(CharacterRecord record,String source,CoinRules rules){var operation=stable(record.id(),"coins-initialized");var data=new LinkedHashMap<String,Object>();data.put("operation_id",operation.toString());data.put("character_id",record.id().toString());data.put("character_name",record.name());data.put("source",source);data.put("configured_minimum",Long.toString(rules.startingMin()));data.put("configured_maximum",Long.toString(rules.startingMax()));data.put("balance",record.coins().toString());data.put("revision",record.revision().toString());data.put("outcome","acknowledged");emit(operation,"character.coins_initialized",data);}
public void lost(CharacterRecord before,CharacterRecord after,UUID death,Instant diedAt){var data=new LinkedHashMap<String,Object>();data.put("operation_id",death.toString());data.put("death_id",death.toString());data.put("character_id",after.id().toString());data.put("character_name",after.name());data.put("reason","player_attributed_final_death");data.put("balance_before",before.coins().toString());data.put("balance_after",after.coins().toString());data.put("death_bound_escrow",after.pvpCoinEscrow().toString());data.put("before_revision",before.revision().toString());data.put("after_revision",after.revision().toString());data.put("final_death_at",diedAt.toString());data.put("outcome","acknowledged");emit(death,"character.coins_lost",data);}
public void configRead(String actorKind,UUID player,String name,String key,String value){config("config.read",actorKind,player,name,key,null,value);}
public void configChanged(String actorKind,UUID player,String name,String key,String before,String after){config("config.changed",actorKind,player,name,key,before,after);}
private void config(String type,String actorKind,UUID player,String name,String key,String before,String after){var action=UUID.randomUUID();var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);if(player!=null)data.put("actor_player_id",player.toString());if(name!=null)data.put("actor_player_name",name);data.put("key",key);if(before!=null)data.put("before",before);data.put("after",after);data.put("application",key.startsWith("starting-")?"future_character_initialization":"future_mob_defeats");data.put("outcome","acknowledged");try{sink.accept(new EventInput(id(action,type),action,clock.instant(),type,player==null?null:new EventInput.Context(player,name,null,null,null,null),Map.copyOf(data)));}catch(RuntimeException ignored){}}
private void emit(UUID action,String type,Map<String,Object> data){try{sink.accept(new EventInput(id(action,type),action,clock.instant(),type,null,Map.copyOf(data)));}catch(RuntimeException ignored){}}
private void config(String type,String actorKind,UUID player,String name,String key,String before,String after){var action=UUID.randomUUID();var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);if(player!=null)data.put("actor_player_id",player.toString());if(name!=null)data.put("actor_player_name",name);data.put("key",key);if(before!=null)data.put("before",before);data.put("after",after);data.put("application",key.startsWith("starting-")?"future_character_initialization":"future_mob_defeats");data.put("outcome","acknowledged");try{publish(new EventInput(id(action,type),action,clock.instant(),type,player==null?null:new EventInput.Context(player,name,null,null,null,null),Map.copyOf(data)));}catch(RuntimeException ignored){}}
private void emit(UUID action,String type,Map<String,Object> data){try{publish(new EventInput(id(action,type),action,clock.instant(),type,null,Map.copyOf(data)));}catch(RuntimeException ignored){}}
private static UUID stable(UUID character,String kind){return UUID.nameUUIDFromBytes(("fantasystick:coin:"+character+":"+kind).getBytes(StandardCharsets.UTF_8));}
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:coin:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
}
@@ -17,22 +17,49 @@ import java.util.function.Consumer;
import org.bukkit.entity.Player;
/** Failure-isolated, single-owner recipient resolution for combat XP and coin shares. */
public final class CombatRewardEventBridge {
public final class CombatRewardEventBridge implements AutoCloseable {
public record Capture(UUID targetId,String targetType,String experienceClass,String coinClass,
CombatRewardRules.KillPosition killPosition,ManagedEncounterOrigin managedOrigin,
String stage,Instant defeatedAt,java.util.List<UUID> directContributors,
String rewardUnitId,int rewardUnitCount,BigInteger experiencePool,BigInteger coinPool){
public Capture{Objects.requireNonNull(targetId);Objects.requireNonNull(targetType);Objects.requireNonNull(experienceClass);Objects.requireNonNull(coinClass);Objects.requireNonNull(stage);Objects.requireNonNull(defeatedAt);directContributors=java.util.List.copyOf(directContributors);Objects.requireNonNull(rewardUnitId);Objects.requireNonNull(experiencePool);Objects.requireNonNull(coinPool);if(rewardUnitCount<1||experiencePool.signum()<0||coinPool.signum()<0)throw new IllegalArgumentException("Invalid combat reward capture");}
String rewardUnitId,int rewardUnitCount,BigInteger experiencePool,BigInteger coinPool,
Map<String,Object> managedEvidence){
public Capture(UUID targetId,String targetType,String experienceClass,String coinClass,
CombatRewardRules.KillPosition killPosition,ManagedEncounterOrigin managedOrigin,
String stage,Instant defeatedAt,java.util.List<UUID> directContributors,
String rewardUnitId,int rewardUnitCount,BigInteger experiencePool,BigInteger coinPool){
this(targetId,targetType,experienceClass,coinClass,killPosition,managedOrigin,stage,defeatedAt,
directContributors,rewardUnitId,rewardUnitCount,experiencePool,coinPool,Map.of());
}
public Capture{managedEvidence=Map.copyOf(managedEvidence);Objects.requireNonNull(targetId);Objects.requireNonNull(targetType);Objects.requireNonNull(experienceClass);Objects.requireNonNull(coinClass);Objects.requireNonNull(stage);Objects.requireNonNull(defeatedAt);directContributors=java.util.List.copyOf(directContributors);Objects.requireNonNull(rewardUnitId);Objects.requireNonNull(experiencePool);Objects.requireNonNull(coinPool);if(rewardUnitCount<1||experiencePool.signum()<0||coinPool.signum()<0)throw new IllegalArgumentException("Invalid combat reward capture");}
}
private final Consumer<EventInput> sink;private final Clock clock;
public CombatRewardEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
private final FeatureEventOutbox outbox;
public CombatRewardEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);outbox=null;}
public CombatRewardEventBridge(java.util.function.Function<EventInput,java.util.concurrent.CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock,java.nio.file.Path data,Consumer<Throwable> failures){
this.sink=event->sink.apply(event);this.clock=Objects.requireNonNull(clock);
outbox=new FeatureEventOutbox(data,"combat-reward-event-intents",java.util.Set.of("combat.reward_resolved"),sink,failures);
}
public void replay(){if(outbox!=null)outbox.replay();}
public java.util.concurrent.CompletionStage<Void> drained(){return outbox==null?java.util.concurrent.CompletableFuture.completedFuture(null):outbox.drained();}
@Override public void close(){if(outbox!=null)outbox.close();}
public void resolved(UUID operation,UUID character,BigInteger allocatedExperience,BigInteger allocatedCoins,
CombatRewardRules.KillPosition restricted,CombatRewardRules rules,Capture capture,
CharacterRecord before,CharacterStorage.CombatAward award,EquipmentSessions.Active active,
Player player,String nativeSync,String feedback){
if(award==null||award.record().isEmpty()||before==null)return;var after=award.record().orElseThrow();var data=new LinkedHashMap<String,Object>();data.put("operation_id",operation.toString());data.put("reward_receipt",operation.toString());data.put("stage",capture.stage());data.put("recipient_character_id",character.toString());data.put("target_id",capture.targetId().toString());data.put("target_type",capture.targetType());data.put("experience_class",capture.experienceClass());data.put("coin_class",capture.coinClass());data.put("defeated_at",capture.defeatedAt().toString());data.put("direct_contributor_character_ids",capture.directContributors().stream().map(UUID::toString).sorted().toList());data.put("reward_unit_id",capture.rewardUnitId());data.put("reward_unit_count",capture.rewardUnitCount());data.put("experience_pool",capture.experiencePool().toString());data.put("coin_pool",capture.coinPool().toString());if(capture.killPosition()!=null)data.put("kill_location",position(capture.killPosition()));data.put("allocated_experience",allocatedExperience.toString());data.put("allocated_coins",allocatedCoins.toString());var appliedExperience=after.experience().subtract(before.experience()).max(BigInteger.ZERO);var appliedCoins=after.coins().subtract(before.coins()).max(BigInteger.ZERO);data.put("applied_experience",appliedExperience.toString());data.put("applied_coins",appliedCoins.toString());data.put("suppressed_experience",allocatedExperience.subtract(appliedExperience.min(allocatedExperience)).toString());data.put("suppressed_coins",allocatedCoins.subtract(appliedCoins.min(allocatedCoins)).toString());data.put("before_revision",before.revision().toString());data.put("after_revision",after.revision().toString());data.put("before_level",before.level());data.put("after_level",after.level());data.put("before_experience",before.experience().toString());data.put("after_experience",after.experience().toString());data.put("before_points",before.unspentPoints().toString());data.put("after_points",after.unspentPoints().toString());data.put("balance_before",before.coins().toString());data.put("balance_after",after.coins().toString());data.put("storage_status",award.status().name().toLowerCase(java.util.Locale.ROOT));data.put("levels_gained",award.levels());data.put("native_sync",nativeSync);data.put("feedback",feedback);if(capture.managedOrigin()!=null){data.put("resolution","managed_origin_exempt");data.put("managed_origin_kind",capture.managedOrigin().kind().name().toLowerCase(java.util.Locale.ROOT));data.put("managed_origin_source",capture.managedOrigin().source().toString());data.put("spacing_applicability","authenticated_managed_origin_exempt");}else if(restricted!=null){boolean blocked=award.status()==CharacterStorage.CombatAwardStatus.SPACING_BLOCKED;data.put("resolution",blocked?"ordinary_spacing_blocked":"ordinary_spacing_eligible");data.put("spacing_applicability","ordinary_restricted_kill");data.put("spacing_minimum",rules.restrictedSpacing().toPlainString());var prior=before.restrictedKills().get(restricted.world());if(prior!=null)data.put("ordinary_history_before",position(prior));var current=after.restrictedKills().get(restricted.world());if(current!=null)data.put("ordinary_history_after",position(current));}else{data.put("resolution","spacing_not_applicable");data.put("spacing_applicability","passive_or_fantasy_character");}data.put("loss_reason",loss(award.status()));var context=context(active,player,after.name());try{sink.accept(new EventInput(id(operation),operation,clock.instant(),"combat.reward_resolved",context,Map.copyOf(data)));}catch(RuntimeException ignored){}
if(award==null||award.record().isEmpty()||before==null||award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE)return;var after=award.record().orElseThrow();var data=new LinkedHashMap<String,Object>();data.put("operation_id",operation.toString());data.put("reward_receipt",operation.toString());data.put("stage",capture.stage());data.put("recipient_character_id",character.toString());data.put("target_id",capture.targetId().toString());data.put("target_type",capture.targetType());data.put("target_entity_type",capture.targetType().toUpperCase(java.util.Locale.ROOT));data.put("reward_class",capture.experienceClass());data.put("classifier_version","native_entity_type_v1");data.put("classifier_evidence",Map.of("native_entity_type",capture.targetType().toUpperCase(java.util.Locale.ROOT),"selected_experience_class",capture.experienceClass(),"capture_stage",capture.stage()));if("hard".equals(capture.experienceClass()))data.put("hard_mob_xp",capture.experiencePool().toString());data.put("experience_class",capture.experienceClass());data.put("coin_class",capture.coinClass());data.put("defeated_at",capture.defeatedAt().toString());data.put("direct_contributor_character_ids",capture.directContributors().stream().map(UUID::toString).sorted().toList());data.put("reward_unit_id",capture.rewardUnitId());data.put("reward_unit_count",capture.rewardUnitCount());data.put("experience_pool",capture.experiencePool().toString());data.put("coin_pool",capture.coinPool().toString());if(capture.killPosition()!=null)data.put("kill_location",position(capture.killPosition()));data.put("allocated_experience",allocatedExperience.toString());data.put("allocated_coins",allocatedCoins.toString());var appliedExperience=after.experience().subtract(before.experience()).max(BigInteger.ZERO);var appliedCoins=after.coins().subtract(before.coins()).max(BigInteger.ZERO);data.put("applied_experience",appliedExperience.toString());data.put("applied_coins",appliedCoins.toString());data.put("suppressed_experience",allocatedExperience.subtract(appliedExperience.min(allocatedExperience)).toString());data.put("suppressed_coins",allocatedCoins.subtract(appliedCoins.min(allocatedCoins)).toString());data.put("before_revision",before.revision().toString());data.put("after_revision",after.revision().toString());data.put("before_level",before.level());data.put("after_level",after.level());data.put("before_experience",before.experience().toString());data.put("after_experience",after.experience().toString());data.put("before_points",before.unspentPoints().toString());data.put("after_points",after.unspentPoints().toString());data.put("balance_before",before.coins().toString());data.put("balance_after",after.coins().toString());data.put("storage_status",award.status().name().toLowerCase(java.util.Locale.ROOT));data.put("levels_gained",award.levels());data.put("native_sync",nativeSync);data.put("feedback",feedback);if(capture.managedOrigin()!=null){data.put("resolution","managed_origin_exempt");data.put("managed_origin_kind",capture.managedOrigin().kind().name().toLowerCase(java.util.Locale.ROOT));data.put("managed_origin_source",capture.managedOrigin().source().toString());data.put("spacing_applicability","authenticated_managed_origin_exempt");if(capture.managedOrigin().kind()==ManagedEncounterOrigin.Kind.DUNGEON&&capture.managedOrigin().source().toString().equals(capture.managedEvidence().get("managed_source_id")))data.putAll(capture.managedEvidence());}else if(restricted!=null){boolean blocked=award.status()==CharacterStorage.CombatAwardStatus.SPACING_BLOCKED;data.put("resolution",blocked?"ordinary_spacing_blocked":"ordinary_spacing_eligible");data.put("spacing_applicability","ordinary_restricted_kill");data.put("spacing_minimum",rules.restrictedSpacing().toPlainString());var prior=before.restrictedKills().get(restricted.world());if(prior!=null)data.put("ordinary_history_before",position(prior));var current=after.restrictedKills().get(restricted.world());if(current!=null)data.put("ordinary_history_after",position(current));}else{data.put("resolution","spacing_not_applicable");data.put("spacing_applicability","passive_or_fantasy_character");}data.put("loss_reason",loss(award.status()));var context=context(active,player,after.name());data.put("location_capture_quality",context==null||context.location()==null?"unavailable":"exact");try{var event=new EventInput(id(operation),operation,clock.instant(),"combat.reward_resolved",context,Map.copyOf(data));if(outbox!=null)outbox.accepted(event);else sink.accept(event);}catch(RuntimeException ignored){}
}
private EventInput.Context context(EquipmentSessions.Active active,Player player,String characterName){
if(active==null||player==null)return null;
try{return new EventInput.Context(active.player(),player.getName(),active.character(),characterName,active.session(),location(player));}
catch(RuntimeException unavailable){return null;}
}
private EventInput.Location location(Player player){
try{
var value=player.getLocation();if(value==null||value.getWorld()==null)return null;
return new EventInput.Location(value.getWorld().getUID(),value.getWorld().getName(),value.getX(),value.getY(),value.getZ(),clock.instant(),"exact");
}catch(RuntimeException unavailable){return null;}
}
private EventInput.Context context(EquipmentSessions.Active active,Player player,String characterName){if(active==null||player==null)return null;var location=player.getLocation();if(location==null||location.getWorld()==null)return new EventInput.Context(active.player(),player.getName(),active.character(),characterName,active.session(),null);return new EventInput.Context(active.player(),player.getName(),active.character(),characterName,active.session(),new EventInput.Location(location.getWorld().getUID(),location.getWorld().getName(),location.getX(),location.getY(),location.getZ(),clock.instant(),"exact"));}
private static Map<String,Object> position(CombatRewardRules.KillPosition value){return Map.of("world_id",value.world().toString(),"x",value.x().toPlainString(),"y",value.y().toPlainString(),"z",value.z().toPlainString());}
private static String loss(CharacterStorage.CombatAwardStatus status){return switch(status){case SPACING_BLOCKED->"restricted_kill_spacing";case AT_CAP->"maximum_level";case UNCHANGED->"zero_experience_and_coins";case DUPLICATE->"duplicate_receipt";case UNAVAILABLE->"recipient_unavailable";case STALE->"stale_retry";case APPLIED->"none";};}
private static UUID id(UUID operation){return UUID.nameUUIDFromBytes(("fantasystick:combat-reward:"+operation).getBytes(StandardCharsets.UTF_8));}
@@ -0,0 +1,117 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.dungeon.DungeonState;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.command.CommandSender;
/** Independent authoring observations; neither delivery nor replay authorizes dungeon gameplay. */
public final class DungeonAuditEventBridge implements AutoCloseable {
private final FeatureEventOutbox outbox;
private final Function<CommandSender,CampEventBridge.Actor> capture;
private final Clock clock=Clock.systemUTC();
public DungeonAuditEventBridge(Function<EventInput,CompletionStage<LocalEventLog.Delivery>> sink,Path data,
Function<CommandSender,CampEventBridge.Actor> capture,Consumer<Throwable> failures){
this.capture=java.util.Objects.requireNonNull(capture);
outbox=new FeatureEventOutbox(data,"dungeon-audit-event-intents",Set.of("dungeon.created","dungeon.mode_changed","dungeon.removed","dungeon.edit.started","dungeon.edit.ended","dungeon.entrance_changed","dungeon.room.created","dungeon.room.removed","dungeon.mob_slot.added","dungeon.mob_slot.removed","dungeon.effect.added","dungeon.effect.removed","dungeon.boss.configured","dungeon.boss.cleared","dungeon.enabled","dungeon.disabled","dungeon.offer.opened","dungeon.offer.closed","adventure.quest.accepted","adventure.quest.abandoned","adventure.quest.expired","dungeon.run.started","dungeon.room.activated","dungeon.entity.spawned","dungeon.entity.defeated","dungeon.entity.removed","dungeon.room.cleared","dungeon.run.closed","effect.applied","effect.ended","config.changed"),sink,failures);
}
/** Calling-server-thread capture only; workers and replay never look up current senders. */
public CampEventBridge.Actor actor(CommandSender sender){try{var original=capture.apply(sender);if(original==null)return null;var facts=new LinkedHashMap<>(original.captured());try{var name=sender.getName();if(name!=null)facts.put("administrator_name",name);else facts.put("administrator_identity_quality","unavailable");}catch(RuntimeException unavailable){facts.put("administrator_identity_quality","unavailable");}return new CampEventBridge.Actor(original.context(),facts);}catch(RuntimeException unavailable){return null;}}
public void configChanged(CampEventBridge.Actor actor,UUID operation,String key,String before,String after,String defaultValue){if(!key.equals(games.dmg.fantasystick.persistence.AdventureQuestSettings.DUNGEON_ROOM_LINK_DISTANCE)||before.equals(after))return;accepted("config.changed",operation,actor,Map.of("feature","dungeon","key",key,"before",before,"after",after,"default",defaultValue,"application_scope","future_definitions_and_offers","active_work_status","unchanged"));}
public void created(UUID operation,CampEventBridge.Actor actor,DungeonState.Dungeon value){
var data=definition(value);data.put("after_revision",value.revision().toString());data.put("after_mode",value.participationMode().value());
data.put("after_enabled",value.enabled());data.put("before_definition_status","absent");data.put("reason","administrator_create");
accepted("dungeon.created",operation,actor,data);
}
public void modeChanged(UUID operation,CampEventBridge.Actor actor,DungeonState.Dungeon before,DungeonState.Dungeon after){
if(before.participationMode()==after.participationMode())return;
var data=definition(after);data.put("before_revision",before.revision().toString());data.put("after_revision",after.revision().toString());
data.put("before_mode",before.participationMode().value());data.put("after_mode",after.participationMode().value());data.put("application_scope","future_acceptances");
accepted("dungeon.mode_changed",operation,actor,data);
}
/** Project only original acknowledged definition changes; reward and character-boss owners stay independent. */
public void definitionSaved(UUID operation,CampEventBridge.Actor actor,games.dmg.fantasystick.dungeon.DungeonDirectory.Publication publication,String reason){
for(var before:publication.before().dungeons()){
var after=publication.after().dungeons().stream().filter(value->value.id().equals(before.id())).findFirst().orElse(null);
if(after==null){change("dungeon.removed",operation,actor,before,null,reason,Map.of("cleanup_status","acknowledged_transition"));continue;}
if(before.equals(after))continue;
if(!before.entrance().equals(after.entrance()))change("dungeon.entrance_changed",operation,actor,before,after,reason,Map.of("before_entrance",position(before.world(),before.entrance()),"after_entrance",position(after.world(),after.entrance())));
if(before.enabled()!=after.enabled())change(after.enabled()?"dungeon.enabled":"dungeon.disabled",operation,actor,before,after,reason,Map.of("before_enabled",before.enabled(),"after_enabled",after.enabled(),"cleanup_status",reason.equals("administrator_disable")?"acknowledged_transition":"definition_save_only"));
for(var room:before.rooms())if(after.rooms().stream().noneMatch(value->value.id().equals(room.id())))change("dungeon.room.removed",operation,actor,before,after,reason,Map.of("room_id",room.id().toString(),"before_bounds",bounds(room.bounds()),"after_room_status","absent"));
for(var room:after.rooms()){
var prior=before.rooms().stream().filter(value->value.id().equals(room.id())).findFirst().orElse(null);
if(prior==null){change("dungeon.room.created",operation,actor,before,after,reason,Map.of("room_id",room.id().toString(),"after_name",room.displayName(),"after_bounds",bounds(room.bounds()),"before_room_status","absent"));continue;}
for(var slot:room.mobs())if(prior.mobs().stream().noneMatch(value->value.id().equals(slot.id())))change("dungeon.mob_slot.added",operation,actor,before,after,reason,slotData(after.world(),room.id(),slot,"after"));
for(var slot:prior.mobs())if(room.mobs().stream().noneMatch(value->value.id().equals(slot.id())))change("dungeon.mob_slot.removed",operation,actor,before,after,reason,slotData(before.world(),room.id(),slot,"before"));
for(var effect:room.effects())if(prior.effects().stream().noneMatch(value->value.id().equals(effect.id())))change("dungeon.effect.added",operation,actor,before,after,reason,effectData(room.id(),effect,"after"));
for(var effect:prior.effects())if(room.effects().stream().noneMatch(value->value.id().equals(effect.id())))change("dungeon.effect.removed",operation,actor,before,after,reason,effectData(room.id(),effect,"before"));
if(!java.util.Objects.equals(prior.boss(),room.boss())&&(prior.boss()==null||prior.boss().character()==null)&&(room.boss()==null||room.boss().character()==null)){
var data=new LinkedHashMap<String,Object>();data.put("room_id",room.id().toString());data.put("boss_identity_quality","room_and_definition_revision");data.put("before_boss_status",prior.boss()==null?"absent":"configured");data.put("after_boss_status",room.boss()==null?"absent":"configured");
if(prior.boss()!=null)bossData(data,before.world(),prior.boss(),"before");if(room.boss()!=null)bossData(data,after.world(),room.boss(),"after");
change(room.boss()==null?"dungeon.boss.cleared":"dungeon.boss.configured",operation,actor,before,after,reason,data);
}
}
}
}
public void lease(String type,UUID operation,CampEventBridge.Actor actor,DungeonState.Dungeon dungeon,String reason){var data=definition(dungeon);data.put("definition_capture_quality","acknowledged_lease_view");data.put("lease_transition",type.endsWith("started")?"acquired":"released");data.put("reason",reason);data.put("cleanup_status",type.endsWith("started")?"acknowledged_transition":"transient_lease_only");accepted(type,operation,actor,data);}
public void offer(String type,UUID operation,CampEventBridge.Actor actor,DungeonState.Dungeon dungeon,games.dmg.fantasystick.adventure.AdventureQuestState.Catalog catalog,String reason){var data=definition(dungeon);data.put("catalog_id",catalog.revision().toString());data.put("generation_id",catalog.revision().toString());data.put("expires_at",catalog.expiresAt().toString());data.put("reason",reason);data.put("cleanup_status",type.endsWith("closed")?"acknowledged_transition":"not_applicable");accepted(type,operation,actor,data);}
/** Immutable original facts only. Run identity is the existing dungeon/generation composite, not new authority. */
public record RuntimeFacts(UUID operation,CampEventBridge.Actor actor,Map<String,Object> data){public RuntimeFacts{data=Map.copyOf(data);}}
public RuntimeFacts runtime(UUID operation,CampEventBridge.Actor actor,DungeonState.Dungeon dungeon,DungeonState.Room room,UUID generation,Map<String,Object> details){
var data=definition(dungeon);data.put("catalog_id",generation.toString());data.put("generation_id",generation.toString());
data.put("run_id",UUID.nameUUIDFromBytes(("dungeon-run:"+dungeon.id()+":"+generation).getBytes(StandardCharsets.UTF_8)).toString());data.put("run_identity_quality","persisted_dungeon_generation_composite");
if(room!=null){data.put("room_id",room.id().toString());data.put("room_name",room.displayName());data.put("room_bounds",bounds(room.bounds()));}data.putAll(details);return new RuntimeFacts(operation,actor,data);
}
public void observed(String type,RuntimeFacts facts,Map<String,Object> details){var data=new LinkedHashMap<>(facts.data());data.putAll(details);accepted(type,facts.operation(),facts.actor(),data);}
public void questTransition(String type,UUID operation,CampEventBridge.Actor actor,games.dmg.fantasystick.adventure.AdventureQuestState.Quest quest,games.dmg.fantasystick.adventure.AdventureQuestState.Site site,DungeonState.Dungeon definition,String reason){
if(site==null||site.type()!=games.dmg.fantasystick.adventure.AdventureQuestState.SiteType.DUNGEON)return;
questObserved(type,operation,actor,quest,site,definition,reason);
}
private void questObserved(String type,UUID operation,CampEventBridge.Actor actor,games.dmg.fantasystick.adventure.AdventureQuestState.Quest quest,games.dmg.fantasystick.adventure.AdventureQuestState.Site site,DungeonState.Dungeon definition,String reason){
var data=definition==null?new LinkedHashMap<String,Object>():definition(definition);data.put("dungeon_id",quest.site().toString());data.put("site_capture_quality",site==null?"unavailable_legacy_source_identity_only":"original_acknowledged_site");if(definition==null){if(site!=null)data.put("dungeon_name",site.displayName());data.put("definition_revision_status","unavailable_not_in_captured_site");}data.put("definition_capture_quality",definition==null?"unavailable":"original_request_snapshot");
data.put("quest_id",quest.id().toString());data.put("catalog_id",quest.catalog().toString());data.put("generation_id",quest.catalog().toString());data.put("unit_id",quest.unit().toString());data.put("unit_kind",quest.unitType().name().toLowerCase(java.util.Locale.ROOT));data.put("accepted_mode",quest.participationMode().value());data.put("accepted_roster",quest.snapshot().stream().sorted().map(UUID::toString).toList());data.put("expires_at",quest.expiresAt().toString());data.put("reason",reason);
var qualified=UUID.nameUUIDFromBytes((operation+":"+quest.id()).getBytes(StandardCharsets.UTF_8));accepted(type,qualified,actor,data);
}
public void questExpired(games.dmg.fantasystick.adventure.AdventureQuestDirectory.QuestExpiry expired){questObserved("adventure.quest.expired",expired.operation(),expired.actor(),expired.quest(),expired.site(),null,expired.reason());}
/** Removed durable source runs, not native absence or a second completion. No current Bukkit/definition lookup. */
public void runsRemoved(games.dmg.fantasystick.dungeon.DungeonDirectory.RunRemoval removal){
for(var run:removal.publication().before().runs()){
if(run.defeated()||removal.publication().after().runs().stream().anyMatch(value->value.dungeon().equals(run.dungeon())&&value.generation().equals(run.generation())))continue;
var definition=removal.publication().before().dungeons().stream().filter(value->value.id().equals(run.dungeon())).findFirst().orElse(null);if(definition==null)continue;
var operation=UUID.nameUUIDFromBytes((removal.operation()+":"+run.dungeon()+":"+run.generation()).getBytes(StandardCharsets.UTF_8));
var facts=runtime(operation,null,definition,null,run.generation(),Map.of("source_operation_id",removal.operation().toString(),"causation_capture_quality","acknowledged_source_transition_original_request_actor_unavailable"));
observed("dungeon.run.closed",facts,Map.of("reason",removal.reason(),"run_defeated",false,"reward_status","non_reward_source_cleanup","defeated_slot_ids",run.defeatedSlots().stream().sorted().map(UUID::toString).toList(),"cleared_room_ids",run.clearedRooms().stream().sorted().map(UUID::toString).toList()));
}
}
private void change(String type,UUID operation,CampEventBridge.Actor actor,DungeonState.Dungeon before,DungeonState.Dungeon after,String reason,Map<String,Object> details){var data=definition(after==null?before:after);data.put("before_revision",before.revision().toString());if(after!=null)data.put("after_revision",after.revision().toString());else data.put("after_definition_status","absent");data.put("reason",reason);data.putAll(details);accepted(type,operation,actor,data);}
private static Map<String,Object> position(UUID world,DungeonState.Position at){return Map.of("world_id",world.toString(),"x",at.x(),"y",at.y(),"z",at.z(),"yaw",(double)at.yaw(),"pitch",(double)at.pitch());}
private static Map<String,Object> bounds(DungeonState.Bounds value){return Map.of("min_x",value.minX(),"min_y",value.minY(),"min_z",value.minZ(),"max_x",value.maxX(),"max_y",value.maxY(),"max_z",value.maxZ());}
private static Map<String,Object> slotData(UUID world,UUID room,DungeonState.Spawn slot,String prefix){var data=new LinkedHashMap<String,Object>();data.put("room_id",room.toString());data.put("spawn_slot_id",slot.id().toString());data.put(prefix+"_entity_type",slot.type().name());data.put(prefix+"_name",slot.name());data.put(prefix+"_spawn_location",position(world,slot.position()));data.put(prefix+"_quantity","1");return data;}
private static Map<String,Object> effectData(UUID room,DungeonState.Effect effect,String prefix){return Map.of("room_id",room.toString(),"effect_id",effect.id().toString(),prefix+"_effect",effect.effect(),prefix+"_target",effect.target().name().toLowerCase(java.util.Locale.ROOT),prefix+"_level",Integer.toString(effect.level()),prefix+"_particles",effect.particles());}
private static void bossData(Map<String,Object> data,UUID world,DungeonState.Boss boss,String prefix){data.put(prefix+"_entity_type",boss.type().name());data.put(prefix+"_name",boss.name());data.put(prefix+"_spawn_location",position(world,boss.position()));}
private static LinkedHashMap<String,Object> definition(DungeonState.Dungeon value){
var data=new LinkedHashMap<String,Object>();data.put("dungeon_id",value.id().toString());data.put("dungeon_name",value.name());
data.put("definition_revision",value.revision().toString());data.put("world_id",value.world().toString());
var at=value.entrance();data.put("entrance",Map.of("world_id",value.world().toString(),"x",at.x(),"y",at.y(),"z",at.z(),"yaw",(double)at.yaw(),"pitch",(double)at.pitch()));return data;
}
private void accepted(String type,UUID operation,CampEventBridge.Actor actor,Map<String,Object> input){
var data=new LinkedHashMap<>(input);data.put("operation_id",operation.toString());data.putIfAbsent("outcome","acknowledged");
if(actor!=null){data.putAll(actor.captured());if(actor.context()==null)data.put("location_status",data.get("actor_kind").equals("player")?"unavailable":"not_applicable");}
else{data.put("actor_kind","unavailable");data.put("location_status","unavailable");}
// Match the unchanged writer envelope before journaling, retaining original-actor provenance separately.
if(actor==null||actor.context()==null){var original=data.get("actor_kind");if(!"console".equals(original)&&!"command_block".equals(original)){data.put("original_actor_kind",java.util.Objects.toString(original,"unavailable"));data.put("actor_kind","system");}}
var id=UUID.nameUUIDFromBytes(("fantasystick:dungeon-audit:"+operation+":"+type).getBytes(StandardCharsets.UTF_8));
outbox.accepted(new EventInput(id,operation,clock.instant(),type,actor==null?null:actor.context(),data));
}
public void replay(){outbox.replay();}
public CompletionStage<Void> drained(){return outbox.drained();}
@Override public void close(){outbox.close();}
}
@@ -0,0 +1,125 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.dungeon.DungeonDirectory;
import games.dmg.fantasystick.dungeon.DungeonState;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
/** Dungeon-specific evidence passed to the shared recoverable feature-event outbox. */
public final class DungeonEventBridge {
private final FishingEventBridge outbox;
public DungeonEventBridge(FishingEventBridge outbox) { this.outbox = java.util.Objects.requireNonNull(outbox); }
public FishingEventBridge.Actor actor(org.bukkit.command.CommandSender sender) { return outbox.actor(sender); }
public void rewardChanged(UUID operation, FishingEventBridge.Actor actor,
DungeonDirectory.CompletionRewardChange change) {
var before = change.before();
var after = change.after();
var data = new LinkedHashMap<String, Object>();
data.put("actor_kind", actor == null ? "system" : actor.kind());
if (actor == null) data.put("actor_identity_status", "unavailable");
data.put("dungeon_id", after.id().toString());
data.put("dungeon_name", after.name());
data.put("before_revision", before.revision().toString());
data.put("after_revision", after.revision().toString());
data.put("before_configured", before.rewardConfigured());
data.put("before_xp", before.completionReward().xp().toString());
data.put("before_coins", before.completionReward().coins().toString());
data.put("after_xp", after.completionReward().xp().toString());
data.put("after_coins", after.completionReward().coins().toString());
data.put("application_scope", "future_acceptances");
outbox.durable("dungeon.reward_changed", operation,
actor == null ? new FishingEventBridge.Actor("system", null) : actor, data);
}
/** Only an authoritative defeated run carrying captured native boss proof can attest closure. */
public void completed(DungeonState.Run run) {
if (!run.defeated() || run.completionPublished() || run.completionAudit().isEmpty()) return; // Older runs have no defensible proof.
var audit = run.completionAudit();
var operation = completionOperation(run);
var data = new LinkedHashMap<String, Object>();
data.put("dungeon_id", run.dungeon().toString());
data.put("dungeon_name", required(audit, "dungeon_name"));
data.put("room_id", UUID.fromString(required(audit, "room_id")).toString());
data.put("generation_id", run.generation().toString());
data.put("boss_id", UUID.fromString(required(audit, "boss_id")).toString());
data.put("completed_at", required(audit, "defeated_at"));
if (audit.containsKey("defeat_world_id")) {
var location = new LinkedHashMap<String, Object>();
location.put("world_id", UUID.fromString(required(audit, "defeat_world_id")).toString());
location.put("world_name", required(audit, "defeat_world_name"));
for (var coordinate : java.util.List.of("x", "y", "z"))
location.put(coordinate, Double.parseDouble(required(audit, "defeat_" + coordinate)));
data.put("death_location", location);
} else data.put("death_location", Map.of("status", "unavailable"));
int count = Integer.parseInt(required(audit, "represented_count"));
if (count < 0 || count > 10_000) throw new IllegalArgumentException("Invalid represented unit count");
var represented = new java.util.ArrayList<Map<String, Object>>(count);
var receipts = new java.util.ArrayList<String>(count);
for (int index = 0; index < count; index++) {
var prefix = "represented_" + index + "_";
var unit = new LinkedHashMap<String, Object>();
unit.put("quest_id", UUID.fromString(required(audit, prefix + "quest_id")).toString());
unit.put("unit_kind", required(audit, prefix + "unit_kind"));
unit.put("unit_id", UUID.fromString(required(audit, prefix + "unit_id")).toString());
var roster = required(audit, prefix + "accepted_roster");
unit.put("accepted_roster", roster.isEmpty() ? java.util.List.of() :
java.util.Arrays.stream(roster.split(",")).map(UUID::fromString).map(UUID::toString).toList());
var receipt = UUID.fromString(required(audit, prefix + "receipt_id")).toString();
unit.put("receipt_id", receipt);
receipts.add(receipt);
represented.add(unit);
}
data.put("represented_units", represented);
data.put("receipt_ids", receipts);
data.put("cleanup_status", "completed");
outbox.durable("dungeon.completed", operation,
new FishingEventBridge.Actor("system", null), data);
}
public static UUID completionOperation(DungeonState.Run run) {
return UUID.nameUUIDFromBytes(("dungeon:completed:" + run.dungeon() + ":" + run.generation())
.getBytes(java.nio.charset.StandardCharsets.UTF_8));
}
public static UUID completionEventId(DungeonState.Run run) {
return UUID.nameUUIDFromBytes(("fantasystick:fishing:" + completionOperation(run)
+ ":dungeon.completed").getBytes(java.nio.charset.StandardCharsets.UTF_8));
}
/** Read only exported UTC-day segments, never an uncommitted pending intent. Call off the tick thread. */
public static boolean exported(java.nio.file.Path data, UUID eventId, UUID dungeon,
UUID generation, UUID boss) throws java.io.IOException {
var events = data.toAbsolutePath().resolve("events");
if (java.nio.file.Files.notExists(events)) return false;
try (var files = java.nio.file.Files.walk(events)) {
for (var path : files.filter(file -> java.nio.file.Files.isRegularFile(file,
java.nio.file.LinkOption.NOFOLLOW_LINKS)
&& file.getFileName().toString().endsWith(".jsonl")).sorted().toList()) {
for (var line : java.nio.file.Files.readAllLines(path,java.nio.charset.StandardCharsets.UTF_8)) {
if (line.isBlank()) continue;
final com.google.gson.JsonObject record;
try { record = com.google.gson.JsonParser.parseString(line).getAsJsonObject(); }
catch (RuntimeException corrupt) { throw new java.io.IOException("Invalid exported event segment",corrupt); }
if (!eventId.toString().equals(record.get("id").getAsString())) continue;
var details = record.getAsJsonObject("data");
if (record.get("type").getAsString().endsWith("dungeon.completed")
&& dungeon.toString().equals(details.get("dungeon_id").getAsString())
&& generation.toString().equals(details.get("generation_id").getAsString())
&& boss.toString().equals(details.get("boss_id").getAsString())) return true;
throw new java.io.IOException("Conflicting exported dungeon completion identity");
}
}
} catch (RuntimeException invalid) { throw new java.io.IOException("Invalid exported dungeon completion proof",invalid); }
return false;
}
private static String required(Map<String, String> audit, String key) {
var value = audit.get(key);
if (value == null) throw new IllegalArgumentException("Missing committed dungeon completion " + key);
return value;
}
}
@@ -33,6 +33,7 @@ final class FeatureEventOutbox implements AutoCloseable {
private final ExecutorService worker = Executors.newSingleThreadExecutor(task ->
new Thread(task, "fantasy-feature-event-intents"));
private final CompletableFuture<Void> drained = new CompletableFuture<>();
private final Set<UUID> inFlight = java.util.concurrent.ConcurrentHashMap.newKeySet();
FeatureEventOutbox(Path data, String folder, Set<String> types,
Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
@@ -239,12 +240,18 @@ final class FeatureEventOutbox implements AutoCloseable {
}
private void publish(EventInput event) {
if (!inFlight.add(event.id())) return;
try { sink.apply(event).whenComplete((delivery, failure) -> {
if (failure != null) { report(failure); return; }
if (delivery == LocalEventLog.Delivery.REJECTED) return;
try { if (Files.deleteIfExists(path(event))) forceDirectory(); }
catch (IOException error) { report(error); }
}); } catch (RuntimeException failure) { report(failure); }
try {
if (failure != null) { report(failure); return; }
if (delivery == LocalEventLog.Delivery.REJECTED) return;
try { if (Files.deleteIfExists(path(event))) forceDirectory(); }
catch (IOException error) { report(error); }
} finally { inFlight.remove(event.id()); }
}); } catch (RuntimeException failure) {
inFlight.remove(event.id());
report(failure);
}
}
private void forceDirectory() throws IOException {
@@ -26,7 +26,8 @@ public final class FishingEventBridge implements AutoCloseable {
private static final Set<String> DURABLE = Set.of("adventure.site.registered", "adventure.site.updated", "adventure.site.mode_changed",
"adventure.quest.accepted", "config.changed", "fishing.catch_resolved",
"adventure.items_submitted", "adventure.reward_claimed",
"adventure.trophy_earned", "adventure.trophy_redeemed", "combat.reward_resolved");
"adventure.trophy_earned", "adventure.trophy_redeemed", "combat.reward_resolved",
"dungeon.reward_changed", "dungeon.completed");
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final Clock clock;
private final EquipmentSessions sessions;
@@ -130,7 +131,8 @@ public final class FishingEventBridge implements AutoCloseable {
/** Reconstruct a combat-trophy outcome solely from the saved receipt, including after a prepared retry. */
public void trophyEarned(FishingState.Trophy receipt) {
if ((receipt.type() != FishingState.TrophyType.CHAMPION
&& receipt.type() != FishingState.TrophyType.CAMP)
&& receipt.type() != FishingState.TrophyType.CAMP
&& receipt.type() != FishingState.TrophyType.DUNGEON)
|| receipt.phase() != FishingState.TrophyPhase.EARNED) return;
var audit = receipt.audit();
var evidence = new LinkedHashMap<String, Object>();
@@ -148,11 +150,16 @@ public final class FishingEventBridge implements AutoCloseable {
evidence.put("champion_id", audit.getOrDefault("champion_id", "unavailable_legacy_capture"));
evidence.put("defeat_location", savedDefeatLocation(audit));
evidence.put("defeated_at", audit.getOrDefault("defeated_at", "unavailable_legacy_capture"));
} else {
} else if (receipt.type() == FishingState.TrophyType.CAMP) {
evidence.put("final_enemy_id", audit.getOrDefault("final_enemy_id", "unavailable_legacy_capture"));
evidence.put("clear_location", savedDefeatLocation(audit));
evidence.put("cleared_at", audit.getOrDefault("defeated_at", "unavailable_legacy_capture"));
evidence.put("arena", savedArena(audit));
} else {
evidence.put("boss_id", audit.getOrDefault("boss_id", "unavailable_legacy_capture"));
evidence.put("room_id", audit.getOrDefault("room_id", "unavailable_legacy_capture"));
evidence.put("boss_death_location", savedDefeatLocation(audit));
evidence.put("completed_at", audit.getOrDefault("defeated_at", "unavailable_legacy_capture"));
}
evidence.put("unit_kind", audit.getOrDefault("unit_kind", "unavailable_legacy_capture"));
evidence.put("unit_id", audit.getOrDefault("unit_id", "unavailable_legacy_capture"));
@@ -0,0 +1,74 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.HardMobXpSettings;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.file.Path;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
/** Captured Witch XP policy history; replay never configures combat rewards. */
public final class HardMobXpEventBridge implements AutoCloseable {
public record Actor(String kind, UUID playerId, String playerName, EventInput.Context context) {}
private final FeatureEventOutbox outbox;
private final Clock clock = Clock.systemUTC();
public HardMobXpEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Path data, Consumer<Throwable> failures) {
outbox = new FeatureEventOutbox(data, "hard-mob-xp-event-intents", Set.of("config.read", "config.changed"), sink, failures);
}
public Actor actor(CommandSender sender, EquipmentSessions sessions) {
String kind = sender instanceof Player ? "player" : sender instanceof org.bukkit.command.ConsoleCommandSender
? "console" : sender instanceof org.bukkit.command.BlockCommandSender ? "command_block" : "system";
if (!(sender instanceof Player player)) return new Actor(kind, null, null, null);
UUID id = null; String name = null; EventInput.Context context = null;
try { id = player.getUniqueId(); } catch (RuntimeException ignored) { }
try { name = player.getName(); } catch (RuntimeException ignored) { }
try {
var active = id == null ? null : sessions.active(id).filter(sessions::current).orElse(null);
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
var at = player.getLocation();
var location = at == null || at.getWorld() == null ? null : new EventInput.Location(
at.getWorld().getUID(), at.getWorld().getName(), at.getX(), at.getY(), at.getZ(), clock.instant(), "native");
context = new EventInput.Context(id, name, record == null ? null : record.id(),
record == null ? null : record.name(), active == null ? null : active.session(), location);
} catch (RuntimeException ignored) { }
return new Actor(kind, id, name, context);
}
public void read(Actor actor, String value) {
record("config.read", actor, UUID.randomUUID(), null, value);
}
public void changed(Actor actor, UUID operation, String before, String value) {
record("config.changed", actor, operation, before, value);
}
private void record(String type, Actor actor, UUID operation, String before, String value) {
try {
var data = new LinkedHashMap<String, Object>();
data.put("actor_kind", actor == null ? "unavailable" : actor.kind());
data.put("request_actor_kind", actor == null ? "unavailable" : actor.kind());
data.put("actor_capture_quality", actor == null || "player".equals(actor.kind()) && actor.context() == null ? "unavailable" : "native");
if (actor != null && actor.playerId() != null) data.put("actor_player_id", actor.playerId().toString());
if (actor != null && actor.playerName() != null) data.put("actor_player_name", actor.playerName());
data.put("key", HardMobXpSettings.KEY); data.put("after", value); data.put("effective", value);
if (before != null) data.put("before", before);
data.put("application_scope", "future_uncaptured_defeats"); data.put("outcome", "acknowledged");
outbox.accepted(new EventInput(operation, operation, clock.instant(), type,
actor == null ? null : actor.context(), Map.copyOf(data)));
} catch (RuntimeException ignored) { /* Policy publication and reads remain independent of auditing. */ }
}
public void replay() { outbox.replay(); }
public CompletionStage<Void> drained() { return outbox.drained(); }
@Override public void close() { outbox.close(); }
}
@@ -0,0 +1,141 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.Location;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.EntityDamageEvent;
/** Audit-only final-blow snapshots; replaying a heal intent never invokes gameplay. */
public final class HealthStealEventBridge implements AutoCloseable {
public record Damage(UUID id, Instant time, UUID target, String targetType,
UUID directEntity, String cause, String amount, EventInput.Location location) { }
public record Action(UUID id, Damage damage, EventInput.Context context, EventInput.Location targetLocation,
UUID targetCharacter, UUID targetSession, BigInteger configured, BigDecimal requested,
CharacterRecord before, BigDecimal healthBefore, BigDecimal maximum) { }
private final Consumer<EventInput> sink;
private final Clock clock;
private final FeatureEventOutbox outbox;
public HealthStealEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink = Objects.requireNonNull(sink); this.clock = Objects.requireNonNull(clock); outbox = null;
}
public HealthStealEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock, Path data, Consumer<Throwable> failures) {
this.sink = event -> sink.apply(event); this.clock = Objects.requireNonNull(clock);
outbox = new FeatureEventOutbox(data, "health-steal-event-intents", java.util.Set.of("combat.healed"), sink, failures);
}
/** Called only for an already accepted lethal candidate, before its native death callback. */
public Damage damage(EntityDamageEvent event) {
try {
var target = event.getEntity(); var direct = event.getDamageSource().getDirectEntity();
if (direct == null && event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity)
direct = byEntity.getDamager();
return new Damage(UUID.randomUUID(), clock.instant(), target.getUniqueId(),
target instanceof Player ? "PLAYER" : target.getType().name(), direct == null ? null : direct.getUniqueId(),
event.getCause().name(), Double.toString(event.getFinalDamage()), location(target.getLocation()));
} catch (RuntimeException unavailable) { return null; }
}
public Action begin(Damage damage, LivingEntity target, Player player, EquipmentSessions.Active active,
CharacterRecord before, EquipmentSessions.Active targetActive,
BigInteger configured, double requested, double current, double maximum) {
if (damage == null) return null;
try {
var context = new EventInput.Context(player.getUniqueId(), player.getName(), active.character(),
before.name(), active.session(), location(player.getLocation()));
var action = new Action(id(damage.id(), "defeat"), damage, context, location(target.getLocation()),
targetActive == null ? null : targetActive.character(), targetActive == null ? null : targetActive.session(),
configured, BigDecimal.valueOf(requested), before, BigDecimal.valueOf(current), BigDecimal.valueOf(maximum));
emit("action.observed", action, "observed", null, before, null, action.healthBefore(), null, action.maximum(), null);
return action;
} catch (RuntimeException unavailable) { return null; }
}
public void refused(Action action, String reason) {
if (action != null) emit("action.resolved", action, "refused", reason, action.before(), null,
action.healthBefore(), null, action.maximum(), null);
}
public void failed(Action action, String reason) {
if (action != null) emit("action.resolved", action, "failed", reason, action.before(), null,
action.healthBefore(), null, action.maximum(), null);
}
public void healed(Action action, CharacterRecord before, CharacterRecord after,
double nativeBefore, double nativeAfter, double maximum, double accepted) {
if (action == null) return;
var prior = BigDecimal.valueOf(nativeBefore); var next = BigDecimal.valueOf(nativeAfter);
var limit = BigDecimal.valueOf(maximum); var amount = BigDecimal.valueOf(accepted);
emit("action.resolved", action, "accepted", "native_health_acknowledged", before, after, prior, next, limit, amount);
emit("combat.healed", action, "accepted", "native_health_acknowledged", before, after, prior, next, limit, amount);
}
private void emit(String type, Action action, String outcome, String reason, CharacterRecord before,
CharacterRecord after, BigDecimal prior, BigDecimal next, BigDecimal maximum, BigDecimal accepted) {
try {
var data = new LinkedHashMap<String, Object>(); var damage = action.damage();
data.put("action_kind", "health_steal_final_blow"); data.put("passive_id", "health_steal");
data.put("healing_source", "health_steal"); data.put("defeat_id", action.id().toString());
data.put("damage_causation_id", damage.id().toString()); data.put("damage_accepted_at", damage.time().toString());
data.put("damage_cause", damage.cause()); data.put("damage_amount", damage.amount());
data.put("target_id", damage.target().toString()); data.put("target_type", damage.targetType());
if (damage.directEntity() != null) data.put("direct_entity_id", damage.directEntity().toString());
if (damage.location() != null) data.put("damage_target_location", values(damage.location()));
if (action.targetLocation() != null) data.put("target_location", values(action.targetLocation()));
data.put("target_location_capture_quality", action.targetLocation() == null ? "unavailable" : "exact");
data.put("location_capture_quality", action.context().location() == null ? "unavailable" : "exact");
if (action.targetCharacter() != null) {
data.put("target_character_id", action.targetCharacter().toString());
data.put("target_session_id", action.targetSession().toString());
}
data.put("defeat_consumption", "consumed_once"); data.put("configured_amount", action.configured().toString());
data.put("requested_amount", action.requested().toPlainString()); data.put("health_before", prior.toPlainString());
data.put("maximum_health", maximum.toPlainString()); data.put("before_revision", before.revision().toString());
data.put("points_before", before.unspentPoints().toString()); data.put("outcome", outcome);
if (reason != null) data.put("reason", reason);
if (after != null && next != null) {
data.put("health_after", next.toPlainString()); data.put("final_amount", next.subtract(prior).toPlainString());
data.put("native_accepted_amount", accepted.toPlainString()); data.put("persisted_health", after.health().toPlainString());
data.put("after_revision", after.revision().toString()); data.put("points_after", after.unspentPoints().toString());
data.put("native_publication", "acknowledged");
}
var event = new EventInput(id(action.id(), type), action.id(), clock.instant(), type, action.context(), Map.copyOf(data));
if (type.equals("combat.healed") && outbox != null) outbox.accepted(event); else sink.accept(event);
} catch (RuntimeException ignored) { /* Audit failure cannot authorize another defeat, save or native heal. */ }
}
private EventInput.Location location(Location value) {
try {
if (value == null || value.getWorld() == null) return null;
return new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(), value.getX(), value.getY(),
value.getZ(), clock.instant(), "exact");
} catch (RuntimeException unavailable) { return null; }
}
private static Map<String, Object> values(EventInput.Location location) {
return Map.of("world_id", location.worldId().toString(), "world_name", location.worldName(),
"x", location.x(), "y", location.y(), "z", location.z(), "captured_at", location.capturedAt().toString(),
"quality", location.quality());
}
private static UUID id(UUID operation, String type) {
return UUID.nameUUIDFromBytes(("fantasystick:health-steal:" + operation + ":" + type).getBytes(StandardCharsets.UTF_8));
}
public void replay() { if (outbox != null) outbox.replay(); }
public CompletionStage<Void> drained() {
return outbox == null ? java.util.concurrent.CompletableFuture.completedFuture(null) : outbox.drained();
}
@Override public void close() { if (outbox != null) outbox.close(); }
}
@@ -17,21 +17,37 @@ import org.bukkit.Location;
/** Feature-owned acknowledged passive mutations and Quicker Regeneration configuration records. */
public final class PassiveEventBridge {
public record Action(UUID actionId,EventInput.Context context,String passive,boolean learn,
PassiveDefinition definition,CharacterRecord before){
public Action{Objects.requireNonNull(actionId);Objects.requireNonNull(context);Objects.requireNonNull(passive);Objects.requireNonNull(definition);Objects.requireNonNull(before);}
PassiveDefinition definition,CharacterRecord before,Map<String,Object> actorMetadata){
public Action{Objects.requireNonNull(actionId);Objects.requireNonNull(passive);Objects.requireNonNull(definition);Objects.requireNonNull(before);actorMetadata=Map.copyOf(actorMetadata);if(context==null&&!games.dmg.fantasystick.passive.StickKnockback.ID.equals(passive))throw new NullPointerException("Passive actor context");}
public Action(UUID actionId,EventInput.Context context,String passive,boolean learn,PassiveDefinition definition,CharacterRecord before){this(actionId,context,passive,learn,definition,before,Map.of());}
}
private final Consumer<EventInput> sink;private final Clock clock;
public PassiveEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
public Action begin(ControlInput input,CharacterRecord before,PassiveDefinition definition,boolean learn){var action=new Action(UUID.randomUUID(),context(input.player(),input.session(),before.name()),definition.id(),learn,definition,before);emit("action.observed",action,"observed",null,null);return action;}
public void refused(org.bukkit.entity.Player player,EquipmentSessions.Active active,CharacterRecord before,PassiveDefinition definition,boolean learn,String reason){var action=new Action(UUID.randomUUID(),context(player,active,before.name()),definition.id(),learn,definition,before);emit("action.observed",action,"observed",null,null);emit("action.resolved",action,"refused",reason,null);}
public Action begin(ControlInput input,CharacterRecord before,PassiveDefinition definition,boolean learn){var action=captureAction(input.player(),input.session(),before,definition,learn);emit("action.observed",action,"observed",null,null);return action;}
public void refused(org.bukkit.entity.Player player,EquipmentSessions.Active active,CharacterRecord before,PassiveDefinition definition,boolean learn,String reason){var action=captureAction(player,active,before,definition,learn);emit("action.observed",action,"observed",null,null);emit("action.resolved",action,"refused",reason,null);}
public void failed(Action action,String reason){emit("action.resolved",action,"failed",reason,null);}
public void resolved(Action action,PassiveEngine.Mutation mutation){if(mutation!=null&&mutation.status()==games.dmg.fantasystick.persistence.CharacterStorage.PassiveMutationStatus.APPLIED&&mutation.record()!=null){emit("action.resolved",action,"accepted","acknowledged",mutation.record());emit(action.learn()?"passive.learned":"passive.unlearned",action,"accepted","acknowledged",mutation.record());}else emit("action.resolved",action,"refused",mutation==null?"unavailable":mutation.status().name().toLowerCase(java.util.Locale.ROOT),mutation==null?null:mutation.record());}
public void configRead(String actorKind,UUID player,String playerName,String key,String value){config("config.read",actorKind,player,playerName,key,null,value);}
public void configChanged(String actorKind,UUID player,String playerName,String key,String before,String after){config("config.changed",actorKind,player,playerName,key,before,after);}
private void emit(String type,Action action,String outcome,String reason,CharacterRecord after){var before=action.before();var data=new LinkedHashMap<String,Object>();data.put("action_kind",action.learn()?"passive_learn":"passive_unlearn");data.put("passive_id",action.passive());data.put("expected_revision",before.revision().toString());data.put("before_revision",before.revision().toString());data.put("points_before",before.unspentPoints().toString());data.put("point_cost",action.definition().pointCost().toString());var requirements=new LinkedHashMap<String,Object>();action.definition().requirements().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry->requirements.put(entry.getKey().name(),entry.getValue().toString()));data.put("stat_prerequisites",requirements);data.put("passive_prerequisites",action.definition().prerequisites().stream().sorted().toList());data.put("outcome",outcome);if(after!=null){data.put("after_revision",after.revision().toString());data.put("points_after",after.unspentPoints().toString());int slot=(action.learn()?after.passives():before.passives()).indexOf(action.passive());data.put("passive_slot",slot);data.put("slot_index_base",0);data.put("native_publication","acknowledged");data.put("owned_cleanup",action.learn()?"passive_state_acknowledged":"passive_state_acknowledged");}if(reason!=null)data.put(type.startsWith("passive.")?"native_boundary":"reason",reason);try{sink.accept(new EventInput(id(action.actionId(),type),action.actionId(),clock.instant(),type,action.context(),Map.copyOf(data)));}catch(RuntimeException ignored){}
private void emit(String type,Action action,String outcome,String reason,CharacterRecord after){var before=action.before();var data=new LinkedHashMap<String,Object>();data.put("action_kind",action.learn()?"passive_learn":"passive_unlearn");data.put("passive_id",action.passive());data.put("expected_revision",before.revision().toString());data.put("before_revision",before.revision().toString());data.put("points_before",before.unspentPoints().toString());data.put("point_cost",action.definition().pointCost().toString());var requirements=new LinkedHashMap<String,Object>();action.definition().requirements().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry->requirements.put(entry.getKey().name(),entry.getValue().toString()));data.put("stat_prerequisites",requirements);var statValues=new LinkedHashMap<String,Object>();action.definition().requirements().keySet().stream().sorted().forEach(stat->{var value=before.stats().get(stat);if(value!=null)statValues.put(stat.name(),value.toString());});data.put("stat_values_before",statValues);data.put("passive_slots_before",before.passives());data.put("passive_prerequisites",action.definition().prerequisites().stream().sorted().toList());data.put("outcome",outcome);data.putAll(action.actorMetadata());if(after!=null){data.put("after_revision",after.revision().toString());data.put("points_after",after.unspentPoints().toString());data.put("passive_slots_after",after.passives());int slot=(action.learn()?after.passives():before.passives()).indexOf(action.passive());data.put("passive_slot",slot);data.put("slot_index_base",0);data.put("native_publication","acknowledged");data.put("owned_cleanup",action.learn()?"passive_state_acknowledged":"passive_state_acknowledged");}if(reason!=null)data.put(type.startsWith("passive.")?"native_boundary":"reason",reason);try{sink.accept(new EventInput(id(action.actionId(),type),action.actionId(),clock.instant(),type,action.context(),Map.copyOf(data)));}catch(RuntimeException ignored){}
}
private void config(String type,String actorKind,UUID player,String playerName,String key,String before,String after){var action=UUID.randomUUID();var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);if(player!=null)data.put("actor_player_id",player.toString());if(playerName!=null)data.put("actor_player_name",playerName);data.put("key",key);if(before!=null)data.put("before",before);data.put("after",after);data.put("application","future_recovery_pulses");data.put("outcome","acknowledged");var context=player==null?null:new EventInput.Context(player,playerName,null,null,null,null);try{sink.accept(new EventInput(id(action,type),action,clock.instant(),type,context,Map.copyOf(data)));}catch(RuntimeException ignored){}
public void configRead(String actorKind,UUID player,String playerName,String key,String value,String application){config("config.read",actorKind,player,playerName,key,null,value,application);}
public void configChanged(String actorKind,UUID player,String playerName,String key,String before,String after,String application){config("config.changed",actorKind,player,playerName,key,before,after,application);}
private void config(String type,String actorKind,UUID player,String playerName,String key,String before,String after){config(type,actorKind,player,playerName,key,before,after,"future_recovery_pulses");}
private void config(String type,String actorKind,UUID player,String playerName,String key,String before,String after,String application){var action=UUID.randomUUID();var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);if(player!=null)data.put("actor_player_id",player.toString());if(playerName!=null)data.put("actor_player_name",playerName);data.put("key",key);if(before!=null)data.put("before",before);data.put("after",after);data.put("application",application);data.put("outcome","acknowledged");var context=player==null?null:new EventInput.Context(player,playerName,null,null,null,null);try{sink.accept(new EventInput(id(action,type),action,clock.instant(),type,context,Map.copyOf(data)));}catch(RuntimeException ignored){}
}
private Action captureAction(org.bukkit.entity.Player player,EquipmentSessions.Active active,CharacterRecord before,PassiveDefinition definition,boolean learn){
if(!games.dmg.fantasystick.passive.StickKnockback.ID.equals(definition.id()))return new Action(UUID.randomUUID(),context(player,active,before.name()),definition.id(),learn,definition,before);
var data=new LinkedHashMap<String,Object>();data.put("actor_player_id",active.player().toString());data.put("actor_character_id",before.id().toString());data.put("actor_session_id",active.session().toString());data.put("session_id",active.session().toString());
String playerName=null;EventInput.Location captured=null;
try{playerName=player.getName();}catch(RuntimeException ignored){}
data.put("actor_name_capture_quality",playerName==null?"unavailable":"exact");if(playerName!=null)data.put("actor_player_name",playerName);
try{captured=location(player.getLocation());}catch(RuntimeException ignored){}
data.put("location_capture_quality",captured==null?"unavailable":"exact");
if(captured!=null)data.put("actor_location",Map.of("world_id",captured.worldId().toString(),"world_name",captured.worldName(),"x",captured.x(),"y",captured.y(),"z",captured.z(),"captured_at",captured.capturedAt().toString()));
var context=playerName==null?null:new EventInput.Context(active.player(),playerName,before.id(),before.name(),active.session(),captured);
return new Action(UUID.randomUUID(),context,definition.id(),learn,definition,before,data);
}
private EventInput.Context context(org.bukkit.entity.Player player,EquipmentSessions.Active active,String name){return new EventInput.Context(player.getUniqueId(),player.getName(),active.character(),name,active.session(),location(player.getLocation()));}
private EventInput.Location location(Location value){return new EventInput.Location(value.getWorld().getUID(),value.getWorld().getName(),value.getX(),value.getY(),value.getZ(),clock.instant(),"exact");}
@@ -0,0 +1,92 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.entity.Player;
import org.bukkit.potion.PotionEffect;
/** Records real owned potion layers, not periodic evaluation or a desired passive state. */
public final class PassivePotionEventBridge implements AutoCloseable {
public record Application(UUID id, String passive, EventInput.Context context, Instant startedAt,
String effectType, int amplifier) { }
private final Consumer<EventInput> sink;
private final Clock clock;
private final FeatureEventOutbox outbox;
public PassivePotionEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink = Objects.requireNonNull(sink); this.clock = Objects.requireNonNull(clock); outbox = null;
}
public PassivePotionEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock, Path data, Consumer<Throwable> failures) {
this.sink = event -> sink.apply(event); this.clock = Objects.requireNonNull(clock);
outbox = new FeatureEventOutbox(data, "passive-potion-event-intents",
java.util.Set.of("effect.applied", "effect.ended"), sink, failures);
}
public Application applied(String passive, Player player, EquipmentSessions.Active active,
String characterName, PotionEffect owned, PotionEffect previous, String reason) {
try {
var context = new EventInput.Context(player.getUniqueId(), player.getName(), active.character(),
characterName, active.session(), location(player));
var application = new Application(UUID.randomUUID(), passive, context, clock.instant(),
owned.getType().getKey().toString(), owned.getAmplifier());
emit("effect.applied", application, context, reason,
previous == null ? "no_unrelated_layer_displaced" : "weaker_native_layer_retained", previous, false);
return application;
} catch (RuntimeException unavailable) { return null; }
}
public void ended(Application application, Player player, String reason, String disposition,
PotionEffect current, boolean restored) {
if (application == null) return;
try {
var before = application.context();
var context = new EventInput.Context(before.playerId(), before.playerName(), before.characterId(),
before.characterName(), before.sessionId(), location(player));
emit("effect.ended", application, context, reason, disposition, current, restored);
} catch (RuntimeException ignored) { /* Audit failure cannot retry or veto native cleanup. */ }
}
private void emit(String type, Application application, EventInput.Context context, String reason,
String disposition, PotionEffect nativeEffect, boolean restored) {
try {
var data = new LinkedHashMap<String, Object>();
data.put("passive_id", application.passive()); data.put("effect_id", application.id().toString());
data.put("application_id", application.id().toString());
data.put("owner_character_id", context.characterId().toString()); data.put("owner_session_id", context.sessionId().toString());
data.put("target_player_id", context.playerId().toString()); data.put("effect_type", application.effectType());
data.put("amplifier", application.amplifier()); data.put("started_at", application.startedAt().toString());
data.put("reason", reason); data.put("native_disposition", disposition); data.put("unrelated_effect_restored", restored);
data.put("native_publication", "acknowledged");
data.put("location_capture_quality", context.location() == null ? "unavailable" : "exact");
if (nativeEffect != null) data.put("unrelated_native_effect", Map.of(
"effect_type", nativeEffect.getType().getKey().toString(), "amplifier", nativeEffect.getAmplifier(),
"duration_ticks", nativeEffect.getDuration(), "infinite", nativeEffect.isInfinite()));
var event = new EventInput(id(application.id(), type), application.id(), clock.instant(), type, context, Map.copyOf(data));
if (outbox != null) outbox.accepted(event); else sink.accept(event);
} catch (RuntimeException ignored) { /* No native effect operation is reachable from the event exporter. */ }
}
private EventInput.Location location(Player player) {
try {
var value = player.getLocation(); if (value == null || value.getWorld() == null) return null;
return new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(), value.getX(), value.getY(),
value.getZ(), clock.instant(), "exact");
} catch (RuntimeException unavailable) { return null; }
}
private static UUID id(UUID application, String type) {
return UUID.nameUUIDFromBytes(("fantasystick:passive-potion:" + application + ":" + type).getBytes(StandardCharsets.UTF_8));
}
public void replay() { if (outbox != null) outbox.replay(); }
public CompletionStage<Void> drained() {
return outbox == null ? java.util.concurrent.CompletableFuture.completedFuture(null) : outbox.drained();
}
@Override public void close() { if (outbox != null) outbox.close(); }
}
@@ -25,6 +25,7 @@ public final class PreferenceEventBridge {
private final EquipmentSessions sessions;
private final PlayerFeedbackPreferences preferences;
private final Consumer<Throwable> failures;
private final java.util.Set<UUID> inFlight = java.util.concurrent.ConcurrentHashMap.newKeySet();
public PreferenceEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink, Clock clock,
EquipmentSessions sessions, PlayerFeedbackPreferences preferences,
@@ -184,15 +185,32 @@ public final class PreferenceEventBridge {
data.put("after", Boolean.toString(action.requested()));
data.put("preference_revision", Long.toString(preferenceSequence));
}
if (acknowledgedIntent) {
if (!inFlight.add(action.id())) return;
if (preferences.pendingEvents().stream().noneMatch(intent -> intent.actionId().equals(action.id()))) {
inFlight.remove(action.id());
return; // A stale snapshot cannot replay an already acknowledged preference.
}
}
try {
var input = new EventInput(UUID.nameUUIDFromBytes(("fantasystick:preference:"+action.id()+":"+type)
.getBytes(StandardCharsets.UTF_8)), action.id(), type.equals("preference.changed") ? action.time()
: clock.instant(), type, action.context(), Map.copyOf(data));
var result = sink.apply(input);
if (acknowledgedIntent) result.whenComplete((delivery, failure) -> {
if (failure == null && delivery != LocalEventLog.Delivery.REJECTED)
preferences.acknowledgeEvent(action.id());
if (failure != null || delivery == LocalEventLog.Delivery.REJECTED) {
inFlight.remove(action.id()); // Rejected evidence remains available for a real retry.
return;
}
preferences.acknowledgeEvent(action.id()).whenComplete((ignored, acknowledgementFailure) -> {
if (acknowledgementFailure == null) inFlight.remove(action.id());
else try { failures.accept(acknowledgementFailure); } catch (RuntimeException ignoredFailure) { }
// Accepted delivery must not be exported twice while its durable acknowledgement is uncertain.
});
});
} catch (RuntimeException ignored) { /* Logging cannot affect the command or persistent state. */ }
} catch (RuntimeException ignored) {
if (acknowledgedIntent) inFlight.remove(action.id());
/* Logging cannot affect the command or persistent state. */
}
}
}
@@ -3,19 +3,180 @@ package games.dmg.fantasystick.events;
import games.dmg.fantasystick.shop.RareCatalog;
import games.dmg.fantasystick.shop.RareProduct;
import java.math.BigInteger;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.function.Function;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
/** Failure-isolated Phoenix Down catalog and acknowledged purchase event adapter. */
public final class RareEventBridge {
private final Consumer<EventInput> sink;private final Clock clock;
public RareEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
public void catalogRefreshed(RareCatalog catalog){if(!catalog.available(RareProduct.PHOENIX_DOWN))return;var offer=catalog.offer(RareProduct.PHOENIX_DOWN);var data=new LinkedHashMap<String,Object>();data.put("actor_kind","system");data.put("product_id",RareProduct.PHOENIX_DOWN.id());data.put("catalog_revision",catalog.revision().toString());data.put("catalog_day",catalog.day().toString());data.put("refresh_reason",catalog.reason().name().toLowerCase(java.util.Locale.ROOT));data.put("price",offer.price().toString());data.put("stock",Integer.toString(offer.remaining()));data.put("outcome","acknowledged");emit(stable(catalog.revision(),"phoenix-catalog"),catalog.revision(),catalog.refreshedAt(),"rare_catalog.refreshed",null,data);}
public void purchased(UUID operation,UUID catalog,UUID player,String playerName,UUID character,String characterName,UUID session,BigInteger price,int stockBefore,int stockAfter,BigInteger quantityBefore,BigInteger quantityAfter){var data=new LinkedHashMap<String,Object>();data.put("actor_kind","player");data.put("product_id",RareProduct.PHOENIX_DOWN.id());data.put("catalog_revision",catalog.toString());data.put("purchase_operation_id",operation.toString());data.put("price",price.toString());data.put("stock_before",Integer.toString(stockBefore));data.put("stock_after",Integer.toString(stockAfter));data.put("quantity_before",quantityBefore.toString());data.put("quantity_after",quantityAfter.toString());data.put("outcome","acknowledged");var context=new EventInput.Context(player,playerName,character,characterName,session,null);emit(stable(operation,"phoenix-purchased"),operation,clock.instant(),"rare_item.purchased",context,data);}
private void emit(UUID id,UUID action,java.time.Instant time,String type,EventInput.Context context,java.util.Map<String,Object> data){try{sink.accept(new EventInput(id,action,time,type,context,data));}catch(RuntimeException ignored){}}
/** Failure-isolated rare catalog and acknowledged purchase event adapter. */
public final class RareEventBridge implements AutoCloseable {
private final Consumer<EventInput> sink;private final Clock clock;private final FeatureEventOutbox outbox;
private final java.util.Set<UUID> catalogEvents=java.util.concurrent.ConcurrentHashMap.newKeySet();
public RareEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);this.outbox=null;}
public RareEventBridge(Function<EventInput,CompletionStage<LocalEventLog.Delivery>> sink,Clock clock,Path data,Consumer<Throwable> failures){
this.sink=null;this.clock=Objects.requireNonNull(clock);
this.outbox=new FeatureEventOutbox(data,"rare-event-intents",Set.of("rare_catalog.refreshed",
"rare_shop.placed","rare_shop.removed","rare_item.purchased","rare_item.used",
"config.read","config.changed","action.observed","action.resolved"),sink,failures);
}
public void replay(){if(outbox!=null)outbox.replay();}
@Override public void close(){if(outbox!=null)outbox.close();}
public CompletionStage<Void> drained(){return outbox==null?CompletableFuture.completedFuture(null):outbox.drained();}
public void catalogRefreshed(RareCatalog catalog){
if(!catalogEvents.add(catalog.revision()))return;
var products=new java.util.ArrayList<java.util.Map<String,String>>();
for(var product:RareProduct.values())if(catalog.available(product)){
var offer=catalog.offer(product);
products.add(java.util.Map.of("product_id",product.id(),"price",offer.price().toString(),
"stock",Integer.toString(offer.remaining())));
}
var data=new LinkedHashMap<String,Object>();data.put("actor_kind","system");
data.put("catalog_revision",catalog.revision().toString());data.put("catalog_day",catalog.day().toString());
data.put("refresh_reason",catalog.reason().name().toLowerCase(java.util.Locale.ROOT));
data.put("next_midnight",catalog.day().plusDays(1).atStartOfDay(java.time.ZoneOffset.UTC).toInstant().toString());
data.put("products",products);data.put("outcome","acknowledged");
emit(stable(catalog.revision(),"rare-catalog"),catalog.revision(),catalog.refreshedAt(),
"rare_catalog.refreshed",null,data);
}
public void purchased(UUID operation,UUID catalog,UUID player,String playerName,UUID character,String characterName,UUID session,BigInteger price,int stockBefore,int stockAfter,BigInteger quantityBefore,BigInteger quantityAfter){
purchased(RareProduct.PHOENIX_DOWN,operation,catalog,null,player,playerName,character,characterName,
session,price,stockBefore,stockAfter,quantityBefore,quantityAfter,null,null,null,null);
}
public void purchased(RareProduct product,UUID operation,UUID catalog,UUID shop,UUID player,String playerName,
UUID character,String characterName,UUID session,BigInteger price,int stockBefore,int stockAfter,
BigInteger quantityBefore,BigInteger quantityAfter,BigInteger coinsBefore,BigInteger coinsAfter,
BigInteger revisionBefore,BigInteger revisionAfter){
purchased(product,operation,catalog,shop,player,playerName,character,characterName,session,price,
stockBefore,stockAfter,quantityBefore,quantityAfter,coinsBefore,coinsAfter,revisionBefore,revisionAfter,null);
}
public void purchased(RareProduct product,UUID operation,UUID catalog,UUID shop,UUID player,String playerName,
UUID character,String characterName,UUID session,BigInteger price,int stockBefore,int stockAfter,
BigInteger quantityBefore,BigInteger quantityAfter,BigInteger coinsBefore,BigInteger coinsAfter,
BigInteger revisionBefore,BigInteger revisionAfter,EventInput.Location location){
var data=new LinkedHashMap<String,Object>();data.put("actor_kind","player");data.put("product_id",product.id());
data.put("catalog_revision",catalog.toString());data.put("purchase_operation_id",operation.toString());
if(shop!=null)data.put("shop_id",shop.toString());
data.put("price",price.toString());data.put("stock_before",Integer.toString(stockBefore));
data.put("stock_after",Integer.toString(stockAfter));data.put("quantity_before",quantityBefore.toString());
data.put("quantity_after",quantityAfter.toString());
if(coinsBefore!=null&&coinsAfter!=null){data.put("coins_before",coinsBefore.toString());data.put("coins_after",coinsAfter.toString());}
if(revisionBefore!=null&&revisionAfter!=null){data.put("revision_before",revisionBefore.toString());data.put("revision_after",revisionAfter.toString());}
data.put("outcome","acknowledged");
var context=new EventInput.Context(player,playerName,character,characterName,session,location);
emit(stable(operation,product==RareProduct.PHOENIX_DOWN?"phoenix-purchased":"rare-purchased"),operation,
clock.instant(),"rare_item.purchased",context,data);
}
public void configRead(String actorKind,UUID player,String playerName,String key,String value){
config("config.read",actorKind,player,playerName,key,null,value);
}
public void configChanged(String actorKind,UUID player,String playerName,String key,String before,String after){
config("config.changed",actorKind,player,playerName,key,before,after);
}
private void config(String type,String actorKind,UUID player,String playerName,String key,String before,String after){
var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);
if(player!=null)data.put("actor_player_id",player.toString());
if(playerName!=null)data.put("actor_player_name",playerName);
data.put("key",key);if(before!=null)data.put("before",before);data.put("after",after);
data.put("application",key.endsWith("-healing-percent")?"future_potion_uses":"next_rare_restock");
data.put("outcome","acknowledged");var operation=UUID.randomUUID();
var context=player==null?null:new EventInput.Context(player,playerName,null,null,null,null);
emit(stable(operation,type),operation,clock.instant(),type,context,data);
}
public void shopPlaced(games.dmg.fantasystick.shop.RareMerchant shop,UUID administrator,String name){
shopChanged("rare_shop.placed",shop.id(),shop,administrator,name);
}
public void shopRemoved(UUID operation,games.dmg.fantasystick.shop.RareMerchant shop,UUID administrator,String name){
shopChanged("rare_shop.removed",operation,shop,administrator,name);
}
private void shopChanged(String type,UUID operation,games.dmg.fantasystick.shop.RareMerchant shop,UUID administrator,String name){
var data=new LinkedHashMap<String,Object>();data.put("actor_kind",administrator==null?"console":"player");
if(name!=null)data.put("administrator_name",name);
data.put("shop_id",shop.id().toString());data.put("shop_name",shop.name());
data.put("world_id",shop.world().toString());data.put("x",shop.x());data.put("y",shop.y());
data.put("z",shop.z());data.put("yaw",(double)shop.yaw());data.put("pitch",(double)shop.pitch());
data.put("outcome","acknowledged");
var context=administrator==null?null:new EventInput.Context(administrator,name,null,null,null,null);
emit(stable(operation,type),operation,clock.instant(),type,context,data);
}
public EventInput.Context potionContext(org.bukkit.entity.Player player,UUID character,String name,UUID session){
var nativeLocation=player.getLocation();var world=nativeLocation.getWorld();
var location=world==null?null:new EventInput.Location(world.getUID(),world.getName(),
nativeLocation.getX(),nativeLocation.getY(),nativeLocation.getZ(),clock.instant(),"native");
return new EventInput.Context(player.getUniqueId(),player.getName(),character,name,session,location);
}
public void potionUsed(UUID operation,RareProduct product,EventInput.Context context,BigInteger quantityBefore,
int percent,BigDecimal healthBefore,BigDecimal healthAfter,BigDecimal maximum,
BigDecimal requested,BigDecimal applied,BigInteger revisionBefore,BigInteger revisionAfter){
var data=new LinkedHashMap<String,Object>();data.put("actor_kind","player");data.put("product_id",product.id());
data.put("use_operation_id",operation.toString());data.put("quantity_before",quantityBefore.toString());
data.put("quantity_after",quantityBefore.subtract(BigInteger.ONE).toString());
data.put("healing_percent",Integer.toString(percent));data.put("health_before",healthBefore.stripTrailingZeros().toPlainString());
data.put("health_after",healthAfter.stripTrailingZeros().toPlainString());
data.put("maximum_health",maximum.stripTrailingZeros().toPlainString());
data.put("requested_healing",requested.stripTrailingZeros().toPlainString());
data.put("applied_healing",applied.stripTrailingZeros().toPlainString());
data.put("revision_before",revisionBefore.toString());data.put("revision_after",revisionAfter.toString());
data.put("outcome","acknowledged");
emit(stable(operation,"potion-used"),operation,clock.instant(),"rare_item.used",context,data);
}
public void magicCoreUsed(UUID operation,EventInput.Context context,BigInteger quantityBefore,
games.dmg.fantasystick.persistence.CharacterRecord before,
games.dmg.fantasystick.persistence.CharacterRecord after){
var data=new LinkedHashMap<String,Object>();data.put("actor_kind","player");data.put("product_id","magic-core");
data.put("use_operation_id",operation.toString());data.put("quantity_before",quantityBefore.toString());
data.put("quantity_after",quantityBefore.subtract(BigInteger.ONE).toString());
data.put("points_before",before.unspentPoints().toString());data.put("points_after",after.unspentPoints().toString());
data.put("credited_points",after.unspentPoints().subtract(before.unspentPoints()).toString());
data.put("revision_before",before.revision().toString());data.put("revision_after",after.revision().toString());
data.put("level_before",Integer.toString(before.level()));data.put("level_after",Integer.toString(after.level()));
data.put("xp_before",before.experience().toString());data.put("xp_after",after.experience().toString());
data.put("outcome","acknowledged");
emit(stable(operation,"magic-core-used"),operation,clock.instant(),"rare_item.used",context,data);
}
public void reskillAction(UUID operation,EventInput.Context context,String phase,String outcome,String reason){
var data=new LinkedHashMap<String,Object>();data.put("actor_kind","player");data.put("product_id","reskill");
data.put("action_kind","reskill_use");data.put("use_operation_id",operation.toString());
data.put("phase",phase);data.put("outcome",outcome);data.put("reason",reason);
String type=outcome.equals("observed")?"action.observed":"action.resolved";
emit(stable(operation,"reskill-"+phase+"-"+type),operation,clock.instant(),type,context,data);
}
public void reskillUsed(UUID operation,EventInput.Context context,BigInteger quantityBefore,
games.dmg.fantasystick.persistence.CharacterRecord before,
games.dmg.fantasystick.persistence.CharacterRecord after,
BigDecimal healthBefore,BigDecimal healthAfter,BigDecimal maximumBefore,BigDecimal maximumAfter){
var data=new LinkedHashMap<String,Object>();data.put("actor_kind","player");data.put("product_id","reskill");
data.put("use_operation_id",operation.toString());data.put("quantity_before",quantityBefore.toString());
data.put("quantity_after",quantityBefore.subtract(BigInteger.ONE).toString());
data.put("revision_before",before.revision().toString());data.put("revision_after",after.revision().toString());
var statsBefore=new LinkedHashMap<String,String>();var statsAfter=new LinkedHashMap<String,String>();
for(var stat:games.dmg.fantasystick.progression.Stat.values()){
String key=stat.name().toLowerCase(java.util.Locale.ROOT);
statsBefore.put(key,before.stats().get(stat).toString());statsAfter.put(key,after.stats().get(stat).toString());
}
data.put("stats_before",statsBefore);data.put("stats_after",statsAfter);
data.put("removed_learned",before.learned().stream().sorted().toList());
data.put("removed_passives",before.passives().stream().filter(value->!value.isEmpty()).distinct().sorted().toList());
data.put("active_slots_before",before.loadout());data.put("active_slots_after",after.loadout());
data.put("passive_slots_before",before.passives());data.put("passive_slots_after",after.passives());
data.put("points_before",before.unspentPoints().toString());data.put("points_after",after.unspentPoints().toString());
data.put("refunded_points",after.unspentPoints().subtract(before.unspentPoints()).toString());
data.put("health_before",healthBefore.stripTrailingZeros().toPlainString());
data.put("health_after",healthAfter.stripTrailingZeros().toPlainString());
data.put("maximum_before",maximumBefore.stripTrailingZeros().toPlainString());
data.put("maximum_after",maximumAfter.stripTrailingZeros().toPlainString());
var deadlines=after.cooldowns().entrySet().stream().sorted(java.util.Map.Entry.comparingByKey())
.map(entry->java.util.Map.of("ability_id",entry.getKey(),"deadline",entry.getValue().toString())).toList();
data.put("retained_cooldowns",deadlines);data.put("cleanup_outcome","acknowledged");data.put("outcome","acknowledged");
emit(stable(operation,"reskill-used"),operation,clock.instant(),"rare_item.used",context,data);
}
private void emit(UUID id,UUID action,java.time.Instant time,String type,EventInput.Context context,java.util.Map<String,Object> data){try{var event=new EventInput(id,action,time,type,context,data);if(outbox!=null)outbox.accepted(event);else sink.accept(event);}catch(RuntimeException ignored){}}
private static UUID stable(UUID authority,String kind){return UUID.nameUUIDFromBytes(("fantasystick:"+authority+':'+kind).getBytes(StandardCharsets.UTF_8));}
}
@@ -0,0 +1,194 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.passive.StickKnockback;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import java.util.ArrayDeque;
import java.util.Set;
import java.time.Instant;
import java.nio.charset.StandardCharsets;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import games.dmg.fantasystick.persistence.CharacterRecord;
import org.bukkit.entity.LivingEntity;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.util.Vector;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Function;
/** Knockback-owned immutable audit intents; replay never touches controls, entities or gameplay. */
public final class StickKnockbackEventBridge implements AutoCloseable, Listener {
private final FeatureEventOutbox outbox;
// Server-thread invocation scopes. The first callbacks belong to this exact native call;
// nested external/native calls cannot replace already-bound observations.
private final ArrayDeque<Pending> scopes = new ArrayDeque<>();
private static final StackWalker NATIVE_STACK = StackWalker.getInstance();
private static final class Pending {
final UUID action;
final UUID target;
final EventInput.Context actor;
final Map<String, Object> data;
long nativeDepth;
org.bukkit.event.entity.EntityKnockbackEvent bukkit;
io.papermc.paper.event.entity.EntityKnockbackEvent paper;
Pending(UUID action, UUID target, EventInput.Context actor, Map<String, Object> data) {
this.action = action; this.target = target; this.actor = actor; this.data = Map.copyOf(data);
}
}
public StickKnockbackEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Path data, Consumer<Throwable> failures) {
outbox = new FeatureEventOutbox(data, "stick-knockback-event-intents",
Set.of("passive.learned", "passive.unlearned", "effect.applied"), sink, failures);
}
/** Called only after PassiveEngine has confirmed save and native control publication. */
public void acknowledgedPassive(EventInput input) {
if (!StickKnockback.ID.equals(input.data().get("passive_id"))
|| !(input.type().equals("passive.learned") || input.type().equals("passive.unlearned"))) return;
var data = new LinkedHashMap<>(input.data());
var before = new BigInteger((String) data.get("points_before"));
var after = new BigInteger((String) data.get("points_after"));
data.put("point_delta", after.subtract(before).toString());
data.put("operation_id", input.actionId().toString());
if (input.context() != null) data.put("session_id", input.context().sessionId().toString());
data.put("control_refresh_result", "acknowledged");
data.put("installed_knockback_tier", input.type().equals("passive.learned") ? 1 : 0);
outbox.accepted(new EventInput(input.id(), input.actionId(), input.time(), input.type(), input.context(), data));
}
/** Observe, never decide, the existing native passive application after accepted Basic Attack. */
public void apply(BasicAttackEventBridge.Action action, ControlInput input, CharacterRecord record,
LivingEntity target, EquipmentSessions sessions) {
Pending pending = null;
try { pending = capture(action, input, record, target, sessions); }
catch (RuntimeException ignored) { /* Audit capture has no gameplay authority. */ }
if (pending != null) scopes.push(pending);
boolean returned = false;
try { StickKnockback.apply(input.player(), target); returned = true; }
finally {
if (pending != null) {
scopes.removeFirstOccurrence(pending);
if (returned) {
try { applied(pending, target); }
catch (RuntimeException ignored) { /* Do not undo or interrupt accepted native gameplay. */ }
}
}
}
}
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = false)
public void observedBukkit(org.bukkit.event.entity.EntityKnockbackEvent event) {
var pending = scopes.peek();
try {
if (pending != null && pending.bukkit == null && pending.target.equals(event.getEntity().getUniqueId())
&& event.getCause() == org.bukkit.event.entity.EntityKnockbackEvent.KnockbackCause.UNKNOWN) {
pending.nativeDepth = nativeDepth(); pending.bukkit = event;
}
} catch (RuntimeException ignored) { }
}
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = false)
public void observedPaper(io.papermc.paper.event.entity.EntityKnockbackEvent event) {
var pending = scopes.peek();
try {
if (pending != null && pending.bukkit != null && pending.paper == null
&& pending.target.equals(event.getEntity().getUniqueId())
&& event.getCause() == io.papermc.paper.event.entity.EntityKnockbackEvent.Cause.UNKNOWN
&& pending.nativeDepth == nativeDepth()) pending.paper = event;
} catch (RuntimeException ignored) { }
}
private static long nativeDepth() {
return NATIVE_STACK.walk(frames -> frames.filter(frame -> frame.getMethodName().equals("knockback")).count());
}
private Pending capture(BasicAttackEventBridge.Action action, ControlInput input, CharacterRecord record,
LivingEntity target, EquipmentSessions sessions) {
var operation = action == null ? UUID.randomUUID() : action.id();
var data = new LinkedHashMap<String, Object>();
data.put("passive_id", StickKnockback.ID); data.put("action_kind", "basic_attack_knockback");
data.put("operation_id", operation.toString()); data.put("effect_id", id(operation).toString());
data.put("source_player_id", input.session().player().toString());
data.put("source_character_id", record.id().toString());
data.put("source_session_id", input.session().session().toString());
data.put("source_revision", record.revision().toString());
if (action != null) data.put("damage_event_id", UUID.nameUUIDFromBytes(
("fantasystick:basic-attack:" + action.id() + ":damage").getBytes(StandardCharsets.UTF_8)).toString());
else data.put("damage_event_id_status", "unavailable_basic_attack_audit");
var targetId = target.getUniqueId(); data.put("target_entity_id", targetId.toString());
try { data.put("target_entity_type", target.getType().name().toLowerCase(java.util.Locale.ROOT)); }
catch (RuntimeException ignored) { data.put("target_entity_type_status", "unavailable"); }
if (target instanceof org.bukkit.entity.Player) {
data.put("target_player_id", targetId.toString());
var active = sessions.active(targetId).filter(sessions::current).orElse(null);
if (active != null) {
data.put("target_character_id", active.character().toString());
data.put("target_session_id", active.session().toString());
} else data.put("target_character_status", "unavailable");
}
boolean sprinting = input.player().isSprinting();
data.put("sprinting", sprinting); data.put("strength", sprinting ? 2d : 1d); data.put("knockback_tier", 1);
location(data, "source_location", input.player()::getLocation);
location(data, "target_location", target::getLocation);
velocity(data, "velocity_before", target);
try {
var attribute = target.getAttribute(org.bukkit.attribute.Attribute.KNOCKBACK_RESISTANCE);
double value = attribute == null ? Double.NaN : attribute.getValue();
if (Double.isFinite(value)) data.put("knockback_resistance", value);
else data.put("knockback_resistance_status", "unavailable");
} catch (RuntimeException ignored) { data.put("knockback_resistance_status", "unavailable"); }
EventInput.Context context = action == null ? null : action.actor();
return new Pending(operation, targetId, context, data);
}
private void applied(Pending pending, LivingEntity target) {
// CraftEventFactory dispatches Bukkit first, then Paper. Native velocity is written only
// after the FINAL Paper result returns. Do not report attempted/cancelled/resisted/no-op pushes.
if (pending.bukkit == null || pending.paper == null || pending.paper.isCancelled()
|| !Double.isFinite(pending.bukkit.getForce()) || pending.bukkit.getForce() <= 0) return;
var delta = pending.paper.getKnockback();
if (!finite(delta) || delta.lengthSquared() == 0) return;
var data = new LinkedHashMap<>(pending.data);
data.put("native_force", pending.bukkit.getForce()); data.put("native_cause", pending.paper.getCause().name());
data.put("bukkit_cancelled", pending.bukkit.isCancelled()); data.put("native_final_cancelled", false);
data.put("native_velocity_delta", vector(delta));
velocity(data, "velocity_after", target); data.put("outcome", "accepted");
data.put("native_boundary", "returned_after_final_paper_knockback_and_velocity_application");
outbox.accepted(new EventInput(id(pending.action), pending.action, Instant.now(), "effect.applied", pending.actor, data));
}
private static UUID id(UUID action) {
return UUID.nameUUIDFromBytes(("fantasystick:stick-knockback:" + action + ":applied").getBytes(StandardCharsets.UTF_8));
}
private static void location(Map<String, Object> data, String key, java.util.function.Supplier<org.bukkit.Location> capture) {
try {
var location = capture.get(); var world = location == null ? null : location.getWorld();
if (world == null || !Double.isFinite(location.getX()) || !Double.isFinite(location.getY()) || !Double.isFinite(location.getZ())) {
data.put(key + "_status", "unavailable"); return;
}
data.put(key, Map.of("world_id", world.getUID().toString(), "world_name", world.getName(),
"x", location.getX(), "y", location.getY(), "z", location.getZ(), "captured_at", Instant.now().toString()));
data.put(key + "_status", "exact");
} catch (RuntimeException ignored) { data.put(key + "_status", "unavailable"); }
}
private static void velocity(Map<String, Object> data, String key, LivingEntity target) {
try { var value = target.getVelocity(); if (finite(value)) data.put(key, vector(value));
else data.put(key + "_status", "unavailable"); }
catch (RuntimeException ignored) { data.put(key + "_status", "unavailable"); }
}
private static boolean finite(Vector value) {
return value != null && Double.isFinite(value.getX()) && Double.isFinite(value.getY()) && Double.isFinite(value.getZ());
}
private static Map<String, Object> vector(Vector value) { return Map.of("x", value.getX(), "y", value.getY(), "z", value.getZ()); }
public void replay() { outbox.replay(); }
public CompletionStage<Void> drained() { return outbox.drained(); }
@Override public void close() { outbox.close(); }
}
@@ -0,0 +1,167 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.session.EquipmentSessions;
import games.dmg.fantasystick.training.TrainingDummy;
import games.dmg.fantasystick.training.TrainingDummyController;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
/** Immutable acknowledged dummy-owner records; replay has no gameplay authority. */
public final class TrainingEventBridge implements AutoCloseable {
public record Actor(String kind, UUID playerId, String playerName, EventInput.Context context) {}
private final FeatureEventOutbox outbox;
private final Clock clock = Clock.systemUTC();
public TrainingEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Path data, Consumer<Throwable> failures) {
outbox = new FeatureEventOutbox(data, "training-event-intents", Set.of("training_dummy.placed", "training_dummy.removed", "training_dummy.measured"), sink, failures);
}
public Actor actor(CommandSender sender, EquipmentSessions sessions, org.bukkit.Location input) {
String kind = sender instanceof Player ? "player" : sender instanceof org.bukkit.command.BlockCommandSender ? "command_block" : "console";
if (!(sender instanceof Player player)) return new Actor(kind, null, null, null);
UUID id = null, character = null, session = null; String name = null, characterName = null;
EventInput.Location location = null;
try { id = player.getUniqueId(); } catch (RuntimeException ignored) { }
try { name = player.getName(); } catch (RuntimeException ignored) { }
try {
var active = id == null ? null : sessions.active(id).filter(sessions::current).orElse(null);
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
if (record != null) { character = record.id(); characterName = record.name(); session = active.session(); }
} catch (RuntimeException ignored) { }
try {
var at = input == null ? player.getLocation() : input;
if (at.getWorld() != null) location = new EventInput.Location(at.getWorld().getUID(), at.getWorld().getName(),
at.getX(), at.getY(), at.getZ(), clock.instant(), "native");
} catch (RuntimeException ignored) { }
try { return new Actor(kind, id, name, new EventInput.Context(id, name, character, characterName, session, location)); }
catch (RuntimeException unavailable) { return new Actor(kind, id, name, null); }
}
public void registration(String type, UUID operation, TrainingDummy dummy, Actor actor,
TrainingDummyController.Observed entities, String outcome) {
try {
var values = new LinkedHashMap<String, Object>();
values.put("actor_kind", actor == null ? "unavailable" : actor.kind());
values.put("request_actor_kind", actor == null ? "unavailable" : actor.kind());
values.put("actor_capture_quality", actor == null || "player".equals(actor.kind()) && actor.context() == null ? "unavailable" : "native");
if (actor != null && actor.playerId() != null) values.put("request_player_id", actor.playerId().toString());
if (actor != null && actor.playerName() != null) values.put("request_player_name", actor.playerName());
values.put("operation_id", operation.toString()); values.put("dummy_id", dummy.id().toString());
values.put("dummy_name", dummy.name()); values.put("dummy_display_name", dummy.displayName());
values.put("revision_capture_quality", dummy.revision() == null ? "unavailable" : "persisted");
if (dummy.revision() != null) values.put("dummy_revision", dummy.revision().toString());
values.put("world_id", dummy.world().toString()); values.put("x", dummy.x()); values.put("y", dummy.y());
values.put("z", dummy.z()); values.put("yaw", (double) dummy.yaw()); values.put("pitch", (double) dummy.pitch());
values.put("chunk_status", entities.chunkStatus()); values.put("entity_capture_quality", entities.quality());
values.put("entity_ids", entities.ids().stream().map(UUID::toString).toList());
if (entities.ids().size() == 1) values.put("entity_id", entities.ids().getFirst().toString());
values.put("entity_outcome", outcome); values.put("outcome", "acknowledged");
var id = UUID.nameUUIDFromBytes(("fantasystick:training:" + operation + ":" + type).getBytes(StandardCharsets.UTF_8));
outbox.accepted(new EventInput(id, operation, clock.instant(), type, actor == null ? null : actor.context(), Map.copyOf(values)));
} catch (RuntimeException ignored) { /* Audit cannot undo registration or native reconciliation. */ }
}
public record Capture(games.dmg.fantasystick.training.TrainingDamage.Source source, String playerName,
EventInput.Location location, EventInput.Context context) {}
public Capture capture(Player player, games.dmg.fantasystick.training.TrainingDamage.Source source) {
String name = null; EventInput.Location location = null; EventInput.Context context = null;
try { name = player.getName(); } catch (RuntimeException ignored) { }
try {
var at = player.getLocation();
if (at.getWorld() != null) location = new EventInput.Location(at.getWorld().getUID(), at.getWorld().getName(),
at.getX(), at.getY(), at.getZ(), clock.instant(), "native");
} catch (RuntimeException ignored) { }
try { context = new EventInput.Context(source.playerId(), name, source.characterId(), source.characterName(), source.sessionId(), location); }
catch (RuntimeException ignored) { }
return new Capture(source, name, location, context);
}
public EventInput measurement(Capture capture, UUID causation, TrainingDummy dummy, org.bukkit.event.entity.EntityDamageEvent event,
java.math.BigDecimal amount) {
try {
var source = capture.source(); var values = new LinkedHashMap<String, Object>();
UUID measurement = UUID.nameUUIDFromBytes(("fantasystick:training:" + causation + ":measured").getBytes(StandardCharsets.UTF_8));
values.put("measurement_id", measurement.toString()); values.put("damage_causation_id", causation.toString());
values.put("attack_name", source.attackName());
if (source.abilityId() != null) values.put("ability_id", source.abilityId());
values.put("source_player_id", source.playerId().toString()); values.put("source_character_id", source.characterId().toString());
values.put("source_session_id", source.sessionId().toString()); values.put("source_character_name", source.characterName());
if (capture.playerName() != null) values.put("source_player_name", capture.playerName());
values.put("actor_capture_quality", capture.context() == null ? "unavailable" : "native");
values.put("dummy_id", dummy.id().toString()); values.put("dummy_name", dummy.name()); values.put("dummy_display_name", dummy.displayName());
values.put("revision_capture_quality", dummy.revision() == null ? "unavailable" : "persisted");
if (dummy.revision() != null) values.put("dummy_revision", dummy.revision().toString());
values.put("entity_id", event.getEntity().getUniqueId().toString()); values.put("final_amount", amount.toPlainString());
values.put("native_prior_cancelled", false); values.put("dummy_containment_cancelled", true);
var modifiers = new LinkedHashMap<String, String>();
boolean completeModifiers = true;
for (var modifier : org.bukkit.event.entity.EntityDamageEvent.DamageModifier.values()) {
try {
if (event.isApplicable(modifier)) modifiers.put(modifier.name().toLowerCase(java.util.Locale.ROOT),
java.math.BigDecimal.valueOf(event.getDamage(modifier)).stripTrailingZeros().toPlainString());
} catch (RuntimeException unavailable) { completeModifiers = false; }
}
values.put("native_modifiers", Map.copyOf(modifiers));
values.put("native_modifier_capture_quality", completeModifiers ? "native" : "unavailable");
projectile(values, event);
if (capture.location() != null) values.put("source_location", coordinates(capture.location()));
else values.put("source_location_capture_quality", "unavailable");
try {
var at = event.getEntity().getLocation(); var world = at.getWorld();
if (world != null) values.put("target_location", coordinates(new EventInput.Location(world.getUID(), world.getName(),
at.getX(), at.getY(), at.getZ(), clock.instant(), "native")));
else values.put("target_location_capture_quality", "unavailable");
} catch (RuntimeException unavailable) { values.put("target_location_capture_quality", "unavailable"); }
values.put("outcome", "measured");
return new EventInput(measurement, source.actionId(), clock.instant(), "training_dummy.measured", capture.context(), Map.copyOf(values));
} catch (RuntimeException unavailable) { return null; }
}
private void projectile(Map<String, Object> values, org.bukkit.event.entity.EntityDamageEvent event) {
org.bukkit.entity.Entity direct;
try { direct = event.getDamageSource().getDirectEntity(); }
catch (RuntimeException unavailable) { values.put("direct_entity_capture_quality", "unavailable"); return; }
if (!(direct instanceof org.bukkit.entity.Projectile projectile)) return;
try {
values.put("projectile_id", projectile.getUniqueId().toString());
values.put("projectile_capture_quality", "native");
} catch (RuntimeException unavailable) { values.put("projectile_capture_quality", "unavailable"); }
var flight = new LinkedHashMap<String, Object>();
try {
var at = projectile.getLocation(); var world = at.getWorld();
if (world == null) flight.put("location_capture_quality", "unavailable");
else flight.putAll(coordinates(new EventInput.Location(world.getUID(), world.getName(),
at.getX(), at.getY(), at.getZ(), clock.instant(), "native")));
} catch (RuntimeException unavailable) { flight.put("location_capture_quality", "unavailable"); }
try {
var velocity = projectile.getVelocity();
if (Double.isFinite(velocity.getX()) && Double.isFinite(velocity.getY()) && Double.isFinite(velocity.getZ())) {
flight.put("velocity_x", velocity.getX()); flight.put("velocity_y", velocity.getY()); flight.put("velocity_z", velocity.getZ());
} else flight.put("velocity_capture_quality", "unavailable");
} catch (RuntimeException unavailable) { flight.put("velocity_capture_quality", "unavailable"); }
flight.put("capture_boundary", "native_damage_interception");
values.put("flight_context", Map.copyOf(flight));
}
private static Map<String, Object> coordinates(EventInput.Location location) {
return Map.of("world_id", location.worldId().toString(), "world_name", location.worldName(), "x", location.x(),
"y", location.y(), "z", location.z(), "captured_at", location.capturedAt().toString(), "capture_quality", location.quality());
}
public void measured(EventInput input) {
try { if (input != null) outbox.accepted(input); } catch (RuntimeException ignored) { /* Never gameplay. */ }
}
public void replay() { outbox.replay(); }
public CompletionStage<Void> drained() { return outbox.drained(); }
@Override public void close() { outbox.close(); }
}
@@ -79,6 +79,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
private java.util.function.Predicate<org.bukkit.entity.Entity> excluded = ignored -> false;
private java.util.function.Function<LivingEntity,java.util.Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin>> managedOrigins=ignored->java.util.Optional.empty();
private Consumer<games.dmg.fantasystick.progression.ManagedEncounterOrigin> managedSettlement=ignored->{};
private java.util.function.Function<LivingEntity,Map<String,Object>> managedRewardEvidence=ignored->Map.of();
private games.dmg.fantasystick.events.CombatRewardEventBridge events;
private boolean closed;
@@ -142,6 +143,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
public void attachManagedOrigins(java.util.function.Function<LivingEntity,java.util.Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin>> classifier){
if(closed)throw new IllegalStateException("Combat experience is closed");managedOrigins=Objects.requireNonNull(classifier);
}
public void attachManagedRewardEvidence(java.util.function.Function<LivingEntity,Map<String,Object>> evidence){if(closed)throw new IllegalStateException("Combat experience is closed");managedRewardEvidence=Objects.requireNonNull(evidence);}
public void attachManagedSettlement(Consumer<games.dmg.fantasystick.progression.ManagedEncounterOrigin> settlement){if(closed)throw new IllegalStateException("Combat experience is closed");managedSettlement=Objects.requireNonNull(settlement);}
public void attachEvents(games.dmg.fantasystick.events.CombatRewardEventBridge value){if(closed||events!=null)throw new IllegalStateException("Combat reward events already attached or combat is closed");events=Objects.requireNonNull(value);}
@@ -236,6 +238,9 @@ public final class CombatExperience implements Listener, AutoCloseable {
BigInteger defeatedCoins) {
Instant now = clock.instant();
java.util.Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin> managed;try{managed=managedOrigins.apply(target);}catch(RuntimeException invalid){managed=java.util.Optional.empty();}
Map<String,Object> managedEvidence=Map.of();
if(managed.isPresent())try{managedEvidence=Map.copyOf(managedRewardEvidence.apply(target));}
catch(RuntimeException ignored){}
var direct = new ArrayList<>(contributions.consumeDirect(target.getUniqueId(), now));
if (defeatedCharacter != null) direct.remove(defeatedCharacter);
if (direct.isEmpty()) {managed.ifPresent(this::settled);return;}
@@ -255,7 +260,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
for (UUID character : xp.keySet()) {
String receiptAuthority=managed.isPresent()?authority:targetReward+":"+now+":"+authority;
UUID receipt = UUID.nameUUIDFromBytes((receiptAuthority+":"+character).getBytes(java.nio.charset.StandardCharsets.UTF_8));
var unit=units.stream().filter(value->value.recipients().contains(character)).findFirst().orElseThrow();var capture=new games.dmg.fantasystick.events.CombatRewardEventBridge.Capture(target.getUniqueId(),target.getType().name().toLowerCase(java.util.Locale.ROOT),kind.name().toLowerCase(java.util.Locale.ROOT),coinTarget(target).name().toLowerCase(java.util.Locale.ROOT),defeatedCharacter==null?position(target.getLocation()):null,managed.orElse(null),defeatedCharacter==null?"mob_defeat":"fantasy_character_defeat",now,direct,String.valueOf(unit.key()),units.size(),experiencePool,coinPool);
var unit=units.stream().filter(value->value.recipients().contains(character)).findFirst().orElseThrow();var capture=new games.dmg.fantasystick.events.CombatRewardEventBridge.Capture(target.getUniqueId(),target.getType().name().toLowerCase(java.util.Locale.ROOT),kind.name().toLowerCase(java.util.Locale.ROOT),coinTarget(target).name().toLowerCase(java.util.Locale.ROOT),defeatedCharacter==null?position(target.getLocation()):null,managed.orElse(null),defeatedCharacter==null?"mob_defeat":"fantasy_character_defeat",now,direct,String.valueOf(unit.key()),units.size(),experiencePool,coinPool,managedEvidence);
var proposed = new PendingAward(receipt, character, xp.get(character), coins.get(character), restricted,capturedRewards,capture);
var existing=pending.putIfAbsent(receipt,proposed);var operation=existing==null?proposed:existing;settlements.add(operation.settled);attempt(operation);
}
@@ -1,6 +1,7 @@
package games.dmg.fantasystick.passive;
import games.dmg.fantasystick.ability.AbilityReport;
import games.dmg.fantasystick.events.HealthStealEventBridge;
import games.dmg.fantasystick.party.CombatSources;
import games.dmg.fantasystick.presentation.CombatFeedback;
import games.dmg.fantasystick.persistence.CharacterRecord;
@@ -46,8 +47,8 @@ public final class HealthSteal implements Listener, AutoCloseable {
public static Settings defaults() { return new Settings(BigInteger.ONE); }
}
public record Failure(UUID character, Throwable cause) { }
private record Candidate(LivingEntity target, CombatSources.Origin origin) { }
private record Heal(EquipmentSessions.Active active, double amount) { }
private record Candidate(LivingEntity target, CombatSources.Origin origin, HealthStealEventBridge.Damage audit) { }
private record Heal(EquipmentSessions.Active active, double amount, HealthStealEventBridge.Action audit) { }
private final CharacterStorage characters;
private final EquipmentSessions sessions;
@@ -80,6 +81,11 @@ public final class HealthSteal implements Listener, AutoCloseable {
this.failures = Objects.requireNonNull(failures); this.feedback = feedback; sources = new CombatSources(sessions);
}
private HealthStealEventBridge events;
public void attachEvents(HealthStealEventBridge bridge) {
if (closed || events != null || !lethal.isEmpty() || !pending.isEmpty()) throw new IllegalStateException("Health Steal events already attached or evaluation started");
events = Objects.requireNonNull(bridge);
}
public void configure(Settings value) { settings = Objects.requireNonNull(value); }
public BigInteger healing() { return settings.healing(); }
public PassiveDefinition definition() {
@@ -102,7 +108,7 @@ public final class HealthSteal implements Listener, AutoCloseable {
|| event.getFinalDamage() < remaining) {
lethal.remove(targetId); return;
}
lethal.put(targetId, new Candidate(target, origin));
lethal.put(targetId, new Candidate(target, origin, events == null ? null : events.damage(event)));
}
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
@@ -151,58 +157,75 @@ public final class HealthSteal implements Listener, AutoCloseable {
double amount = configured.bitLength() > 1023 ? maximum - current - reserved : configured.doubleValue();
double proposed = Math.min(maximum - current - reserved, amount);
if (!Double.isFinite(current) || !Double.isFinite(maximum) || current < 0 || maximum <= 0
|| !Double.isFinite(proposed) || proposed <= 0) return;
|| !Double.isFinite(proposed)) return;
var audit = events == null ? null : events.begin(candidate.audit(), target, player, active, record,
target instanceof Player ? sessions.active(target.getUniqueId()).orElse(null) : null,
configured, proposed, current, maximum);
if (proposed <= 0) { if (events != null) events.refused(audit, "full_health_or_no_available_capacity"); return; }
var healing = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
healingEvents.accept(healing);
try { healingEvents.accept(healing); }
catch (RuntimeException failure) { if (events != null) events.failed(audit, "native_healing_validation_failed"); throw failure; }
double accepted = Math.min(proposed, Math.min(maximum - current - reserved, healing.getAmount()));
if (healing.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) return;
if (healing.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) {
if (events != null) events.refused(audit, healing.isCancelled() ? "native_event_cancelled" : "no_positive_healing_accepted"); return;
}
var queue = pending.computeIfAbsent(active.character(), ignored -> new ArrayDeque<>());
if (queue.size() >= MAX_PENDING_PER_CHARACTER) return;
queue.addLast(new Heal(active, accepted));
if (queue.size() >= MAX_PENDING_PER_CHARACTER) { if (events != null) events.refused(audit, "pending_heal_capacity"); return; }
queue.addLast(new Heal(active, accepted, audit));
if (queue.size() == 1) attempt(active.character());
}
private void attempt(UUID character) {
var queue = pending.get(character); var operation = queue == null ? null : queue.peekFirst();
if (closed || operation == null) return;
if (!sessions.current(operation.active())) { finish(character); return; }
if (!sessions.current(operation.active())) { auditFailed(operation, "source_session_changed"); finish(character); return; }
var record = sessions.currentRecord(operation.active()).orElse(null);
var player = sessions.currentPlayer(operation.active()).orElse(null);
if (record == null || player == null || record.dead() || !record.passives().contains(ID)) { finish(character); return; }
if (record == null || player == null || record.dead() || !record.passives().contains(ID)) {
auditFailed(operation, "character_unavailable_or_passive_removed"); finish(character); return;
}
double nativeBefore = player.getHealth();
BigDecimal target = record.health().add(BigDecimal.valueOf(operation.amount()));
BigDecimal limit;
try { limit = nativeLimit.get(); }
catch (RuntimeException failure) { report(character, failure); finish(character); return; }
catch (RuntimeException failure) { auditFailed(operation, "native_health_limit_unavailable"); report(character, failure); finish(character); return; }
BigInteger expected = record.revision();
characters.saveHealth(character, expected, target, limit).whenComplete((saved, failure) -> {
try { nextTick.accept(() -> complete(character, operation, expected, nativeBefore, saved, failure)); }
catch (RuntimeException rejected) { report(character, rejected); }
try { nextTick.accept(() -> complete(character, operation, expected, record, nativeBefore, saved, failure)); }
catch (RuntimeException rejected) { auditFailed(operation, "scheduler_rejected_after_save_uncertain"); report(character, rejected); }
});
}
private void complete(UUID character, Heal operation, BigInteger expected, double nativeBefore,
private void complete(UUID character, Heal operation, BigInteger expected, CharacterRecord before, double nativeBefore,
java.util.Optional<CharacterRecord> saved, Throwable failure) {
try {
if (closed || failure != null || saved == null || saved.isEmpty()) {
auditFailed(operation, closed ? "closed_after_save" : failure != null ? "health_save_failed" : "character_unavailable");
if (failure != null) report(character, failure); return;
}
var record = saved.orElseThrow();
if (!sessions.current(operation.active())
|| !sessions.acknowledgeProgress(operation.active(), expected, record)) return;
|| !sessions.acknowledgeProgress(operation.active(), expected, record)) {
auditFailed(operation, "saved_but_session_publication_refused"); return;
}
var player = sessions.currentPlayer(operation.active()).orElse(null);
if (player == null || Double.compare(player.getHealth(), nativeBefore) != 0) return;
if (player == null || Double.compare(player.getHealth(), nativeBefore) != 0) {
auditFailed(operation, "saved_but_native_health_changed"); return;
}
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
if (attribute == null) return;
if (attribute == null) { auditFailed(operation, "saved_but_maximum_health_unavailable"); return; }
double maximum = Math.min(attribute.getValue(), record.maximumHealth().doubleValue());
double publish = Math.min(maximum, record.health().doubleValue());
if (Double.isFinite(maximum) && Double.isFinite(publish) && publish > nativeBefore) {
player.setHealth(publish);
try { player.setHealth(publish); }
catch (RuntimeException nativeFailure) { auditFailed(operation, "native_health_publication_failed"); throw nativeFailure; }
if (events != null) events.healed(operation.audit(), before, record, nativeBefore, publish, maximum, operation.amount());
if (feedback != null) feedback.report("Health Steal", player, AbilityReport.Type.HEALING, player,
BigDecimal.valueOf(publish - nativeBefore).stripTrailingZeros());
}
} else auditFailed(operation, "saved_but_no_positive_native_heal");
} finally { finish(character); }
}
private void auditFailed(Heal operation, String reason) { if (events != null) events.failed(operation.audit(), reason); }
private void finish(UUID character) {
var queue = pending.get(character);
@@ -23,6 +23,7 @@ public final class LowLightVision implements AutoCloseable {
"Preserves unrelated Night Vision and does not change light, darkness or mob behavior."));
}
public void attachEvents(games.dmg.fantasystick.events.PassivePotionEventBridge bridge) { effect.attachEvents(bridge); }
public void install(org.bukkit.plugin.Plugin plugin) { effect.install(plugin); }
public void sweep() { effect.sweep(); }
@Override public void close() { effect.close(); }
@@ -1,6 +1,7 @@
package games.dmg.fantasystick.passive;
import games.dmg.fantasystick.session.EquipmentSessions;
import games.dmg.fantasystick.events.PassivePotionEventBridge;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -20,6 +21,8 @@ final class PersistentPotionEffect implements AutoCloseable {
PotionEffect displaced;
long displacedUntil;
long refreshedAt;
long applications;
PassivePotionEventBridge.Application application;
State(EquipmentSessions.Active active, Player player) { this.active = active; this.player = player; }
void refresh() {
@@ -28,8 +31,11 @@ final class PersistentPotionEffect implements AutoCloseable {
PotionEffect current;
try { current = player.getPotionEffect(type); }
catch (RuntimeException unavailable) { return; }
if (owned != null && !same(current, owned)) { owned = null; displaced = null; }
if (!eligible()) { remove(); return; }
if (owned != null && !same(current, owned)) {
endAudit("native_layer_replaced_or_removed", current == null ? "native_layer_absent" : "different_native_layer_preserved", current, false);
owned = null; displaced = null;
}
if (!eligible()) { remove(endReason()); return; }
if (current != null && (!replaceWeaker || current.getAmplifier() >= amplifier)) return;
var effect = new PotionEffect(type, PotionEffect.INFINITE_DURATION, amplifier, false, false, true);
try {
@@ -37,6 +43,14 @@ final class PersistentPotionEffect implements AutoCloseable {
owned = effect; displaced = current;
displacedUntil = current == null ? 0 : current.isInfinite()
? Long.MAX_VALUE : sweepGeneration + current.getDuration();
if (events != null) {
try {
var record = sessions.currentRecord(active).orElse(null);
if (record != null) application = events.applied(passive, player, active, record.name(), owned, current,
applications == 0 ? "learned_current_session" : "owned_layer_reestablished");
} catch (RuntimeException ignored) { /* Audit capture cannot discard native ownership. */ }
}
applications++;
}
} catch (RuntimeException unavailable) { owned = null; displaced = null; }
}
@@ -47,7 +61,21 @@ final class PersistentPotionEffect implements AutoCloseable {
return record != null && !record.dead() && record.passives().contains(passive);
}
void remove() {
String endReason() {
if (events == null) return "owner_no_longer_eligible";
try {
if (closed) return "plugin_shutdown";
if (!sessions.current(active)) return "session_no_longer_current";
var record = sessions.currentRecord(active).orElse(null);
if (record != null && record.dead()) return "character_defeated";
return "passive_unlearned_or_character_unavailable";
} catch (RuntimeException ignored) { return "owner_no_longer_eligible"; }
}
void endAudit(String reason, String disposition, PotionEffect current, boolean restored) {
if (events != null && application != null) events.ended(application, player, reason, disposition, current, restored);
application = null;
}
void remove(String reason) {
if (owned != null) {
PotionEffect current;
try { current = player.getPotionEffect(type); }
@@ -55,14 +83,21 @@ final class PersistentPotionEffect implements AutoCloseable {
if (same(current, owned)) {
try {
player.removePotionEffect(type);
boolean restored = false;
if (displaced != null && (displaced.isInfinite() || sweepGeneration < displacedUntil)) {
int duration = displaced.isInfinite() ? PotionEffect.INFINITE_DURATION
: Math.toIntExact(Math.min(Integer.MAX_VALUE, displacedUntil - sweepGeneration));
player.addPotionEffect(new PotionEffect(displaced.getType(), duration, displaced.getAmplifier(),
restored = player.addPotionEffect(new PotionEffect(displaced.getType(), duration, displaced.getAmplifier(),
displaced.isAmbient(), displaced.hasParticles(), displaced.hasIcon()), true);
}
if (events != null) {
try {
var after = player.getPotionEffect(type);
if (!same(after, owned)) endAudit(reason, "owned_layer_removed", after, restored);
} catch (RuntimeException unavailable) { /* No native acknowledgement: do not invent an end record. */ }
}
} catch (RuntimeException unavailable) { return; }
}
} else endAudit(reason, current == null ? "native_layer_absent" : "different_native_layer_preserved", current, false);
owned = null; displaced = null;
}
states.remove(active.player(), this);
@@ -80,6 +115,12 @@ final class PersistentPotionEffect implements AutoCloseable {
private long sweepGeneration;
private org.bukkit.scheduler.BukkitTask sweepTask;
private boolean closed;
private PassivePotionEventBridge events;
void attachEvents(PassivePotionEventBridge bridge) {
if (closed || events != null || !states.isEmpty()) throw new IllegalStateException("Passive potion events already attached or evaluation started");
events = Objects.requireNonNull(bridge);
}
PersistentPotionEffect(String passive, PotionEffectType type, int amplifier, boolean replaceWeaker,
EquipmentSessions sessions) {
@@ -119,7 +160,7 @@ final class PersistentPotionEffect implements AutoCloseable {
var state = states.get(active.player());
boolean learned = !snapshot.record().dead() && snapshot.record().passives().contains(passive);
if (!learned || !sessions.current(active)) { if (state != null) state.refresh(); return; }
if (state != null && !state.active.equals(active)) { state.remove(); state = states.get(active.player()); }
if (state != null && !state.active.equals(active)) { state.remove("owner_session_replaced"); state = states.get(active.player()); }
if (state == null) {
var player = sessions.currentPlayer(active).orElse(null);
if (player == null) return;
@@ -139,7 +180,7 @@ final class PersistentPotionEffect implements AutoCloseable {
if (closed) return;
closed = true;
if (sweepTask != null) sweepTask.cancel();
for (var state : List.copyOf(states.values())) state.remove();
for (var state : List.copyOf(states.values())) state.remove("plugin_shutdown");
states.clear();
}
}
@@ -23,6 +23,7 @@ public final class SpeedI implements AutoCloseable {
"Does not stack with equal Speed I and preserves stronger unrelated Speed effects."));
}
public void attachEvents(games.dmg.fantasystick.events.PassivePotionEventBridge bridge) { effect.attachEvents(bridge); }
public void install(org.bukkit.plugin.Plugin plugin) { effect.install(plugin); }
public void sweep() { effect.sweep(); }
@Override public void close() { effect.close(); }
@@ -194,6 +194,10 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
public CompletionStage<Void> acknowledgeEvent(UUID action) {
Objects.requireNonNull(action);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Player preferences closed"));
pending++;
}
try {
return CompletableFuture.runAsync(() -> {
try (var channel = FileChannel.open(directory.resolve(".player-preferences.lock"),
@@ -208,9 +212,12 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
synchronized (this) { pendingEvents.remove(action); }
} catch (IOException | OverlappingFileLockException failure) {
throw new java.util.concurrent.CompletionException(failure);
}
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException closedWorker) { return CompletableFuture.failedStage(closedWorker); }
} catch (RejectedExecutionException closedWorker) {
finished();
return CompletableFuture.failedStage(closedWorker);
}
}
private CompletionStage<Update> set(UUID account, Preference preference, boolean enabled) {
@@ -21,8 +21,8 @@ public final class YamlAdventureQuestRepository {
if(Files.notExists(file))return AdventureQuestState.empty();
try{
Object decoded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));var root=map(decoded);int schema=number(root.get("schema-version"));if(schema!=1&&schema!=2)throw new IllegalArgumentException();legacyDungeonRewardTerms=schema==1;
var sites=new ArrayList<AdventureQuestState.Site>();for(Object item:list(root.get("sites"))){var r=map(item);AdventureQuestState.Arena arena=null;if(r.containsKey("arena-shape")){var shape=AdventureQuestState.ArenaShape.valueOf(text(r.get("arena-shape")));int x=number(r.get("arena-x")),z=number(r.get("arena-z"));arena=new AdventureQuestState.Arena(shape,x,z);}AdventureQuestState.FishingDefinition fishing=null;if(r.get("fishing")!=null){var f=map(r.get("fishing"));fishing=new AdventureQuestState.FishingDefinition(uuid(f,"item-id"),text(f.get("item-name")),new BigInteger(text(f.get("required"))),new java.math.BigDecimal(text(f.get("chance-percent"))),new BigInteger(text(f.get("reward-xp"))),new BigInteger(text(f.get("reward-coins"))),number(f.get("radius")));}sites.add(new AdventureQuestState.Site(uuid(r,"id"),AdventureQuestState.SiteType.valueOf(text(r.get("type"))),text(r.get("name")),uuid(r,"world"),decimal(r,"x"),decimal(r,"y"),decimal(r,"z"),arena,r.containsKey("participation-mode")?AdventureQuestState.ParticipationMode.parse(text(r.get("participation-mode"))):AdventureQuestState.ParticipationMode.PARTY_AVAILABLE,fishing));}
var givers=new ArrayList<AdventureQuestState.Giver>();for(Object item:list(root.get("givers"))){var r=map(item);givers.add(new AdventureQuestState.Giver(uuid(r,"id"),text(r.get("name")),uuid(r,"world"),decimal(r,"x"),decimal(r,"y"),decimal(r,"z"),(float)decimal(r,"yaw"),(float)decimal(r,"pitch")));}
var sites=new ArrayList<AdventureQuestState.Site>();for(Object item:list(root.get("sites"))){var r=map(item);AdventureQuestState.Arena arena=null;if(r.containsKey("arena-shape")){var shape=AdventureQuestState.ArenaShape.valueOf(text(r.get("arena-shape")));int x=number(r.get("arena-x")),z=number(r.get("arena-z"));arena=new AdventureQuestState.Arena(shape,x,z);}AdventureQuestState.FishingDefinition fishing=null;if(r.get("fishing")!=null){var f=map(r.get("fishing"));fishing=new AdventureQuestState.FishingDefinition(uuid(f,"item-id"),text(f.get("item-name")),new BigInteger(text(f.get("required"))),new java.math.BigDecimal(text(f.get("chance-percent"))),new BigInteger(text(f.get("reward-xp"))),new BigInteger(text(f.get("reward-coins"))),number(f.get("radius")));}sites.add(new AdventureQuestState.Site(uuid(r,"id"),AdventureQuestState.SiteType.valueOf(text(r.get("type"))),text(r.get("name")),uuid(r,"world"),decimal(r,"x"),decimal(r,"y"),decimal(r,"z"),arena,r.containsKey("participation-mode")?AdventureQuestState.ParticipationMode.parse(text(r.get("participation-mode"))):AdventureQuestState.ParticipationMode.PARTY_AVAILABLE,fishing,r.containsKey("registration-revision")?uuid(r,"registration-revision"):null));}
var givers=new ArrayList<AdventureQuestState.Giver>();for(Object item:list(root.get("givers"))){var r=map(item);givers.add(new AdventureQuestState.Giver(uuid(r,"id"),text(r.get("name")),uuid(r,"world"),decimal(r,"x"),decimal(r,"y"),decimal(r,"z"),(float)decimal(r,"yaw"),(float)decimal(r,"pitch"),r.containsKey("registration-revision")?uuid(r,"registration-revision"):null));}
AdventureQuestState.Catalog catalog=null;if(root.get("catalog")!=null){var r=map(root.get("catalog"));catalog=new AdventureQuestState.Catalog(LocalDate.parse(text(r.get("day"))),uuid(r,"revision"),Instant.parse(text(r.get("created-at"))),list(r.get("sites")).stream().map(value->UUID.fromString(text(value))).toList(),r.containsKey("closed")?list(r.get("closed")).stream().map(value->UUID.fromString(text(value))).collect(java.util.stream.Collectors.toSet()):Set.of());}
var quests=new ArrayList<AdventureQuestState.Quest>();for(Object item:list(root.get("quests"))){var r=map(item);quests.add(new AdventureQuestState.Quest(uuid(r,"id"),uuid(r,"catalog"),uuid(r,"site"),AdventureQuestState.UnitType.valueOf(text(r.get("unit-type"))),uuid(r,"unit"),list(r.get("snapshot")).stream().map(value->UUID.fromString(text(value))).collect(java.util.stream.Collectors.toSet()),Instant.parse(text(r.get("expires-at"))),new BigInteger(text(r.get("reward-xp"))),new BigInteger(text(r.get("reward-coins"))),r.containsKey("participation-mode")?AdventureQuestState.ParticipationMode.parse(text(r.get("participation-mode"))):AdventureQuestState.ParticipationMode.PARTY_AVAILABLE,r.get("fishing")==null?null:fishingTerms(map(r.get("fishing")))));}
var unitClaims=new HashSet<AdventureQuestState.UnitClaim>();for(Object item:list(root.get("unit-claims"))){var r=map(item);unitClaims.add(new AdventureQuestState.UnitClaim(uuid(r,"catalog"),uuid(r,"site"),AdventureQuestState.UnitType.valueOf(text(r.get("unit-type"))),uuid(r,"unit")));}
@@ -48,7 +48,7 @@ public final class YamlAdventureQuestRepository {
public void save(AdventureQuestState state)throws IOException{
Objects.requireNonNull(state);var root=new LinkedHashMap<String,Object>();root.put("schema-version",2);
root.put("sites",state.sites().stream().sorted(Comparator.comparing(AdventureQuestState.Site::id)).map(YamlAdventureQuestRepository::siteRow).toList());
root.put("givers",state.givers().stream().sorted(Comparator.comparing(AdventureQuestState.Giver::id)).map(v->row("id",v.id(),"name",v.name(),"world",v.world(),"x",v.x(),"y",v.y(),"z",v.z(),"yaw",v.yaw(),"pitch",v.pitch())).toList());
root.put("givers",state.givers().stream().sorted(Comparator.comparing(AdventureQuestState.Giver::id)).map(YamlAdventureQuestRepository::giverRow).toList());
if(state.catalog()!=null)root.put("catalog",row("day",state.catalog().day(),"revision",state.catalog().revision(),"created-at",state.catalog().createdAt(),"sites",state.catalog().sites(),"closed",state.catalog().closed().stream().sorted().toList()));else root.put("catalog",null);
root.put("quests",state.quests().stream().sorted(Comparator.comparing(AdventureQuestState.Quest::id)).map(v->row("id",v.id(),"catalog",v.catalog(),"site",v.site(),"unit-type",v.unitType(),"unit",v.unit(),"snapshot",v.snapshot().stream().sorted().toList(),"expires-at",v.expiresAt(),"reward-xp",v.rewardXp(),"reward-coins",v.rewardCoins(),"participation-mode",v.participationMode().value(),"fishing",v.fishing()==null?null:fishing(v.fishing()))).toList());
root.put("unit-claims",state.unitClaims().stream().sorted(Comparator.comparing(v->v.catalog().toString()+v.site()+v.unit())).map(v->row("catalog",v.catalog(),"site",v.site(),"unit-type",v.type(),"unit",v.unit())).toList());
@@ -59,7 +59,8 @@ public final class YamlAdventureQuestRepository {
root.put("reset",state.reset()==null?null:row("operation",state.reset().operation(),"generation",state.reset().generation(),"started-at",state.reset().startedAt()));
Files.createDirectories(file.getParent());Path temporary=Files.createTempFile(file.getParent(),"adventure-quests-",".tmp");try{byte[] bytes=yaml().dump(root).getBytes(StandardCharsets.UTF_8);try(var output=java.nio.channels.FileChannel.open(temporary,StandardOpenOption.WRITE)){var buffer=java.nio.ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}try{Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);}catch(AtomicMoveNotSupportedException unsupported){throw new IOException("Atomic adventure persistence unavailable",unsupported);}DurableFiles.forceDirectory(file.getParent());}finally{Files.deleteIfExists(temporary);}legacyDungeonRewardTerms=false;
}
private static Map<String,Object> siteRow(AdventureQuestState.Site site){var result=row("id",site.id(),"type",site.type(),"name",site.name(),"world",site.world(),"x",site.x(),"y",site.y(),"z",site.z(),"participation-mode",site.participationMode().value(),"fishing",site.fishing()==null?null:fishing(site.fishing().terms()));if(site.arena()!=null){result.put("arena-shape",encode(site.arena().shape()));result.put("arena-x",site.arena().xBlocks());result.put("arena-z",site.arena().zBlocks());}return result;}
private static Map<String,Object> siteRow(AdventureQuestState.Site site){var result=row("id",site.id(),"type",site.type(),"name",site.name(),"world",site.world(),"x",site.x(),"y",site.y(),"z",site.z(),"participation-mode",site.participationMode().value(),"fishing",site.fishing()==null?null:fishing(site.fishing().terms()));if(site.registrationRevision()!=null)result.put("registration-revision",site.registrationRevision().toString());if(site.arena()!=null){result.put("arena-shape",encode(site.arena().shape()));result.put("arena-x",site.arena().xBlocks());result.put("arena-z",site.arena().zBlocks());}return result;}
private static Map<String,Object> giverRow(AdventureQuestState.Giver giver){var result=row("id",giver.id(),"name",giver.name(),"world",giver.world(),"x",giver.x(),"y",giver.y(),"z",giver.z(),"yaw",giver.yaw(),"pitch",giver.pitch());if(giver.registrationRevision()!=null)result.put("registration-revision",giver.registrationRevision().toString());return result;}
private static Map<String,Object> fishing(AdventureQuestState.FishingTerms value){return row("item-id",value.itemId(),"item-name",value.itemName(),"required",value.required(),"chance-percent",value.chancePercent(),"reward-xp",value.rewardXp(),"reward-coins",value.rewardCoins(),"radius",value.radius());}
private static AdventureQuestState.FishingTerms fishingTerms(Map<?,?> value){return new AdventureQuestState.FishingTerms(uuid(value,"item-id"),text(value.get("item-name")),new BigInteger(text(value.get("required"))),new java.math.BigDecimal(text(value.get("chance-percent"))),new BigInteger(text(value.get("reward-xp"))),new BigInteger(text(value.get("reward-coins"))),number(value.get("radius")));}
private static Map<String,Object> row(Object...pairs){var row=new LinkedHashMap<String,Object>();for(int i=0;i<pairs.length;i+=2)row.put((String)pairs[i],encode(pairs[i+1]));return row;}
@@ -18,7 +18,7 @@ public final class YamlDungeonRepository {
public DungeonState load()throws IOException{
if(Files.notExists(file))return DungeonState.empty();
try{var root=map(yaml().load(Files.readString(file,StandardCharsets.UTF_8)));int schema=integer(root.get("schema-version"));if(schema!=1&&schema!=2)throw new IllegalArgumentException();var dungeons=new ArrayList<DungeonState.Dungeon>();for(var item:list(root.get("dungeons"))){var row=map(item);var rooms=new ArrayList<DungeonState.Room>();for(var roomItem:list(row.get("rooms"))){var room=map(roomItem);var mobs=new ArrayList<DungeonState.Spawn>();for(var mobItem:list(room.get("mobs"))){var mob=map(mobItem);mobs.add(new DungeonState.Spawn(uuid(mob,"id"),EntityType.valueOf(text(mob.get("type"))),text(mob.get("name")),position(map(mob.get("position")))));}var effects=new ArrayList<DungeonState.Effect>();for(var effectItem:list(room.get("effects"))){var effect=map(effectItem);effects.add(new DungeonState.Effect(uuid(effect,"id"),DungeonState.EffectTarget.valueOf(text(effect.get("target"))),text(effect.get("effect")),integer(effect.get("level")),bool(effect.get("particles"))));}DungeonState.Boss boss=null;if(room.get("boss")!=null){var value=map(room.get("boss"));DungeonState.CharacterBoss character=null;if(value.get("character")!=null){var custody=map(value.get("character"));DungeonState.Recovery recovery=null;if(custody.get("recovery")!=null){var pending=map(custody.get("recovery"));recovery=new DungeonState.Recovery(uuid(pending,"operation"),uuid(pending,"administrator"));}character=new DungeonState.CharacterBoss(uuid(custody,"operation"),CharacterYaml.decode(text(custody.get("record"))),new java.math.BigDecimal(text(custody.get("multiplier"))),custody.containsKey("prior-type")?EntityType.valueOf(text(custody.get("prior-type"))):EntityType.HUSK,custody.containsKey("prior-name")?text(custody.get("prior-name")):"",recovery);}boss=new DungeonState.Boss(EntityType.valueOf(text(value.get("type"))),text(value.get("name")),position(map(value.get("position"))),character);}rooms.add(new DungeonState.Room(uuid(room,"id"),text(room.get("name")),bounds(map(room.get("bounds"))),mobs,effects,new DungeonState.Reward(new BigInteger(text(room.get("reward-xp"))),new BigInteger(text(room.get("reward-coins")))),boss));}var mode=row.containsKey("participation-mode")?games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.parse(text(row.get("participation-mode"))):games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;var completion=schema==2?new DungeonState.Reward(new BigInteger(text(row.get("completion-reward-xp"))),new BigInteger(text(row.get("completion-reward-coins")))):rooms.stream().filter(room->room.boss()!=null).map(DungeonState.Room::reward).findFirst().orElse(DungeonState.Reward.empty());if(schema==1)for(int index=0;index<rooms.size();index++){var room=rooms.get(index);rooms.set(index,new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects(),DungeonState.Reward.empty(),room.boss()));}dungeons.add(new DungeonState.Dungeon(uuid(row,"id"),text(row.get("name")),uuid(row,"world"),position(map(row.get("entrance"))),bool(row.get("enabled")),uuid(row,"revision"),rooms,mode,completion,schema==1||bool(row.get("completion-reward-configured"))));}
var runs=new ArrayList<DungeonState.Run>();for(var item:list(root.get("runs"))){var row=map(item);var unitClaims=new HashSet<DungeonState.UnitClaim>();for(var value:list(row.get("unit-claims"))){var claim=map(value);unitClaims.add(new DungeonState.UnitClaim(uuid(claim,"room"),uuid(claim,"quest")));}var characterClaims=new HashSet<DungeonState.CharacterClaim>();for(var value:list(row.get("character-claims"))){var claim=map(value);characterClaims.add(new DungeonState.CharacterClaim(uuid(claim,"room"),uuid(claim,"character")));}runs.add(new DungeonState.Run(uuid(row,"dungeon"),uuid(row,"generation"),uuids(row.get("defeated-slots")),uuids(row.get("cleared-rooms")),unitClaims,characterClaims,bool(row.get("defeated"))));}
var runs=new ArrayList<DungeonState.Run>();for(var item:list(root.get("runs"))){var row=map(item);var unitClaims=new HashSet<DungeonState.UnitClaim>();for(var value:list(row.get("unit-claims"))){var claim=map(value);unitClaims.add(new DungeonState.UnitClaim(uuid(claim,"room"),uuid(claim,"quest")));}var characterClaims=new HashSet<DungeonState.CharacterClaim>();for(var value:list(row.get("character-claims"))){var claim=map(value);characterClaims.add(new DungeonState.CharacterClaim(uuid(claim,"room"),uuid(claim,"character")));}var audit=new LinkedHashMap<String,String>();if(row.get("completion-audit")!=null)for(var entry:map(row.get("completion-audit")).entrySet())audit.put(text(entry.getKey()),text(entry.getValue()));runs.add(new DungeonState.Run(uuid(row,"dungeon"),uuid(row,"generation"),uuids(row.get("defeated-slots")),uuids(row.get("cleared-rooms")),unitClaims,characterClaims,bool(row.get("defeated")),audit,row.containsKey("completion-published")&&bool(row.get("completion-published"))));}
return new DungeonState(dungeons,runs);
}catch(RuntimeException invalid){throw new IOException("Invalid dungeon file",invalid);}
}
@@ -26,7 +26,7 @@ public final class YamlDungeonRepository {
private static Map<String,Object> dungeon(DungeonState.Dungeon value){return row("id",value.id(),"name",value.name(),"world",value.world(),"entrance",position(value.entrance()),"enabled",value.enabled(),"revision",value.revision(),"participation-mode",value.participationMode().value(),"completion-reward-xp",value.completionReward().xp(),"completion-reward-coins",value.completionReward().coins(),"completion-reward-configured",value.rewardConfigured(),"rooms",value.rooms().stream().sorted(Comparator.comparing(DungeonState.Room::id)).map(YamlDungeonRepository::room).toList());}
private static Map<String,Object> room(DungeonState.Room value){return row("id",value.id(),"name",value.name(),"bounds",bounds(value.bounds()),"mobs",value.mobs().stream().sorted(Comparator.comparing(DungeonState.Spawn::id)).map(v->row("id",v.id(),"type",v.type(),"name",v.name(),"position",position(v.position()))).toList(),"effects",value.effects().stream().sorted(Comparator.comparing(DungeonState.Effect::id)).map(v->row("id",v.id(),"target",v.target(),"effect",v.effect(),"level",v.level(),"particles",v.particles())).toList(),"reward-xp",value.reward().xp(),"reward-coins",value.reward().coins(),"boss",value.boss()==null?null:row("type",value.boss().type(),"name",value.boss().name(),"position",position(value.boss().position()),"character",value.boss().character()==null?null:character(value.boss().character())));}
private static Map<String,Object> character(DungeonState.CharacterBoss value){return row("operation",value.operation(),"record",CharacterYaml.encode(value.character()),"multiplier",value.multiplier().toPlainString(),"prior-type",value.priorType(),"prior-name",value.priorName(),"recovery",value.recovery()==null?null:row("operation",value.recovery().operation(),"administrator",value.recovery().administrator()));}
private static Map<String,Object> run(DungeonState.Run value){return row("dungeon",value.dungeon(),"generation",value.generation(),"defeated-slots",value.defeatedSlots().stream().sorted().toList(),"cleared-rooms",value.clearedRooms().stream().sorted().toList(),"unit-claims",value.unitClaims().stream().sorted(Comparator.comparing(v->v.room().toString()+v.quest())).map(v->row("room",v.room(),"quest",v.quest())).toList(),"character-claims",value.characterClaims().stream().sorted(Comparator.comparing(v->v.room().toString()+v.character())).map(v->row("room",v.room(),"character",v.character())).toList(),"defeated",value.defeated());}
private static Map<String,Object> run(DungeonState.Run value){return row("dungeon",value.dungeon(),"generation",value.generation(),"defeated-slots",value.defeatedSlots().stream().sorted().toList(),"cleared-rooms",value.clearedRooms().stream().sorted().toList(),"unit-claims",value.unitClaims().stream().sorted(Comparator.comparing(v->v.room().toString()+v.quest())).map(v->row("room",v.room(),"quest",v.quest())).toList(),"character-claims",value.characterClaims().stream().sorted(Comparator.comparing(v->v.room().toString()+v.character())).map(v->row("room",v.room(),"character",v.character())).toList(),"defeated",value.defeated(),"completion-audit",new java.util.TreeMap<>(value.completionAudit()),"completion-published",value.completionPublished());}
private static Map<String,Object> position(DungeonState.Position value){return row("x",value.x(),"y",value.y(),"z",value.z(),"yaw",value.yaw(),"pitch",value.pitch());}
private static DungeonState.Position position(Map<?,?> value){return new DungeonState.Position(decimal(value.get("x")),decimal(value.get("y")),decimal(value.get("z")),(float)decimal(value.get("yaw")),(float)decimal(value.get("pitch")));}
private static Map<String,Object> bounds(DungeonState.Bounds value){return row("min-x",value.minX(),"min-y",value.minY(),"min-z",value.minZ(),"max-x",value.maxX(),"max-y",value.maxY(),"max-z",value.maxZ());}
@@ -3,7 +3,7 @@ import games.dmg.fantasystick.shop.FantasyArmorer;import java.io.*;import java.n
/** Forced atomic desired-state adapter for Fantasy Armorer registrations. */
public final class YamlFantasyArmorerRepository{
private final Path file;public YamlFantasyArmorerRepository(Path data){file=data.toAbsolutePath().resolve("fantasy-armorers.yml");}
public List<FantasyArmorer> load()throws IOException{if(Files.notExists(file))return List.of();try{Object decoded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));if(!(decoded instanceof Map<?,?> root)||Integer.parseInt(root.get("schema-version").toString())!=1)throw new IllegalArgumentException();var result=new ArrayList<FantasyArmorer>();for(Object value:list(root.get("shops"))){var row=map(value);result.add(new FantasyArmorer(UUID.fromString(text(row,"shop-id")),text(row,"name"),UUID.fromString(text(row,"world")),Double.parseDouble(text(row,"x")),Double.parseDouble(text(row,"y")),Double.parseDouble(text(row,"z")),Float.parseFloat(text(row,"yaw")),Float.parseFloat(text(row,"pitch"))));}validate(result);return List.copyOf(result);}catch(RuntimeException invalid){throw new IOException("Invalid Fantasy Armorer file",invalid);}}
public void save(List<FantasyArmorer> values)throws IOException{validate(values);Map<String,Object> root=new LinkedHashMap<>();root.put("schema-version",1);root.put("shops",values.stream().sorted(Comparator.comparing(FantasyArmorer::id)).map(value->{Map<String,Object> row=new LinkedHashMap<>();row.put("shop-id",value.id().toString());row.put("name",value.name());row.put("world",value.world().toString());row.put("x",Double.toString(value.x()));row.put("y",Double.toString(value.y()));row.put("z",Double.toString(value.z()));row.put("yaw",Float.toString(value.yaw()));row.put("pitch",Float.toString(value.pitch()));return row;}).toList());Files.createDirectories(file.getParent());Path temporary=Files.createTempFile(file.getParent(),"armorers-",".tmp");try{byte[] bytes=yaml().dump(root).getBytes(StandardCharsets.UTF_8);try(var output=java.nio.channels.FileChannel.open(temporary,StandardOpenOption.WRITE)){var buffer=java.nio.ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}try{Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);}catch(AtomicMoveNotSupportedException unsupported){throw new IOException("Atomic Armorer persistence unavailable",unsupported);}DurableFiles.forceDirectory(file.getParent());}finally{Files.deleteIfExists(temporary);}}
public List<FantasyArmorer> load()throws IOException{if(Files.notExists(file))return List.of();try{Object decoded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));if(!(decoded instanceof Map<?,?> root)||Integer.parseInt(root.get("schema-version").toString())!=1)throw new IllegalArgumentException();var result=new ArrayList<FantasyArmorer>();for(Object value:list(root.get("shops"))){var row=map(value);result.add(new FantasyArmorer(UUID.fromString(text(row,"shop-id")),text(row,"name"),UUID.fromString(text(row,"world")),Double.parseDouble(text(row,"x")),Double.parseDouble(text(row,"y")),Double.parseDouble(text(row,"z")),Float.parseFloat(text(row,"yaw")),Float.parseFloat(text(row,"pitch")),row.containsKey("registration-revision")?UUID.fromString(text(row,"registration-revision")):null));}validate(result);return List.copyOf(result);}catch(RuntimeException invalid){throw new IOException("Invalid Fantasy Armorer file",invalid);}}
public void save(List<FantasyArmorer> values)throws IOException{validate(values);Map<String,Object> root=new LinkedHashMap<>();root.put("schema-version",1);root.put("shops",values.stream().sorted(Comparator.comparing(FantasyArmorer::id)).map(value->{Map<String,Object> row=new LinkedHashMap<>();row.put("shop-id",value.id().toString());if(value.revision()!=null)row.put("registration-revision",value.revision().toString());row.put("name",value.name());row.put("world",value.world().toString());row.put("x",Double.toString(value.x()));row.put("y",Double.toString(value.y()));row.put("z",Double.toString(value.z()));row.put("yaw",Float.toString(value.yaw()));row.put("pitch",Float.toString(value.pitch()));return row;}).toList());Files.createDirectories(file.getParent());Path temporary=Files.createTempFile(file.getParent(),"armorers-",".tmp");try{byte[] bytes=yaml().dump(root).getBytes(StandardCharsets.UTF_8);try(var output=java.nio.channels.FileChannel.open(temporary,StandardOpenOption.WRITE)){var buffer=java.nio.ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}try{Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);}catch(AtomicMoveNotSupportedException unsupported){throw new IOException("Atomic Armorer persistence unavailable",unsupported);}DurableFiles.forceDirectory(file.getParent());}finally{Files.deleteIfExists(temporary);}}
private static void validate(List<FantasyArmorer> values){var ids=new HashSet<UUID>();if(values.stream().anyMatch(value->!ids.add(value.id())))throw new IllegalArgumentException("Duplicate Armorer identity");}private static Yaml yaml(){var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(0);options.setCodePointLimit(1_048_576);return new Yaml(new SafeConstructor(options));}private static List<?> list(Object value){if(value instanceof List<?> result)return result;throw new IllegalArgumentException();}private static Map<?,?> map(Object value){if(value instanceof Map<?,?> result)return result;throw new IllegalArgumentException();}private static String text(Map<?,?> map,String key){Object value=map.get(key);if(value instanceof String text)return text;throw new IllegalArgumentException();}
}
@@ -39,7 +39,8 @@ public final class YamlTrainingDummyRepository {
var row = map(value);
result.add(new TrainingDummy(UUID.fromString(text(row.get("dummy-id"))), text(row.get("name")),
UUID.fromString(text(row.get("world"))), decimal(row.get("x")), decimal(row.get("y")),
decimal(row.get("z")), (float) decimal(row.get("yaw")), (float) decimal(row.get("pitch"))));
decimal(row.get("z")), (float) decimal(row.get("yaw")), (float) decimal(row.get("pitch")),
row.containsKey("registration-revision") ? UUID.fromString(text(row.get("registration-revision"))) : null));
}
validate(result); return List.copyOf(result);
} catch (RuntimeException invalid) {
@@ -53,6 +54,7 @@ public final class YamlTrainingDummyRepository {
root.put("dummies", dummies.stream().sorted(Comparator.comparing(TrainingDummy::id)).map(value -> {
Map<String, Object> row = new LinkedHashMap<>();
row.put("dummy-id", value.id().toString()); row.put("name", value.name());
if (value.revision() != null) row.put("registration-revision", value.revision().toString());
row.put("world", value.world().toString()); row.put("x", Double.toString(value.x()));
row.put("y", Double.toString(value.y())); row.put("z", Double.toString(value.z()));
row.put("yaw", Float.toString(value.yaw())); row.put("pitch", Float.toString(value.pitch())); return row;
@@ -81,9 +81,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.persistence.GearSettings gearSettings;
private games.dmg.fantasystick.persistence.CoinSettings coinSettings;
private games.dmg.fantasystick.events.CoinEventBridge coinEvents;
private games.dmg.fantasystick.events.ArmorEventBridge armorEvents;
private games.dmg.fantasystick.persistence.HardMobXpSettings hardMobXpSettings;
private games.dmg.fantasystick.events.HardMobXpEventBridge hardMobXpEvents;
private games.dmg.fantasystick.persistence.AdventureQuestSettings adventureQuestSettings;
private games.dmg.fantasystick.events.FishingEventBridge fishingEvents;
private games.dmg.fantasystick.events.CampEventBridge campEvents;
private games.dmg.fantasystick.adventure.AdventureQuestCommands adventureQuestCommands;
private games.dmg.fantasystick.dungeon.DungeonCommands dungeonCommands;
private games.dmg.fantasystick.party.CombatExperience combatExperience;
@@ -106,11 +109,13 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.events.PartyEventBridge partyEvents;
private games.dmg.fantasystick.training.TrainingDummyDirectory trainingDummies;
private games.dmg.fantasystick.training.TrainingDummyController trainingDummyController;
private games.dmg.fantasystick.events.TrainingEventBridge trainingEvents;
private games.dmg.fantasystick.shop.FantasyArmorerDirectory armorers;
private games.dmg.fantasystick.shop.FantasyArmorerController armorerController;
private games.dmg.fantasystick.shop.ArmorMarket armorMarket;
private games.dmg.fantasystick.shop.RareMerchantDirectory rareMerchants;
private games.dmg.fantasystick.shop.RareMerchantController rareMerchantController;
private games.dmg.fantasystick.events.RareEventBridge rareAuditEvents;
private games.dmg.fantasystick.shop.RareMarket rareMarket;
private games.dmg.fantasystick.persistence.RareSettings rareSettings;
private games.dmg.fantasystick.persistence.DownedSettings downedSettings;
@@ -360,6 +365,18 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
public void attachPartyEconomySettings(games.dmg.fantasystick.persistence.PartyEconomySettings settings){if(closed||partyEconomySettings!=null)throw new IllegalStateException("Party economy settings already attached or command closed");partyEconomySettings=Objects.requireNonNull(settings);}
public void attachGearSettings(games.dmg.fantasystick.persistence.GearSettings settings){if(closed||gearSettings!=null)throw new IllegalStateException("Gear settings already attached or command closed");gearSettings=Objects.requireNonNull(settings);}
public void attachCoinSettings(games.dmg.fantasystick.persistence.CoinSettings settings){if(closed||coinSettings!=null)throw new IllegalStateException("Coin settings already attached or command closed");coinSettings=Objects.requireNonNull(settings);}
public void attachTrainingEvents(games.dmg.fantasystick.events.TrainingEventBridge events) {
if (closed || trainingEvents != null) throw new IllegalStateException("Training events already attached or command closed");
trainingEvents = Objects.requireNonNull(events);
}
public void attachArmorEvents(games.dmg.fantasystick.events.ArmorEventBridge events) {
if (closed || armorEvents != null) throw new IllegalStateException("Armor events already attached or closed");
armorEvents = Objects.requireNonNull(events);
}
public void attachHardMobXpEvents(games.dmg.fantasystick.events.HardMobXpEventBridge events) {
if (closed || hardMobXpEvents != null) throw new IllegalStateException("Hard-mob XP events already attached or closed");
hardMobXpEvents = Objects.requireNonNull(events);
}
public void attachCoinEvents(games.dmg.fantasystick.events.CoinEventBridge events){if(closed||coinEvents!=null)throw new IllegalStateException("Coin events already attached or command closed");coinEvents=Objects.requireNonNull(events);}
public void attachAbilityConfigEvents(games.dmg.fantasystick.events.AbilityConfigEventBridge events){if(closed||abilityConfigEvents!=null)throw new IllegalStateException("Ability config events already attached or command closed");abilityConfigEvents=Objects.requireNonNull(events);}
public void attachHardMobXpSettings(games.dmg.fantasystick.persistence.HardMobXpSettings settings,
@@ -373,6 +390,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
adventureQuestCommands = Objects.requireNonNull(commands); adventureQuestSettings = Objects.requireNonNull(settings);
}
public void attachFishingEvents(games.dmg.fantasystick.events.FishingEventBridge value){fishingEvents=Objects.requireNonNull(value);}
public void attachCampEvents(games.dmg.fantasystick.events.CampEventBridge value){campEvents=Objects.requireNonNull(value);}
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.DungeonAuditEventBridge dungeonEvents;
public void attachDungeonEvents(games.dmg.fantasystick.events.DungeonAuditEventBridge value){dungeonEvents=Objects.requireNonNull(value);}
public void attachDungeons(games.dmg.fantasystick.dungeon.DungeonCommands commands){if(closed||dungeonCommands!=null)throw new IllegalStateException("Dungeons already attached or command closed");dungeonCommands=Objects.requireNonNull(commands);}
public void attachSheetResourcePack(games.dmg.fantasystick.persistence.SheetResourcePackSettings settings,games.dmg.fantasystick.presentation.SheetResourcePack delivery){if(closed||sheetResourcePackSettings!=null)throw new IllegalStateException("Sheet resource pack already attached or command closed");sheetResourcePackSettings=Objects.requireNonNull(settings);sheetResourcePack=Objects.requireNonNull(delivery);}
public void attachSheetResourcePackEvents(games.dmg.fantasystick.events.SheetResourcePackEventBridge events){
@@ -491,6 +513,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
public void attachArmorers(games.dmg.fantasystick.shop.FantasyArmorerDirectory directory,games.dmg.fantasystick.shop.FantasyArmorerController controller){if(closed||armorers!=null)throw new IllegalStateException("Armorers already attached or command closed");armorers=Objects.requireNonNull(directory);armorerController=Objects.requireNonNull(controller);}
public void attachArmorMarket(games.dmg.fantasystick.shop.ArmorMarket market){if(closed||armorMarket!=null)throw new IllegalStateException("Armor market already attached or command closed");armorMarket=Objects.requireNonNull(market);}
public void attachRareMarket(games.dmg.fantasystick.shop.RareMerchantDirectory directory,games.dmg.fantasystick.shop.RareMerchantController controller,games.dmg.fantasystick.shop.RareMarket market,games.dmg.fantasystick.persistence.RareSettings settings){if(closed||rareMerchants!=null)throw new IllegalStateException("Rare market already attached or command closed");rareMerchants=Objects.requireNonNull(directory);rareMerchantController=Objects.requireNonNull(controller);rareMarket=Objects.requireNonNull(market);rareSettings=Objects.requireNonNull(settings);}
public void attachRareEvents(games.dmg.fantasystick.events.RareEventBridge events){if(rareAuditEvents!=null)throw new IllegalStateException("Rare events already attached");rareAuditEvents=Objects.requireNonNull(events);}
public void attachDownedSettings(games.dmg.fantasystick.persistence.DownedSettings settings){if(closed||downedSettings!=null)throw new IllegalStateException("Downed settings already attached or command closed");downedSettings=Objects.requireNonNull(settings);}
public void attachDownedEvents(games.dmg.fantasystick.events.DownedEventBridge events){if(closed||downedEvents!=null)throw new IllegalStateException("Downed events already attached or command closed");downedEvents=Objects.requireNonNull(events);}
@@ -860,15 +883,79 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
else sender.sendMessage("Restocked all Fantasy Armorers with catalog "+catalog.revision()+"; the next automatic restock remains 00:00 UTC.");
});}catch(RuntimeException rejected){report(null,"shop-restock-response",rejected);}});return;}
if(args[1].equals("list")&&args.length==2){var values=armorers.all();if(values.isEmpty())sender.sendMessage("No Fantasy Armorers are registered.");else for(var value:values)sender.sendMessage(value.id()+" "+(value.name().isBlank()?"Fantasy Armorer":value.name())+" @ "+value.world()+" "+value.x()+" "+value.y()+" "+value.z());return;}
if(args[1].equals("place")&&sender instanceof org.bukkit.entity.Player player){String name=args.length<3?"":String.join(" ",java.util.Arrays.copyOfRange(args,2,args.length));var location=player.getLocation().clone();java.util.concurrent.CompletableFuture<games.dmg.fantasystick.shop.FantasyArmorer> operation;try{operation=armorers.place(name,location.getWorld().getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch());}catch(IllegalArgumentException invalid){sender.sendMessage("Fantasy Armorer name is invalid.");return;}operation.whenComplete((saved,failure)->placeShopResponse(player,location,saved,failure));return;}
if(args[1].equals("remove")&&args.length==3){UUID id;try{id=UUID.fromString(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Fantasy Armorer UUID is invalid.");return;}armorers.remove(id).whenComplete((removed,failure)->removeShopResponse(sender,id,removed,failure));return;}
if(args[1].equals("place")&&sender instanceof org.bukkit.entity.Player player){String name=args.length<3?"":String.join(" ",java.util.Arrays.copyOfRange(args,2,args.length));var location=player.getLocation().clone();var auditActor=armorEvents==null?null:armorEvents.actor(sender,sessions,location);var auditOperation=UUID.randomUUID();java.util.concurrent.CompletableFuture<games.dmg.fantasystick.shop.FantasyArmorer> operation;try{operation=armorers.place(name,location.getWorld().getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch());}catch(IllegalArgumentException invalid){sender.sendMessage("Fantasy Armorer name is invalid.");return;}operation.whenComplete((saved,failure)->placeShopResponse(player,location,auditOperation,auditActor,saved,failure));return;}
if(args[1].equals("remove")&&args.length==3){UUID id;try{id=UUID.fromString(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Fantasy Armorer UUID is invalid.");return;}var auditActor=armorEvents==null?null:armorEvents.actor(sender,sessions,null);var auditOperation=UUID.randomUUID();armorers.remove(id).whenComplete((removed,failure)->removeShopResponse(sender,id,auditOperation,auditActor,removed,failure));return;}
shopUsage(sender);
}
private static void shopUsage(CommandSender sender){sender.sendMessage("Usage: /fstickadmin shop <place [name...]|remove <shop-uuid>|list|restock>");}
private void placeShopResponse(org.bukkit.entity.Player player,org.bukkit.Location location,games.dmg.fantasystick.shop.FantasyArmorer saved,Throwable failure){if(failure!=null)report(player.getUniqueId(),"shop-place",failure);if(closed)return;nextTick.accept(()->{if(closed)return;if(failure!=null){if(player.isOnline())player.sendMessage("Fantasy Armorer placement failed; no registration or entity changed.");return;}try{int x=location.getBlockX()>>4,z=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(x,z))armorerController.reconcile(location.getWorld().getChunkAt(x,z));if(player.isOnline())player.sendMessage("Placed Fantasy Armorer "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"shop-entity",entityFailure);armorers.remove(saved.id()).whenComplete((ignored,rollback)->nextTick.accept(()->{armorerController.removeLoaded(saved.id());if(player.isOnline())player.sendMessage("Fantasy Armorer placement failed; no registration or entity changed.");}));}});}
private void removeShopResponse(CommandSender sender,UUID id,java.util.Optional<games.dmg.fantasystick.shop.FantasyArmorer> removed,Throwable failure){if(failure!=null)report(null,"shop-remove",failure);if(closed)return;nextTick.accept(()->{if(failure!=null)sender.sendMessage("Fantasy Armorer removal failed; registration and entity are unchanged.");else if(removed.isEmpty())sender.sendMessage("Fantasy Armorer not found.");else try{armorerController.removeLoaded(id);sender.sendMessage("Removed Fantasy Armorer "+id+".");}catch(RuntimeException entityFailure){var value=removed.orElseThrow();report(null,"shop-entity-remove",entityFailure);armorers.restore(value).whenComplete((restored,rollback)->{if(rollback!=null)report(null,"shop-remove-rollback",rollback);if(closed)return;nextTick.accept(()->{if(rollback==null){var world=server.getWorld(value.world());if(world!=null&&world.isChunkLoaded(((int)Math.floor(value.x()))>>4,((int)Math.floor(value.z()))>>4))armorerController.reconcile(world.getChunkAt(((int)Math.floor(value.x()))>>4,((int)Math.floor(value.z()))>>4));sender.sendMessage("Fantasy Armorer removal failed; registration and entity are unchanged.");}else sender.sendMessage("Fantasy Armorer removal rollback failed; inspect diagnostics.");});});}});}
private void placeShopResponse(org.bukkit.entity.Player player, org.bukkit.Location location, UUID operation,
games.dmg.fantasystick.events.ArmorEventBridge.Actor actor,
games.dmg.fantasystick.shop.FantasyArmorer saved, Throwable failure) {
if (failure != null) report(actor == null ? null : actor.playerId(), "shop-place", failure);
if (closed) return;
nextTick.accept(() -> {
if (closed) return;
if (failure != null) {
if (player.isOnline()) player.sendMessage("Fantasy Armorer placement failed; no registration or entity changed.");
return;
}
try {
int x = location.getBlockX() >> 4, z = location.getBlockZ() >> 4;
if (location.getWorld().isChunkLoaded(x, z)) armorerController.reconcile(location.getWorld().getChunkAt(x, z));
if (armorEvents != null) {
var observed = armorerController.observed(saved);
String outcome = !observed.quality().equals("native") ? "unavailable"
: !observed.ids().isEmpty() ? "materialized" : observed.chunkStatus().equals("unloaded") ? "unloaded" : "not_materialized";
armorEvents.armorerChanged("armorer.placed", operation, saved, actor, observed, outcome);
}
if (player.isOnline()) player.sendMessage("Placed Fantasy Armorer " + saved.id() + ".");
} catch (RuntimeException entityFailure) {
report(player.getUniqueId(), "shop-entity", entityFailure);
armorers.remove(saved.id()).whenComplete((ignored, rollback) -> nextTick.accept(() -> {
armorerController.removeLoaded(saved.id());
if (player.isOnline()) player.sendMessage("Fantasy Armorer placement failed; no registration or entity changed.");
}));
}
});
}
private void removeShopResponse(CommandSender sender, UUID id, UUID operation,
games.dmg.fantasystick.events.ArmorEventBridge.Actor actor,
java.util.Optional<games.dmg.fantasystick.shop.FantasyArmorer> removed, Throwable failure) {
if (failure != null) report(null, "shop-remove", failure);
if (closed) return;
nextTick.accept(() -> {
if (failure != null) sender.sendMessage("Fantasy Armorer removal failed; registration and entity are unchanged.");
else if (removed.isEmpty()) sender.sendMessage("Fantasy Armorer not found.");
else try {
var saved = removed.orElseThrow();
var before = armorEvents == null ? null : armorerController.observed(saved);
armorerController.removeLoaded(id);
if (armorEvents != null) {
var after = armorerController.observed(saved);
String outcome = !before.quality().equals("native") || !after.quality().equals("native") ? "unavailable"
: before.ids().isEmpty() ? "none_loaded" : before.ids().stream().anyMatch(after.ids()::contains) ? "not_removed" : "removed";
armorEvents.armorerChanged("armorer.removed", operation, saved, actor, before, outcome);
}
sender.sendMessage("Removed Fantasy Armorer " + id + ".");
} catch (RuntimeException entityFailure) {
var value = removed.orElseThrow(); report(null, "shop-entity-remove", entityFailure);
armorers.restore(value).whenComplete((restored, rollback) -> {
if (rollback != null) report(null, "shop-remove-rollback", rollback);
if (closed) return;
nextTick.accept(() -> {
if (rollback == null) {
var world = server.getWorld(value.world());
if (world != null && world.isChunkLoaded(((int) Math.floor(value.x())) >> 4, ((int) Math.floor(value.z())) >> 4))
armorerController.reconcile(world.getChunkAt(((int) Math.floor(value.x())) >> 4, ((int) Math.floor(value.z())) >> 4));
sender.sendMessage("Fantasy Armorer removal failed; registration and entity are unchanged.");
} else sender.sendMessage("Fantasy Armorer removal rollback failed; inspect diagnostics.");
});
});
}
});
}
private void rareShop(CommandSender sender,String[] args){if(rareMerchants==null||rareMerchantController==null||rareMarket==null||args.length<2){rareShopUsage(sender);return;}if(args[1].equals("restock")&&args.length==2){rareMarket.restock().whenComplete((catalog,failure)->{if(failure!=null)report(null,"rare-shop-restock",failure);if(closed)return;nextTick.accept(()->{if(closed||!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Restocked Rare Items catalog "+catalog.revision()+"; next automatic restock remains 00:00 UTC.":"Rare Items restock failed; prior catalog is unchanged.");});});return;}if(args[1].equals("list")&&args.length==2){var values=rareMerchants.all();if(values.isEmpty())sender.sendMessage("No Rare Items merchants are registered.");else for(var value:values)sender.sendMessage(value.id()+" "+(value.name().isBlank()?"Rare Items":value.name())+" @ "+value.world()+" "+value.x()+" "+value.y()+" "+value.z());return;}if(args[1].equals("place")&&sender instanceof org.bukkit.entity.Player player){String name=args.length<3?"":String.join(" ",java.util.Arrays.copyOfRange(args,2,args.length));var location=player.getLocation().clone();try{rareMerchants.place(name,location.getWorld().getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((saved,failure)->{if(failure!=null)report(player.getUniqueId(),"rare-shop-place",failure);if(closed)return;nextTick.accept(()->{if(closed||!player.hasPermission(PERMISSION)||!player.isOnline())return;if(failure!=null){player.sendMessage("Rare Items placement failed; prior state is unchanged.");return;}try{int x=location.getBlockX()>>4,z=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(x,z))rareMerchantController.reconcile(location.getWorld().getChunkAt(x,z));player.sendMessage("Placed Rare Items merchant "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"rare-shop-entity",entityFailure);rareMerchants.remove(saved.id());rareMerchantController.removeLoaded(saved.id());player.sendMessage("Rare Items placement failed; registration rollback was requested.");}});});}catch(IllegalArgumentException invalid){sender.sendMessage("Rare Items merchant name is invalid.");}return;}if(args[1].equals("remove")&&args.length==3){UUID id;try{id=UUID.fromString(args[2]);if(!id.toString().equals(args[2]))throw new IllegalArgumentException();}catch(IllegalArgumentException invalid){sender.sendMessage("Rare Items merchant UUID is invalid.");return;}rareMerchants.remove(id).whenComplete((removed,failure)->{if(failure!=null)report(null,"rare-shop-remove",failure);if(closed)return;nextTick.accept(()->{if(closed||!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Rare Items removal failed; registration is unchanged.");else if(removed.isEmpty())sender.sendMessage("Rare Items merchant not found.");else{rareMerchantController.removeLoaded(id);sender.sendMessage("Removed Rare Items merchant "+id+".");}});});return;}rareShopUsage(sender);}
private void rareShop(CommandSender sender,String[] args){if(rareMerchants==null||rareMerchantController==null||rareMarket==null||args.length<2){rareShopUsage(sender);return;}if(args[1].equals("restock")&&args.length==2){rareMarket.restock().whenComplete((catalog,failure)->{if(failure!=null)report(null,"rare-shop-restock",failure);if(closed)return;nextTick.accept(()->{if(closed||!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Restocked Rare Items catalog "+catalog.revision()+"; next automatic restock remains 00:00 UTC.":"Rare Items restock failed; prior catalog is unchanged.");});});return;}if(args[1].equals("list")&&args.length==2){var values=rareMerchants.all();if(values.isEmpty())sender.sendMessage("No Rare Items merchants are registered.");else for(var value:values)sender.sendMessage(value.id()+" "+(value.name().isBlank()?"Rare Items":value.name())+" @ "+value.world()+" "+value.x()+" "+value.y()+" "+value.z());return;}if(args[1].equals("place")&&sender instanceof org.bukkit.entity.Player player){String name=args.length<3?"":String.join(" ",java.util.Arrays.copyOfRange(args,2,args.length));var location=player.getLocation().clone();try{rareMerchants.place(name,location.getWorld().getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((saved,failure)->{if(failure!=null)report(player.getUniqueId(),"rare-shop-place",failure);if(closed)return;nextTick.accept(()->{if(closed||!player.hasPermission(PERMISSION)||!player.isOnline())return;if(failure!=null){player.sendMessage("Rare Items placement failed; prior state is unchanged.");return;}try{int x=location.getBlockX()>>4,z=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(x,z))rareMerchantController.reconcile(location.getWorld().getChunkAt(x,z));if(rareAuditEvents!=null)rareAuditEvents.shopPlaced(saved,player.getUniqueId(),player.getName());player.sendMessage("Placed Rare Items merchant "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"rare-shop-entity",entityFailure);rareMerchants.remove(saved.id());rareMerchantController.removeLoaded(saved.id());player.sendMessage("Rare Items placement failed; registration rollback was requested.");}});});}catch(IllegalArgumentException invalid){sender.sendMessage("Rare Items merchant name is invalid.");}return;}if(args[1].equals("remove")&&args.length==3){UUID id;try{id=UUID.fromString(args[2]);if(!id.toString().equals(args[2]))throw new IllegalArgumentException();}catch(IllegalArgumentException invalid){sender.sendMessage("Rare Items merchant UUID is invalid.");return;}rareMerchants.remove(id).whenComplete((removed,failure)->{if(failure!=null)report(null,"rare-shop-remove",failure);if(closed)return;nextTick.accept(()->{if(closed||!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Rare Items removal failed; registration is unchanged.");else if(removed.isEmpty())sender.sendMessage("Rare Items merchant not found.");else{rareMerchantController.removeLoaded(id);if(rareAuditEvents!=null)rareAuditEvents.shopRemoved(UUID.randomUUID(),removed.orElseThrow(),sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null,sender instanceof org.bukkit.entity.Player namedPlayer?namedPlayer.getName():null);sender.sendMessage("Removed Rare Items merchant "+id+".");}});});return;}rareShopUsage(sender);}
private static void rareShopUsage(CommandSender sender){sender.sendMessage("Usage: /fstickadmin rare-shop <place [name...]|remove <shop-uuid>|list|restock>");}
private void trainingDummy(CommandSender sender, String[] args) {
@@ -885,10 +972,13 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (args[1].equals("place") && sender instanceof org.bukkit.entity.Player player) {
String name = args.length < 3 ? "" : String.join(" ", java.util.Arrays.copyOfRange(args, 2, args.length));
var location = player.getLocation().clone();
var owner = player.getUniqueId();
var actor = trainingEvents == null ? null : trainingEvents.actor(sender, sessions, location);
var operation = UUID.randomUUID();
try {
trainingDummies.place(name, location.getWorld().getUID(), location.getX(), location.getY(),
location.getZ(), location.getYaw(), location.getPitch())
.whenComplete((saved, failure) -> placeDummyResponse(player, location, saved, failure));
.whenComplete((saved, failure) -> placeDummyResponse(player, owner, location, operation, actor, saved, failure));
} catch (IllegalArgumentException invalid) { sender.sendMessage("Training dummy name is invalid."); }
return;
}
@@ -896,7 +986,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
UUID id;
try { id = UUID.fromString(args[2]); }
catch (IllegalArgumentException invalid) { sender.sendMessage("Training dummy UUID is invalid."); return; }
trainingDummies.remove(id).whenComplete((removed, failure) -> removeDummyResponse(sender, id, removed, failure));
var actor = trainingEvents == null ? null : trainingEvents.actor(sender, sessions, null);
var operation = UUID.randomUUID();
trainingDummies.remove(id).whenComplete((removed, failure) -> removeDummyResponse(sender, id, operation, actor, removed, failure));
return;
}
trainingDummyUsage(sender);
@@ -906,9 +998,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
sender.sendMessage("Usage: /fstickadmin training-dummy <place [name...]|remove <dummy-uuid>|list>");
}
private void placeDummyResponse(org.bukkit.entity.Player player, org.bukkit.Location location,
private void placeDummyResponse(org.bukkit.entity.Player player, UUID owner, org.bukkit.Location location, UUID operation,
games.dmg.fantasystick.events.TrainingEventBridge.Actor actor,
games.dmg.fantasystick.training.TrainingDummy saved, Throwable failure) {
if (failure != null) report(player.getUniqueId(), "training-dummy-place", failure);
if (failure != null) report(owner, "training-dummy-place", failure);
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
@@ -918,7 +1011,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
}
if (!player.isOnline() || !player.hasPermission(PERMISSION)) {
trainingDummies.remove(saved.id()).whenComplete((ignored, rollbackFailure) -> {
if (rollbackFailure != null) report(player.getUniqueId(), "training-dummy-permission-rollback", rollbackFailure);
if (rollbackFailure != null) report(owner, "training-dummy-permission-rollback", rollbackFailure);
if (!closed) nextTick.accept(() -> {
trainingDummyController.removeLoaded(saved.id());
if (player.isOnline()) player.sendMessage("Training dummy placement was cancelled because permission changed.");
@@ -931,21 +1024,28 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (location.getWorld().isChunkLoaded(chunkX, chunkZ)) {
trainingDummyController.reconcile(location.getWorld().getChunkAt(chunkX, chunkZ));
}
if (trainingEvents != null) {
var observed = trainingDummyController.observed(saved);
String outcome = !observed.quality().equals("native") ? "unavailable"
: !observed.ids().isEmpty() ? "materialized" : observed.chunkStatus().equals("unloaded") ? "unloaded" : "not_materialized";
trainingEvents.registration("training_dummy.placed", operation, saved, actor, observed, outcome);
}
player.sendMessage("Placed training dummy " + saved.id() + ".");
} catch (RuntimeException entityFailure) {
report(player.getUniqueId(), "training-dummy-entity", entityFailure);
report(owner, "training-dummy-entity", entityFailure);
trainingDummies.remove(saved.id()).whenComplete((ignored, rollbackFailure) -> {
if (rollbackFailure != null) report(player.getUniqueId(), "training-dummy-rollback", rollbackFailure);
if (rollbackFailure != null) report(owner, "training-dummy-rollback", rollbackFailure);
if (!closed) nextTick.accept(() -> {
trainingDummyController.removeLoaded(saved.id());
if (player.isOnline()) player.sendMessage("Training dummy placement failed; no record or entity changed.");
});
});
}
}); } catch (RuntimeException rejected) { report(player.getUniqueId(), "training-dummy-response", rejected); }
}); } catch (RuntimeException rejected) { report(owner, "training-dummy-response", rejected); }
}
private void removeDummyResponse(CommandSender sender, UUID id,
private void removeDummyResponse(CommandSender sender, UUID id, UUID operation,
games.dmg.fantasystick.events.TrainingEventBridge.Actor actor,
java.util.Optional<games.dmg.fantasystick.training.TrainingDummy> removed, Throwable failure) {
if (failure != null) report(null, "training-dummy-remove", failure);
if (closed) return;
@@ -958,7 +1058,18 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
restoreDummy(sender, removed.orElseThrow(), "Training dummy removal was cancelled because permission changed.");
return;
}
try { trainingDummyController.removeLoaded(id); sender.sendMessage("Removed training dummy " + id + "."); }
try {
var saved = removed.orElseThrow();
var before = trainingEvents == null ? null : trainingDummyController.observed(saved);
trainingDummyController.removeLoaded(id);
if (trainingEvents != null) {
var after = trainingDummyController.observed(saved);
String outcome = !before.quality().equals("native") || !after.quality().equals("native") ? "unavailable"
: before.ids().isEmpty() ? "none_loaded" : before.ids().stream().anyMatch(after.ids()::contains) ? "not_removed" : "removed";
trainingEvents.registration("training_dummy.removed", operation, saved, actor, before, outcome);
}
sender.sendMessage("Removed training dummy " + id + ".");
}
catch (RuntimeException entityFailure) {
report(null, "training-dummy-entity", entityFailure);
restoreDummy(sender, removed.orElseThrow(), "Training dummy removal failed; record and entity are unchanged.");
@@ -1280,9 +1391,15 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
}
private void quickerRegenerationIIConfiguration(CommandSender sender, String[] args) {
String key = games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY;
String before = quickerRegenerationIISettings.value();
String actorKind = sender instanceof org.bukkit.entity.Player ? "player"
: sender instanceof org.bukkit.command.BlockCommandSender ? "command_block" : "console";
UUID actorPlayer = sender instanceof org.bukkit.entity.Player player ? player.getUniqueId() : null;
String actorName = sender instanceof org.bukkit.entity.Player ? sender.getName() : null;
if (args.length == 2) {
sender.sendMessage(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY + "="
+ quickerRegenerationIISettings.value() + " (applies to future recovery pulses only)"); return;
if (passiveEvents != null) passiveEvents.configRead(actorKind, actorPlayer, actorName, key, before);
sender.sendMessage(key + "=" + before + " (applies to future recovery pulses only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.Update> operation;
try { operation = quickerRegenerationIISettings.set(args[2]); }
@@ -1295,7 +1412,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed) return; boolean applied = false;
if (failure == null) {
applied = quickerRegenerationIISettings.publish(saved);
if (applied) { quickerRegenerationII.configure(saved.settings()); passiveRegistry.replace(quickerRegenerationII.definition()); }
if (applied) {
quickerRegenerationII.configure(saved.settings()); passiveRegistry.replace(quickerRegenerationII.definition());
if (passiveEvents != null) passiveEvents.configChanged(actorKind, actorPlayer, actorName, key, before, saved.value());
}
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
@@ -1309,9 +1429,15 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
}
private void healthStealConfiguration(CommandSender sender, String[] args) {
String key = games.dmg.fantasystick.persistence.HealthStealSettings.KEY;
String before = healthStealSettings.value();
String actorKind = sender instanceof org.bukkit.entity.Player ? "player"
: sender instanceof org.bukkit.command.BlockCommandSender ? "command_block" : "console";
UUID actorPlayer = sender instanceof org.bukkit.entity.Player player ? player.getUniqueId() : null;
String actorName = sender instanceof org.bukkit.entity.Player ? sender.getName() : null;
if (args.length == 2) {
sender.sendMessage(games.dmg.fantasystick.persistence.HealthStealSettings.KEY + "="
+ healthStealSettings.value() + " (applies to later qualifying final blows only)"); return;
if (passiveEvents != null) passiveEvents.configRead(actorKind, actorPlayer, actorName, key, before, "future_qualifying_defeats");
sender.sendMessage(key + "=" + before + " (applies to later qualifying final blows only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.HealthStealSettings.Update> operation;
try { operation = healthStealSettings.set(args[2]); }
@@ -1324,7 +1450,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed) return; boolean applied = false;
if (failure == null) {
applied = healthStealSettings.publish(saved);
if (applied) { healthSteal.configure(saved.settings()); passiveRegistry.replace(healthSteal.definition()); }
if (applied) {
healthSteal.configure(saved.settings()); passiveRegistry.replace(healthSteal.definition());
if (passiveEvents != null) passiveEvents.configChanged(actorKind, actorPlayer, actorName, key, before, saved.value(), "future_qualifying_defeats");
}
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
@@ -1898,16 +2027,59 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void partyEconomyConfiguration(CommandSender sender,String[] args){String key=args[1],before=partyEconomySettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;if(args.length==2){if(partyEvents!=null)partyEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+" (applies to future party transactions only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PartyEconomySettings.Update> operation;try{operation=partyEconomySettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Party price is invalid; enter a whole number from 0 through 2147483647.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=failure==null&&partyEconomySettings.publish(saved);if(applied&&partyEvents!=null)partyEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Party price save failed; the effective price is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+partyEconomySettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for future party transactions.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void gearConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+gearSettings.value(key)+" (applies to the next armor restock only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.GearSettings.Update> operation;try{operation=gearSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Armor market setting is invalid; variance is 0–100, prices are nonnegative, and positive stock minima cannot exceed maxima.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;nextTick.accept(()->{boolean applied=failure==null&&gearSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Armor market setting save failed; effective settings and the current catalog are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+gearSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for the next automatic or administrative restock; the current catalog is unchanged.");});});}
private void rareConfiguration(CommandSender sender,String[] args){String key=args[1],before=rareSettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;boolean phoenix=key.contains("phoenix-down");if(args.length==2){if(phoenix&&downedEvents!=null)downedEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+(key.endsWith("-healing-percent")||key.equals(games.dmg.fantasystick.persistence.RareSettings.PHOENIX_PROTECTION)?" (applies to future accepted uses)":" (applies to the next rare restock only)"));return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RareSettings.Update> operation;try{operation=rareSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Rare setting is invalid; percentages are 0–100, values are nonnegative, and stock minima cannot exceed maxima.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;nextTick.accept(()->{boolean applied=failure==null&&rareSettings.publish(saved);if(applied&&phoenix&&downedEvents!=null)downedEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Rare setting save failed; prior effective state is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+rareSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+"; current catalog and accepted uses are unchanged.");});});}
private void gearConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) {
String value = gearSettings.value(key);
if (armorEvents != null) armorEvents.configRead(armorEvents.actor(sender, sessions, null), key, value);
sender.sendMessage(key + "=" + value + " (applies to the next armor restock only)");
return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.GearSettings.Update> operation;
try { operation = gearSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) {
sender.sendMessage("Armor market setting is invalid; variance is 0–100, prices are nonnegative, and positive stock minima cannot exceed maxima.");
return;
}
var actor = armorEvents == null ? null : armorEvents.actor(sender, sessions, null);
operation.whenComplete((saved, failure) -> {
if (failure != null) report(null, "config-" + key, failure);
if (closed) return;
nextTick.accept(() -> {
String before = gearSettings.value(key);
boolean applied = failure == null && gearSettings.publish(saved);
if (applied && armorEvents != null) armorEvents.configChanged(actor, key, before, saved.value());
if (!sender.hasPermission(PERMISSION) || sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
if (failure != null) sender.sendMessage("Armor market setting save failed; effective settings and the current catalog are unchanged.");
else if (!applied) sender.sendMessage("Saved request was superseded; effective " + key + "=" + gearSettings.value(key) + ".");
else sender.sendMessage("Saved " + key + "=" + saved.value() + " for the next automatic or administrative restock; the current catalog is unchanged.");
});
});
}
private static boolean potionConfig(String key){
if(key.equals("rare-price-variance-percent"))return true;
for(var product:games.dmg.fantasystick.shop.RareProduct.values())if(product.heals()){
var id=product.id();
if(key.equals("rare-price-"+id)||key.equals("rare-stock-"+id+"-min")
||key.equals("rare-stock-"+id+"-max")||key.equals("rare-"+id+"-healing-percent"))return true;
}
return false;
}
private void rareConfiguration(CommandSender sender,String[] args){String key=args[1],before=rareSettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;boolean phoenix=key.contains("phoenix-down");if(args.length==2){if(phoenix&&downedEvents!=null)downedEvents.configRead(actorKind,actorPlayer,actorName,key,before);else if((potionConfig(key)||key.endsWith("-reskill")||key.startsWith("rare-stock-reskill-")||key.endsWith("-magic-core")||key.startsWith("rare-stock-magic-core-"))&&rareAuditEvents!=null)rareAuditEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+(key.endsWith("-healing-percent")||key.equals(games.dmg.fantasystick.persistence.RareSettings.PHOENIX_PROTECTION)?" (applies to future accepted uses)":" (applies to the next rare restock only)"));return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RareSettings.Update> operation;try{operation=rareSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Rare setting is invalid; percentages are 0–100, values are nonnegative, and stock minima cannot exceed maxima.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;nextTick.accept(()->{boolean applied=failure==null&&rareSettings.publish(saved);if(applied&&phoenix&&downedEvents!=null)downedEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());else if(applied&&(potionConfig(key)||key.endsWith("-reskill")||key.startsWith("rare-stock-reskill-")||key.endsWith("-magic-core")||key.startsWith("rare-stock-magic-core-"))&&rareAuditEvents!=null)rareAuditEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Rare setting save failed; prior effective state is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+rareSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+"; current catalog and accepted uses are unchanged.");});});}
private void downedConfiguration(CommandSender sender,String[] args){String key=args[1],before=downedSettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;if(args.length==2){if(downedEvents!=null)downedEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+" (applies to future downed outcomes only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.DownedSettings.Update> operation;try{operation=downedSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Downed setting is invalid; timeout must be positive and protection may be zero, both at most 365 days.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;nextTick.accept(()->{boolean applied=failure==null&&downedSettings.publish(saved);if(applied&&downedEvents!=null)downedEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Downed setting save failed; prior effective state is unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+downedSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+"; existing deadlines are unchanged.");});});}
private void coinConfiguration(CommandSender sender,String[] args){String key=args[1],before=coinSettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;if(args.length==2){if(coinEvents!=null)coinEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+" (applies to future creation or defeats only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.CoinSettings.Update> operation;try{operation=coinSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Coin setting is invalid; ranges must be ordered whole numbers from 0 through 2147483647.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=failure==null&&coinSettings.publish(saved);if(applied&&coinEvents!=null)coinEvents.configChanged(actorKind,actorPlayer,actorName,key,before,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Coin configuration save failed; effective ranges are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+coinSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for future creation or defeats; existing balances and captured rewards are unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void coinConfiguration(CommandSender sender,String[] args){String key=args[1],before=coinSettings.value(key),actorKind=sender instanceof org.bukkit.entity.Player?"player":sender instanceof org.bukkit.command.BlockCommandSender?"command_block":"console";UUID actorPlayer=sender instanceof org.bukkit.entity.Player player?player.getUniqueId():null;String actorName=sender instanceof org.bukkit.entity.Player?sender.getName():null;if(args.length==2){if(coinEvents!=null)coinEvents.configRead(actorKind,actorPlayer,actorName,key,before);sender.sendMessage(key+"="+before+" (applies to future creation or defeats only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.CoinSettings.Update> operation;try{operation=coinSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Coin setting is invalid; ranges must be ordered whole numbers from 0 through 2147483647.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;String publishedBefore=coinSettings.value(key);boolean applied=failure==null&&coinSettings.publish(saved);if(applied&&coinEvents!=null)coinEvents.configChanged(actorKind,actorPlayer,actorName,key,publishedBefore,saved.value());if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Coin configuration save failed; effective ranges are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+coinSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for future creation or defeats; existing balances and captured rewards are unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void hardMobXpConfiguration(CommandSender sender, String[] args) {
String key = games.dmg.fantasystick.persistence.HardMobXpSettings.KEY;
if (args.length == 2) { sender.sendMessage(key + "=" + hardMobXpSettings.value()
+ " (applies to future uncaptured Witch defeats only)"); return; }
if (args.length == 2) {
String value = hardMobXpSettings.value();
if (hardMobXpEvents != null) hardMobXpEvents.read(hardMobXpEvents.actor(sender, sessions), value);
sender.sendMessage(key + "=" + value + " (applies to future uncaptured Witch defeats only)"); return;
}
var audit = hardMobXpEvents;
var actor = audit == null ? null : audit.actor(sender, sessions);
var operationId = UUID.randomUUID();
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.HardMobXpSettings.Update> operation;
try { operation = hardMobXpSettings.set(args[2]); }
catch (IllegalArgumentException invalid) {
@@ -1918,8 +2090,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
String before = hardMobXpSettings.value();
boolean applied = failure == null && hardMobXpSettings.publish(saved);
if (applied) combatExperience.configure(combatExperience.rules().withHardPool(saved.pool()));
if (applied) {
combatExperience.configure(combatExperience.rules().withHardPool(saved.pool()));
if (audit != null && !before.equals(saved.value())) audit.changed(actor, operationId, before, saved.value());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
if (failure != null) sender.sendMessage("Hard-mob XP save failed; the effective pool is unchanged.");
@@ -1937,9 +2113,17 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
||key.equals(games.dmg.fantasystick.persistence.AdventureQuestSettings.FISHING_CATCH_XP_MAX);
var audit=fishingKey?fishingEvents:null;
var actor=audit==null?null:audit.actor(sender);
var campAudit=games.dmg.fantasystick.events.CampEventBridge.ownsSetting(key)?campEvents:null;
var campActor=campAudit==null?null:campAudit.actor(sender,null);
var championAudit=games.dmg.fantasystick.events.ChampionEventBridge.ownsSetting(key)?championEvents:null;
var championActor=championAudit==null||campEvents==null?null:campEvents.actor(sender,null);
var dungeonAudit=key.equals(games.dmg.fantasystick.persistence.AdventureQuestSettings.DUNGEON_ROOM_LINK_DISTANCE)?dungeonEvents:null;
var dungeonActor=dungeonAudit==null?null:dungeonAudit.actor(sender);
if(args.length==2){
var effective=adventureQuestSettings.value(key);
if(audit!=null)audit.configRead(actor,key,effective,games.dmg.fantasystick.persistence.AdventureQuestSettings.defaultValue(key));
if(campAudit!=null)campAudit.configRead(campActor,key,effective,games.dmg.fantasystick.persistence.AdventureQuestSettings.defaultValue(key));
if(championAudit!=null)championAudit.configRead(championActor,key,effective,games.dmg.fantasystick.persistence.AdventureQuestSettings.defaultValue(key));
sender.sendMessage(key+"="+effective+" (applies to the owning later catalog, acceptance or encounter attempt)");return;
}
var operationId=UUID.randomUUID();
@@ -1952,6 +2136,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
var effective=adventureQuestSettings.value(key);
if(applied&&audit!=null&&!before.equals(effective))audit.configChanged(actor,operationId,key,before,effective,
games.dmg.fantasystick.persistence.AdventureQuestSettings.defaultValue(key));
if(applied&&campAudit!=null&&!before.equals(effective))campAudit.configChanged(campActor,operationId,key,before,effective,
games.dmg.fantasystick.persistence.AdventureQuestSettings.defaultValue(key));
if(applied&&championAudit!=null&&!before.equals(effective))championAudit.configChanged(championActor,operationId,key,before,effective,
games.dmg.fantasystick.persistence.AdventureQuestSettings.defaultValue(key));
if(applied&&dungeonAudit!=null&&!before.equals(effective))try{dungeonAudit.configChanged(dungeonActor,operationId,key,before,effective,games.dmg.fantasystick.persistence.AdventureQuestSettings.defaultValue(key));}catch(RuntimeException ignored){}
if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;
if(failure!=null)sender.sendMessage("Adventure quest configuration save failed; effective values are unchanged.");
else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+effective+".");
@@ -11,8 +11,16 @@ import java.util.function.Supplier;
/** Serialized durable authority for the one shared daily armor catalog. */
public final class ArmorMarket implements AutoCloseable {
public enum PurchaseStatus { APPLIED, SOLD_OUT, STALE, INSUFFICIENT, NOT_UPGRADE, UNAVAILABLE, FAILED }
public record PurchaseOutcome(PurchaseStatus status, Optional<CharacterRecord> record, ArmorCatalog catalog) {
public record PurchaseProof(UUID operation, java.math.BigInteger price, int stockBefore, int stockAfter) {}
public record PurchaseOutcome(PurchaseStatus status, Optional<CharacterRecord> record, ArmorCatalog catalog, PurchaseProof proof) {
public PurchaseOutcome { Objects.requireNonNull(status); Objects.requireNonNull(record); Objects.requireNonNull(catalog); }
public PurchaseOutcome(PurchaseStatus status, Optional<CharacterRecord> record, ArmorCatalog catalog) {
this(status, record, catalog, null);
}
}
private static PurchaseProof proof(ArmorPurchaseOperationStore.Plan plan, ArmorCatalog catalog) {
int after = catalog.offer(plan.product()).remaining();
return new PurchaseProof(plan.id(), plan.price(), after + 1, after);
}
private final ArmorCatalogRepository catalogs;
@@ -26,6 +34,8 @@ public final class ArmorMarket implements AutoCloseable {
private volatile ArmorCatalog snapshot;
private volatile boolean initialized;
private volatile Consumer<ArmorCatalog> listener=ignored->{};
private games.dmg.fantasystick.events.ArmorEventBridge auditEvents;
private ArmorCatalog newlyPublishedCatalog;
private boolean closed,operationsClosing;
private int pending;
private final CompletableFuture<Void> drained=new CompletableFuture<>();
@@ -40,6 +50,15 @@ public final class ArmorMarket implements AutoCloseable {
}
public void attachListener(Consumer<ArmorCatalog> listener){this.listener=Objects.requireNonNull(listener);}
public synchronized void attachEvents(games.dmg.fantasystick.events.ArmorEventBridge events) {
if (closed || auditEvents != null) throw new IllegalStateException("Armor market events already attached or closed");
auditEvents = Objects.requireNonNull(events);
if (newlyPublishedCatalog != null) auditEvents.catalogRefreshed(newlyPublishedCatalog);
}
private synchronized void refreshed(ArmorCatalog catalog) {
newlyPublishedCatalog = catalog;
if (auditEvents != null) auditEvents.catalogRefreshed(catalog);
}
public Optional<ArmorCatalog> snapshot(){return Optional.ofNullable(snapshot);}
public Instant now(){return clock.instant();}
public CompletionStage<Void> ready(){return readiness;}
@@ -114,7 +133,8 @@ public final class ArmorMarket implements AutoCloseable {
var pruned=catalog.prune(plan.id());persist(pruned,false);
var record=characters.find(plan.character()).join();
return new PurchaseOutcome(transaction.state()==ArmorCatalog.TransactionState.CONSUMED
?PurchaseStatus.APPLIED:PurchaseStatus.FAILED,record,pruned);
?PurchaseStatus.APPLIED:PurchaseStatus.FAILED,record,pruned,
transaction.state()==ArmorCatalog.TransactionState.CONSUMED ? proof(plan,pruned) : null);
}
CharacterStorage.GearPurchase purchase;
try {
@@ -133,7 +153,7 @@ public final class ArmorMarket implements AutoCloseable {
case STALE->PurchaseStatus.STALE;
case UNAVAILABLE->PurchaseStatus.UNAVAILABLE;
};
return new PurchaseOutcome(status,purchase.record(),catalog);
return new PurchaseOutcome(status,purchase.record(),catalog,consumed ? proof(plan,catalog) : null);
}
private ArmorCatalog ensureCurrent(ArmorCatalog.RefreshReason reason)throws IOException {
@@ -148,7 +168,10 @@ public final class ArmorMarket implements AutoCloseable {
}
private void persist(ArmorCatalog catalog,boolean notify)throws IOException {
catalogs.save(catalog);snapshot=catalog;if(notify)publish(catalog);
var before = snapshot;
catalogs.save(catalog);snapshot=catalog;
if (notify && (before == null || !before.revision().equals(catalog.revision()))) refreshed(catalog);
if(notify)publish(catalog);
}
private void publish(ArmorCatalog catalog){if(!initialized)return;try{listener.accept(catalog);}catch(RuntimeException ignored){}}
@@ -19,6 +19,11 @@ public final class ArmorShop {
private final ArmorMarket market;
private final Consumer<Runnable> nextTick;
private final Consumer<Throwable> failures;
private games.dmg.fantasystick.events.ArmorEventBridge events;
public void attachEvents(games.dmg.fantasystick.events.ArmorEventBridge events) {
if (this.events != null) throw new IllegalStateException("Armor shop events already attached");
this.events = Objects.requireNonNull(events);
}
public ArmorShop(FantasyArmorerDirectory shops,EquipmentSessions sessions,ArmorMarket market,
Consumer<Runnable> nextTick,Consumer<Throwable> failures){
@@ -52,6 +57,7 @@ public final class ArmorShop {
var context=context(player,shopId);
if(context.isEmpty()){held.close();result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}
var captured=context.orElseThrow();
var auditActor=events==null?null:events.actor(player,sessions,null);
market.purchase(captured.record().id(),captured.record().revision(),catalogRevision,product).whenComplete((purchase,failure)->{
if(failure!=null){report(failure);publishHeld(held,()->result.accept(new Outcome(Status.FAILED,Optional.empty())));return;}
publishHeld(held,()->{
@@ -64,8 +70,13 @@ public final class ArmorShop {
case NOT_UPGRADE->Status.NOT_UPGRADE;case STALE->Status.STALE;
case UNAVAILABLE,FAILED->Status.FAILED;
};
if(purchase.status()==ArmorMarket.PurchaseStatus.APPLIED&&purchase.record().isPresent())
sessions.acknowledgeProgress(captured.active(),captured.record().revision(),purchase.record().orElseThrow());
if(purchase.status()==ArmorMarket.PurchaseStatus.APPLIED&&purchase.record().isPresent()) {
var saved=purchase.record().orElseThrow();
boolean acknowledged=sessions.acknowledgeProgress(captured.active(),captured.record().revision(),saved);
if(acknowledged&&events!=null&&purchase.proof()!=null)
events.purchased(purchase.proof(),purchase.catalog().revision(),captured.shop(),product,
auditActor,captured.record(),saved);
}
result.accept(new Outcome(status,purchase.record()));
});
});
@@ -1,4 +1,4 @@
package games.dmg.fantasystick.shop;
import java.util.*;
/** Durable desired location for one administrator-managed Fantasy Armorer. */
public record FantasyArmorer(UUID id,String name,UUID world,double x,double y,double z,float yaw,float pitch){public FantasyArmorer{Objects.requireNonNull(id);Objects.requireNonNull(name);Objects.requireNonNull(world);name=name.strip();if(name.codePointCount(0,name.length())>64||name.codePoints().anyMatch(codePoint->{int type=Character.getType(codePoint);return type==Character.CONTROL||type==Character.FORMAT||type==Character.SURROGATE;})||!Double.isFinite(x)||!Double.isFinite(y)||!Double.isFinite(z)||!Float.isFinite(yaw)||!Float.isFinite(pitch))throw new IllegalArgumentException("Invalid Fantasy Armorer");}}
public record FantasyArmorer(UUID id,String name,UUID world,double x,double y,double z,float yaw,float pitch,UUID revision){public FantasyArmorer(UUID id,String name,UUID world,double x,double y,double z,float yaw,float pitch){this(id,name,world,x,y,z,yaw,pitch,null);}public FantasyArmorer{Objects.requireNonNull(id);Objects.requireNonNull(name);Objects.requireNonNull(world);name=name.strip();if(name.codePointCount(0,name.length())>64||name.codePoints().anyMatch(codePoint->{int type=Character.getType(codePoint);return type==Character.CONTROL||type==Character.FORMAT||type==Character.SURROGATE;})||!Double.isFinite(x)||!Double.isFinite(y)||!Double.isFinite(z)||!Float.isFinite(yaw)||!Float.isFinite(pitch))throw new IllegalArgumentException("Invalid Fantasy Armorer");}}
@@ -5,6 +5,15 @@ public final class FantasyArmorerController implements Listener,AutoCloseable{
private final Plugin plugin;private final FantasyArmorerDirectory directory;private final BiConsumer<Player,UUID> interactions;private final NamespacedKey idKey,typeKey;private final Set<Entity> relocating=Collections.newSetFromMap(new IdentityHashMap<>());private boolean closed;public FantasyArmorerController(Plugin plugin,FantasyArmorerDirectory directory,BiConsumer<Player,UUID> interactions){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.interactions=Objects.requireNonNull(interactions);idKey=new NamespacedKey(plugin,"fantasy_armorer_id");typeKey=new NamespacedKey(plugin,"managed_npc_type");}
public void install(){for(var world:plugin.getServer().getWorlds()){var chunks=world.getLoadedChunks();if(chunks!=null)for(var chunk:chunks)reconcile(chunk);}}public boolean tagged(Entity entity){return "fantasy_armorer".equals(entity.getPersistentDataContainer().get(typeKey,PersistentDataType.STRING));}
public void reconcile(Chunk chunk){if(closed)return;var desired=new HashMap<UUID,FantasyArmorer>();for(var value:directory.all())if(value.world().equals(chunk.getWorld().getUID())&&section(value.x())==chunk.getX()&&section(value.z())==chunk.getZ())desired.put(value.id(),value);var retained=new HashSet<UUID>();for(var entity:chunk.getEntities()){if(!tagged(entity))continue;UUID id=id(entity);var target=id==null?null:desired.get(id);if(!(entity instanceof Villager villager)||target==null||!retained.add(id)){entity.remove();continue;}configure(villager,target);var current=villager.getLocation();var expected=location(target);if(current.distanceSquared(expected)>0.000001||Math.abs(current.getYaw()-expected.getYaw())>0.001||Math.abs(current.getPitch()-expected.getPitch())>0.001){relocating.add(villager);boolean moved;try{moved=villager.teleport(expected);}finally{relocating.remove(villager);}if(!moved){villager.remove();spawn(target);}}}for(var target:desired.values())if(!retained.contains(target.id()))spawn(target);}
public record Observed(String chunkStatus,List<UUID> ids,String quality){public Observed{ids=List.copyOf(ids);}}
public Observed observed(FantasyArmorer value){
try{var world=plugin.getServer().getWorld(value.world());if(world==null)return new Observed("unavailable",List.of(),"unavailable");
String chunk=world.isChunkLoaded(section(value.x()),section(value.z()))?"loaded":"unloaded";
var entities=world.getEntities();if(entities==null)return new Observed(chunk,List.of(),"unavailable");
var ids=new ArrayList<UUID>();for(var entity:entities)if(entity instanceof Villager&&tagged(entity)&&value.id().equals(id(entity)))ids.add(entity.getUniqueId());
ids.sort(Comparator.comparing(UUID::toString));return new Observed(chunk,ids,"native");
}catch(RuntimeException unavailable){return new Observed("unavailable",List.of(),"unavailable");}
}
public void removeLoaded(UUID id){for(var world:plugin.getServer().getWorlds()){var entities=world.getEntities();if(entities!=null)for(var entity:entities)if(tagged(entity)&&id.equals(id(entity)))entity.remove();}}
private void spawn(FantasyArmorer value){var world=plugin.getServer().getWorld(value.world());if(world==null)return;int x=section(value.x()),z=section(value.z());if(world.isChunkLoaded(x,z))world.spawn(location(value),Villager.class,villager->configure(villager,value));}private void configure(Villager villager,FantasyArmorer value){var data=villager.getPersistentDataContainer();data.set(typeKey,PersistentDataType.STRING,"fantasy_armorer");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.name().isBlank()?"Fantasy Armorer":value.name()));villager.setCustomNameVisible(true);}private Location location(FantasyArmorer value){return new Location(plugin.getServer().getWorld(value.world()),value.x(),value.y(),value.z(),value.yaw(),value.pitch());}private UUID id(Entity entity){String value=entity.getPersistentDataContainer().get(idKey,PersistentDataType.STRING);try{return value==null?null:UUID.fromString(value);}catch(IllegalArgumentException invalid){return null;}}private static int section(double value){return((int)Math.floor(value))>>4;}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void interact(org.bukkit.event.player.PlayerInteractEntityEvent event){if(!tagged(event.getRightClicked()))return;event.setCancelled(true);if(event.getHand()!=org.bukkit.inventory.EquipmentSlot.HAND)return;UUID id=id(event.getRightClicked());if(id!=null&&directory.find(id).isPresent())interactions.accept(event.getPlayer(),id);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void damage(org.bukkit.event.entity.EntityDamageEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void combust(org.bukkit.event.entity.EntityCombustEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void transform(org.bukkit.event.entity.EntityTransformEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void breed(org.bukkit.event.entity.EntityBreedEvent event){if(tagged(event.getMother())||tagged(event.getFather()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void vehicle(org.bukkit.event.vehicle.VehicleEnterEvent event){if(tagged(event.getEntered()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void leash(org.bukkit.event.entity.PlayerLeashEntityEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void teleport(org.bukkit.event.entity.EntityTeleportEvent event){if(tagged(event.getEntity())&&!relocating.contains(event.getEntity()))event.setCancelled(true);}@EventHandler(priority=EventPriority.MONITOR)public void chunk(org.bukkit.event.world.ChunkLoadEvent event){reconcile(event.getChunk());}
@@ -3,7 +3,7 @@ import games.dmg.fantasystick.persistence.YamlFantasyArmorerRepository;import ja
/** Serialized durable Fantasy Armorer desired state. */
public final class FantasyArmorerDirectory implements AutoCloseable{
private final YamlFantasyArmorerRepository repository;private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-armorer-persistence"));private volatile List<FantasyArmorer> snapshot;private final Map<UUID,Integer> leases=new HashMap<>();private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();public FantasyArmorerDirectory(YamlFantasyArmorerRepository repository)throws IOException{this.repository=Objects.requireNonNull(repository);snapshot=repository.load();}public List<FantasyArmorer> all(){return snapshot;}public Optional<FantasyArmorer> find(UUID id){return snapshot.stream().filter(value->value.id().equals(id)).findFirst();}
public CompletableFuture<FantasyArmorer> place(String name,UUID world,double x,double y,double z,float yaw,float pitch){return submit(()->{synchronized(this){var value=new FantasyArmorer(UUID.randomUUID(),name,world,x,y,z,yaw,pitch);var next=new ArrayList<>(snapshot);next.add(value);repository.save(next);snapshot=List.copyOf(next);return value;}});}public CompletableFuture<Optional<FantasyArmorer>> remove(UUID id){return submit(()->{synchronized(this){var found=find(id);if(found.isEmpty())return Optional.empty();if(leases.getOrDefault(id,0)>0)throw new IOException("Fantasy Armorer has an active purchase");var next=snapshot.stream().filter(value->!value.id().equals(id)).toList();repository.save(next);snapshot=next;return found;}});}public CompletableFuture<FantasyArmorer> restore(FantasyArmorer value){return submit(()->{synchronized(this){var found=find(value.id());if(found.isPresent()){if(found.orElseThrow().equals(value))return value;throw new IOException("Armorer identity collision");}var next=new ArrayList<>(snapshot);next.add(value);repository.save(next);snapshot=List.copyOf(next);return value;}});}
public CompletableFuture<FantasyArmorer> place(String name,UUID world,double x,double y,double z,float yaw,float pitch){return submit(()->{synchronized(this){var value=new FantasyArmorer(UUID.randomUUID(),name,world,x,y,z,yaw,pitch,UUID.randomUUID());var next=new ArrayList<>(snapshot);next.add(value);repository.save(next);snapshot=List.copyOf(next);return value;}});}public CompletableFuture<Optional<FantasyArmorer>> remove(UUID id){return submit(()->{synchronized(this){var found=find(id);if(found.isEmpty())return Optional.empty();if(leases.getOrDefault(id,0)>0)throw new IOException("Fantasy Armorer has an active purchase");var next=snapshot.stream().filter(value->!value.id().equals(id)).toList();repository.save(next);snapshot=next;return found;}});}public CompletableFuture<FantasyArmorer> restore(FantasyArmorer value){return submit(()->{synchronized(this){var found=find(value.id());if(found.isPresent()){if(found.orElseThrow().equals(value))return value;throw new IOException("Armorer identity collision");}var next=new ArrayList<>(snapshot);next.add(value);repository.save(next);snapshot=List.copyOf(next);return value;}});}
public synchronized Optional<Lease> acquire(UUID id){if(closed||find(id).isEmpty())return Optional.empty();leases.merge(id,1,Integer::sum);return Optional.of(new Lease(id));}public final class Lease implements AutoCloseable{private final UUID id;private boolean released;private Lease(UUID id){this.id=id;}public FantasyArmorer shop(){return find(id).orElseThrow();}@Override public void close(){synchronized(FantasyArmorerDirectory.this){if(released)return;released=true;int remaining=leases.getOrDefault(id,0)-1;if(remaining<=0)leases.remove(id);else leases.put(id,remaining);}}}
private <T>CompletableFuture<T> submit(Callable<T> operation){var result=new CompletableFuture<T>();synchronized(this){if(closed){result.completeExceptionally(new IOException("Armorer directory closed"));return result;}pending++;}worker.execute(()->{try{result.complete(operation.call());}catch(Throwable failure){result.completeExceptionally(failure);}finally{finished();}});return result;}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);}
}
@@ -21,6 +21,12 @@ public final class RareMagicCoreService {
private final EquipmentSessions sessions;
private final Consumer<Runnable> nextTick;
private final Consumer<Throwable> failures;
private games.dmg.fantasystick.events.RareEventBridge events;
public void attachEvents(games.dmg.fantasystick.events.RareEventBridge events){
if(this.events!=null)throw new IllegalStateException("Magic Core events already attached");
this.events=Objects.requireNonNull(events);
}
public RareMagicCoreService(RareInventoryDirectory inventory, CharacterStorage characters,
EquipmentSessions sessions, Consumer<Runnable> nextTick,
@@ -38,22 +44,33 @@ public final class RareMagicCoreService {
BigInteger quantity=quantity(captured.record().id());
if(quantity.signum()<=0){result.accept(new Outcome(Status.NOT_AVAILABLE,Optional.of(captured.record())));return;}
UUID operation=UUID.randomUUID();
var actor=events==null?null:safeActor(player,captured);
inventory.reserveUse(captured.record().id(),RareProduct.MAGIC_CORE,operation,quantity).whenComplete((reservation,reserveFailure)->{
if(reserveFailure!=null){fail(reserveFailure,result);return;}
if(reservation.status()!=RareInventoryDirectory.UseStatus.RESERVED){publish(()->result.accept(new Outcome(reservation.status()==RareInventoryDirectory.UseStatus.NOT_AVAILABLE?Status.NOT_AVAILABLE:Status.STALE,Optional.empty())));return;}
characters.consumeMagicCore(captured.record().id(),captured.record().revision(),operation).whenComplete((credit,creditFailure)->complete(player,captured,operation,credit,creditFailure,result));
characters.consumeMagicCore(captured.record().id(),captured.record().revision(),operation).whenComplete((credit,creditFailure)->complete(player,captured,operation,quantity,actor,credit,creditFailure,result));
});
}
/** Resolve interrupted credits from authoritative character proof without publishing a second point. */
public void install(){for(var use:inventory.unresolvedUses()){if(!use.product().equals(RareProduct.MAGIC_CORE.id()))continue;characters.find(use.character()).whenComplete((record,failure)->{if(failure!=null){report(failure);return;}boolean applied=record.isPresent()&&record.orElseThrow().rewardReceipts().contains(use.operation());var work=applied?inventory.commitUse(use.operation()):inventory.refundUse(use.operation());work.exceptionally(problem->{report(problem);return RareInventoryDirectory.UseStatus.RESERVED;});});}}
private void complete(Player player,Context captured,UUID operation,CharacterStorage.MagicCore credit,Throwable failure,Consumer<Outcome> result){
private void complete(Player player,Context captured,UUID operation,BigInteger quantity,
games.dmg.fantasystick.events.EventInput.Context actor,CharacterStorage.MagicCore credit,Throwable failure,Consumer<Outcome> result){
if(failure!=null||credit==null){if(failure!=null)report(failure);refund(operation,()->result.accept(new Outcome(Status.FAILED,Optional.empty())));return;}
if(credit.status()!=CharacterStorage.MagicCoreStatus.APPLIED&&credit.status()!=CharacterStorage.MagicCoreStatus.DUPLICATE){var status=credit.status()==CharacterStorage.MagicCoreStatus.UNAVAILABLE?Status.NOT_AVAILABLE:Status.STALE;refund(operation,()->result.accept(new Outcome(status,credit.record())));return;}
publish(()->{boolean acknowledged=false;try{var current=context(player);acknowledged=current.isPresent()&&current.orElseThrow().active().session().equals(captured.active().session())&&(credit.status()==CharacterStorage.MagicCoreStatus.DUPLICATE||sessions.acknowledgeProgress(captured.active(),captured.record().revision(),credit.record().orElseThrow()));}catch(RuntimeException uncertain){report(uncertain);}boolean currentSession=acknowledged;inventory.commitUse(operation).whenComplete((ignored,commitFailure)->{if(commitFailure!=null){report(commitFailure);publish(()->result.accept(new Outcome(Status.FAILED,credit.record())));}else publish(()->result.accept(new Outcome(currentSession?Status.APPLIED:Status.STALE,credit.record())));});});
publish(()->{boolean acknowledged=false;try{var current=context(player);acknowledged=current.isPresent()&&current.orElseThrow().active().session().equals(captured.active().session())&&(credit.status()==CharacterStorage.MagicCoreStatus.DUPLICATE||sessions.acknowledgeProgress(captured.active(),captured.record().revision(),credit.record().orElseThrow()));}catch(RuntimeException uncertain){report(uncertain);}boolean currentSession=acknowledged;inventory.commitUse(operation).whenComplete((commitStatus,commitFailure)->{if(commitFailure!=null){report(commitFailure);publish(()->result.accept(new Outcome(Status.FAILED,credit.record())));}else {
if(currentSession&&actor!=null&&credit.status()==CharacterStorage.MagicCoreStatus.APPLIED
&&commitStatus==RareInventoryDirectory.UseStatus.COMMITTED)
events.magicCoreUsed(operation,actor,quantity,captured.record(),credit.record().orElseThrow());
publish(()->result.accept(new Outcome(currentSession?Status.APPLIED:Status.STALE,credit.record())));
}});});
}
private games.dmg.fantasystick.events.EventInput.Context safeActor(Player player,Context captured){
try{return events.potionContext(player,captured.record().id(),captured.record().name(),captured.active().session());}
catch(RuntimeException failure){report(failure);return null;}
}
private void refund(UUID operation,Runnable after){inventory.refundUse(operation).whenComplete((ignored,failure)->{if(failure!=null)report(failure);publish(after);});}
private Optional<Context> context(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);return record==null?Optional.empty():Optional.of(new Context(active,record));}
private void fail(Throwable failure,Consumer<Outcome> result){report(failure);publish(()->result.accept(new Outcome(Status.FAILED,Optional.empty())));}
@@ -14,17 +14,19 @@ public final class RareMarket implements AutoCloseable {
public enum PurchaseStatus{APPLIED,SOLD_OUT,STALE,INSUFFICIENT,UNAVAILABLE,FAILED}
public record PurchaseProof(UUID operation,RareProduct product,BigInteger price,int stockAfter){}
public record PurchaseOutcome(PurchaseStatus status,Optional<CharacterRecord> record,RareCatalog catalog,PurchaseProof proof){public PurchaseOutcome(PurchaseStatus status,Optional<CharacterRecord> record,RareCatalog catalog){this(status,record,catalog,null);}}
private final RareCatalogRepository catalogs;private final RarePurchaseOperationStore operations;private final CharacterStorage characters;private final RareInventoryDirectory inventory;private final Supplier<RareMarketRules> rules;private final Clock clock;private final RareCatalogGenerator.InclusiveRoll random;private final ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-rare-market"));private final CompletionStage<Void> readiness;private volatile RareCatalog snapshot;private volatile boolean initialized;private volatile Consumer<RareCatalog> listener=ignored->{};private boolean closed,operationsClosing;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
private final RareCatalogRepository catalogs;private final RarePurchaseOperationStore operations;private final CharacterStorage characters;private final RareInventoryDirectory inventory;private final Supplier<RareMarketRules> rules;private final Clock clock;private final RareCatalogGenerator.InclusiveRoll random;private final ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-rare-market"));private final CompletionStage<Void> readiness;private volatile RareCatalog snapshot;private Consumer<RareCatalog> listener=ignored->{};private final ArrayDeque<RareCatalog> pendingRefreshes=new ArrayDeque<>();private boolean listenerAttached,closed,operationsClosing;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
public RareMarket(RareCatalogRepository catalogs,RarePurchaseOperationStore operations,CharacterStorage characters,RareInventoryDirectory inventory,Supplier<RareMarketRules> rules,Clock clock,RareCatalogGenerator.InclusiveRoll random){this.catalogs=Objects.requireNonNull(catalogs);this.operations=Objects.requireNonNull(operations);this.characters=Objects.requireNonNull(characters);this.inventory=Objects.requireNonNull(inventory);this.rules=Objects.requireNonNull(rules);this.clock=Objects.requireNonNull(clock);this.random=Objects.requireNonNull(random);readiness=submit(()->{initialize();return null;});}
public void attachListener(Consumer<RareCatalog> listener){this.listener=Objects.requireNonNull(listener);}public Optional<RareCatalog> snapshot(){return Optional.ofNullable(snapshot);}public Instant now(){return clock.instant();}public CompletionStage<Void> ready(){return readiness;}public CompletionStage<RareCatalog> current(){return readiness.thenCompose(ignored->submit(()->ensureCurrent(RareCatalog.RefreshReason.SCHEDULED)));}
public synchronized void attachListener(Consumer<RareCatalog> listener){this.listener=Objects.requireNonNull(listener);listenerAttached=true;while(!pendingRefreshes.isEmpty())deliver(pendingRefreshes.removeFirst());}public Optional<RareCatalog> snapshot(){return Optional.ofNullable(snapshot);}public Instant now(){return clock.instant();}public CompletionStage<Void> ready(){return readiness;}public CompletionStage<RareCatalog> current(){return readiness.thenCompose(ignored->submit(()->ensureCurrent(RareCatalog.RefreshReason.SCHEDULED)));}
public CompletionStage<RareCatalog> restock(){return readiness.thenCompose(ignored->submit(()->{var current=ensureCurrent(RareCatalog.RefreshReason.SCHEDULED);if(!current.transactions().isEmpty())throw new IOException("Rare purchases are unresolved");var generated=RareCatalogGenerator.generate(LocalDate.now(clock.withZone(ZoneOffset.UTC)),clock.instant().truncatedTo(java.time.temporal.ChronoUnit.SECONDS),RareCatalog.RefreshReason.ADMINISTRATIVE,rules.get(),random);persist(generated,true);return generated;}));}
public static Instant nextScheduledRestock(Instant now){return now.atZone(ZoneOffset.UTC).toLocalDate().plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();}
public CompletionStage<PurchaseOutcome> purchase(UUID character,BigInteger expectedCharacterRevision,UUID expectedCatalogRevision,RareProduct product){return readiness.thenCompose(ignored->submit(()->purchaseBlocking(character,expectedCharacterRevision,expectedCatalogRevision,product)));}
private void initialize()throws Exception{snapshot=catalogs.load().orElse(null);var plans=operations.load().toCompletableFuture().join();if(snapshot==null&&!plans.isEmpty())throw new IOException("Rare catalog missing for pending purchases");for(var plan:plans)execute(plan);if(snapshot!=null&&!snapshot.transactions().isEmpty()){var next=snapshot;for(var entry:snapshot.transactions().entrySet()){if(entry.getValue().state()==RareCatalog.TransactionState.RESERVED)throw new IOException("Orphan reserved rare purchase "+entry.getKey());next=next.prune(entry.getKey());}persist(next,false);}ensureCurrent(RareCatalog.RefreshReason.STARTUP);initialized=true;}
private void initialize()throws Exception{snapshot=catalogs.load().orElse(null);var plans=operations.load().toCompletableFuture().join();if(snapshot==null&&!plans.isEmpty())throw new IOException("Rare catalog missing for pending purchases");for(var plan:plans)execute(plan);if(snapshot!=null&&!snapshot.transactions().isEmpty()){var next=snapshot;for(var entry:snapshot.transactions().entrySet()){if(entry.getValue().state()==RareCatalog.TransactionState.RESERVED)throw new IOException("Orphan reserved rare purchase "+entry.getKey());next=next.prune(entry.getKey());}persist(next,false);}ensureCurrent(RareCatalog.RefreshReason.STARTUP);}
private PurchaseOutcome purchaseBlocking(UUID character,BigInteger expectedCharacterRevision,UUID expectedCatalogRevision,RareProduct product)throws Exception{var catalog=ensureCurrent(RareCatalog.RefreshReason.SCHEDULED);if(!catalog.transactions().isEmpty())return new PurchaseOutcome(PurchaseStatus.FAILED,Optional.empty(),catalog);if(!catalog.revision().equals(expectedCatalogRevision))return new PurchaseOutcome(PurchaseStatus.STALE,Optional.empty(),catalog);if(!catalog.available(product))return new PurchaseOutcome(PurchaseStatus.UNAVAILABLE,Optional.empty(),catalog);if(catalog.offer(product).remaining()==0)return new PurchaseOutcome(PurchaseStatus.SOLD_OUT,Optional.empty(),catalog);var plan=new RarePurchaseOperationStore.Plan(UUID.randomUUID(),character,expectedCharacterRevision,catalog.revision(),product,catalog.offer(product).price());operations.prepare(plan).toCompletableFuture().join();return execute(plan);}
private PurchaseOutcome execute(RarePurchaseOperationStore.Plan plan)throws Exception{var catalog=Objects.requireNonNull(snapshot,"Rare catalog unavailable");if(!catalog.revision().equals(plan.catalogRevision()))throw new IOException("Pending rare purchase belongs to another catalog");var transaction=catalog.transactions().get(plan.id());if(transaction==null){try{catalog=catalog.reserve(plan.id(),plan.product());}catch(IllegalStateException soldOut){operations.complete(plan.id()).toCompletableFuture().join();return new PurchaseOutcome(PurchaseStatus.SOLD_OUT,Optional.empty(),catalog);}persist(catalog,false);transaction=catalog.transactions().get(plan.id());}if(transaction.product()!=plan.product())throw new IOException("Rare reservation product mismatch");if(transaction.state()!=RareCatalog.TransactionState.RESERVED){if(transaction.state()==RareCatalog.TransactionState.CONSUMED)inventory.add(plan.character(),plan.product(),plan.id()).join();operations.complete(plan.id()).toCompletableFuture().join();var pruned=catalog.prune(plan.id());persist(pruned,false);return new PurchaseOutcome(transaction.state()==RareCatalog.TransactionState.CONSUMED?PurchaseStatus.APPLIED:PurchaseStatus.FAILED,characters.find(plan.character()).join(),pruned,transaction.state()==RareCatalog.TransactionState.CONSUMED?new PurchaseProof(plan.id(),plan.product(),plan.price(),pruned.offer(plan.product()).remaining()):null);}var purchase=characters.purchaseRare(plan.character(),plan.expectedCharacterRevision(),plan.id(),plan.price()).join();boolean consumed=purchase.status()==CharacterStorage.RarePurchaseStatus.APPLIED||purchase.status()==CharacterStorage.RarePurchaseStatus.DUPLICATE;if(consumed)inventory.add(plan.character(),plan.product(),plan.id()).join();catalog=catalog.settle(plan.id(),consumed);persist(catalog,consumed);operations.complete(plan.id()).toCompletableFuture().join();catalog=catalog.prune(plan.id());persist(catalog,false);var status=switch(purchase.status()){case APPLIED,DUPLICATE->PurchaseStatus.APPLIED;case INSUFFICIENT->PurchaseStatus.INSUFFICIENT;case STALE->PurchaseStatus.STALE;case UNAVAILABLE->PurchaseStatus.UNAVAILABLE;};return new PurchaseOutcome(status,purchase.record(),catalog,status==PurchaseStatus.APPLIED?new PurchaseProof(plan.id(),plan.product(),plan.price(),catalog.offer(plan.product()).remaining()):null);}
private RareCatalog ensureCurrent(RareCatalog.RefreshReason reason)throws IOException{var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));if(snapshot!=null&&snapshot.day().equals(today))return snapshot;if(snapshot!=null&&!snapshot.transactions().isEmpty())throw new IOException("Rare purchases unresolved at rollover");var generated=RareCatalogGenerator.generate(today,clock.instant().truncatedTo(java.time.temporal.ChronoUnit.SECONDS),reason,rules.get(),random);persist(generated,true);return generated;}
private void persist(RareCatalog catalog,boolean notify)throws IOException{catalogs.save(catalog);snapshot=catalog;if(notify&&initialized)try{listener.accept(catalog);}catch(RuntimeException ignored){}}
private void persist(RareCatalog catalog,boolean notify)throws IOException{catalogs.save(catalog);snapshot=catalog;if(notify)notifyRefresh(catalog);}
private synchronized void notifyRefresh(RareCatalog catalog){if(!listenerAttached)pendingRefreshes.addLast(catalog);else deliver(catalog);}
private void deliver(RareCatalog catalog){try{listener.accept(catalog);}catch(RuntimeException ignored){}}
private <T>CompletionStage<T> submit(Callable<T> action){synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Rare market closed"));pending++;}return CompletableFuture.supplyAsync(()->{try{return action.call();}catch(Exception failure){throw new CompletionException(failure);}finally{finished();}},worker).minimalCompletionStage();}
private synchronized void finished(){pending--;if(closed&&pending==0)closeOperations();}private void closeOperations(){if(operationsClosing)return;operationsClosing=true;operations.close();operations.drained().whenComplete((ignored,failure)->{if(failure==null)drained.complete(null);else drained.completeExceptionally(failure);});}public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)closeOperations();}
}
@@ -20,13 +20,18 @@ public final class RarePotionService {
public enum Status{APPLIED,FULL_HEALTH,NO_EFFECT,NOT_AVAILABLE,STALE,FAILED}
public record Outcome(Status status,BigDecimal healed){}
private record Context(EquipmentSessions.Active active,CharacterRecord record){}
private final RareInventoryDirectory inventory;private final RareSettings settings;private final CharacterStorage characters;private final EquipmentSessions sessions;private final Consumer<EntityRegainHealthEvent> healingEvents;private final Consumer<Runnable> nextTick;private final Consumer<Throwable> failures;
private final RareInventoryDirectory inventory;private final RareSettings settings;private final CharacterStorage characters;private final EquipmentSessions sessions;private final Consumer<EntityRegainHealthEvent> healingEvents;private final Consumer<Runnable> nextTick;private final Consumer<Throwable> failures;private games.dmg.fantasystick.events.RareEventBridge events;
public RarePotionService(RareInventoryDirectory inventory,RareSettings settings,CharacterStorage characters,EquipmentSessions sessions,Consumer<EntityRegainHealthEvent> healingEvents,Consumer<Runnable> nextTick,Consumer<Throwable> failures){this.inventory=Objects.requireNonNull(inventory);this.settings=Objects.requireNonNull(settings);this.characters=Objects.requireNonNull(characters);this.sessions=Objects.requireNonNull(sessions);this.healingEvents=Objects.requireNonNull(healingEvents);this.nextTick=Objects.requireNonNull(nextTick);this.failures=Objects.requireNonNull(failures);}
public void attachEvents(games.dmg.fantasystick.events.RareEventBridge events){if(this.events!=null)throw new IllegalStateException("Rare potion events already attached");this.events=Objects.requireNonNull(events);}
/** Ambiguous pre-restart native delivery is consumed but never replayed onto a replacement holder. */
public void install(){for(var use:inventory.unresolvedUses())if(RareProduct.find(use.product()).map(RareProduct::heals).orElse(false))inventory.commitUse(use.operation()).exceptionally(failure->{report(failure);return RareInventoryDirectory.UseStatus.RESERVED;});}
public BigInteger quantity(UUID character,RareProduct product){return inventory.quantity(character,product);}
public void use(Player player,RareProduct product,Consumer<Outcome> result){if(!product.heals()){result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO));return;}var context=context(player);if(context.isEmpty()){result.accept(new Outcome(Status.STALE,BigDecimal.ZERO));return;}var captured=context.orElseThrow();var attribute=player.getAttribute(Attribute.MAX_HEALTH);double current=player.getHealth(),maximum=attribute==null?Double.NaN:attribute.getValue();if(!Double.isFinite(current)||!Double.isFinite(maximum)||maximum<=0||current>=maximum){result.accept(new Outcome(Status.FULL_HEALTH,BigDecimal.ZERO));return;}int percent=settings.rules().healingPercent().get(product);double proposed=Math.min(maximum-current,maximum*percent/100d);if(!Double.isFinite(proposed)||proposed<=0){result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO));return;}var event=new EntityRegainHealthEvent(player,proposed,EntityRegainHealthEvent.RegainReason.CUSTOM);healingEvents.accept(event);double accepted=Math.min(proposed,Math.min(maximum-current,event.getAmount()));if(event.isCancelled()||!Double.isFinite(accepted)||accepted<=0){result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO));return;}UUID operation=UUID.randomUUID();BigInteger expectedQuantity=inventory.quantity(captured.record().id(),product);BigDecimal before=BigDecimal.valueOf(current),limit=BigDecimal.valueOf(maximum).min(NativeHealthLimit.read()),target=BigDecimal.valueOf(current+accepted).min(limit);inventory.reserveUse(captured.record().id(),product,operation,expectedQuantity,before,limit,percent).whenComplete((reservation,reserveFailure)->{if(reserveFailure!=null){report(reserveFailure);publish(()->result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO)));return;}if(reservation.status()!=RareInventoryDirectory.UseStatus.RESERVED){publish(()->result.accept(new Outcome(reservation.status()==RareInventoryDirectory.UseStatus.NOT_AVAILABLE?Status.NOT_AVAILABLE:Status.STALE,BigDecimal.ZERO)));return;}characters.saveHealth(captured.record().id(),captured.record().revision(),target,limit).whenComplete((saved,saveFailure)->publish(()->complete(player,captured,operation,accepted,saved,saveFailure,result)));});}
private void complete(Player player,Context captured,UUID operation,double accepted,Optional<CharacterRecord> saved,Throwable failure,Consumer<Outcome> result){if(failure!=null||saved==null||saved.isEmpty()){if(failure!=null)report(failure);inventory.refundUse(operation).whenComplete((ignored,refundFailure)->{if(refundFailure!=null)report(refundFailure);publish(()->result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO)));});return;}var persisted=saved.orElseThrow();var current=context(player);boolean acknowledged=false;try{acknowledged=current.isPresent()&&current.orElseThrow().active().session().equals(captured.active().session())&&sessions.acknowledgeProgress(captured.active(),captured.record().revision(),persisted);}catch(RuntimeException uncertain){report(uncertain);}if(!acknowledged){inventory.commitUse(operation).whenComplete((ignored,commitFailure)->{if(commitFailure!=null)report(commitFailure);publish(()->result.accept(new Outcome(Status.STALE,BigDecimal.ZERO)));});return;}var attribute=player.getAttribute(Attribute.MAX_HEALTH);double now=player.getHealth(),maximum=attribute==null?Double.NaN:attribute.getValue(),desired=Math.min(maximum,persisted.health().doubleValue()),restored=Math.min(accepted,desired-now);if(!Double.isFinite(now)||!Double.isFinite(maximum)||!Double.isFinite(restored)||restored<=0){inventory.refundUse(operation).whenComplete((ignored,refundFailure)->{if(refundFailure!=null)report(refundFailure);publish(()->result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO)));});return;}try{player.setHealth(Math.min(maximum,now+restored));}catch(RuntimeException uncertain){report(uncertain);inventory.commitUse(operation);result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO));return;}inventory.commitUse(operation).whenComplete((status,commitFailure)->{if(commitFailure!=null){report(commitFailure);publish(()->result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO)));return;}publish(()->result.accept(new Outcome(Status.APPLIED,BigDecimal.valueOf(restored).stripTrailingZeros())));});}
public void use(Player player,RareProduct product,Consumer<Outcome> result){if(!product.heals()){result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO));return;}var context=context(player);if(context.isEmpty()){result.accept(new Outcome(Status.STALE,BigDecimal.ZERO));return;}var captured=context.orElseThrow();var attribute=player.getAttribute(Attribute.MAX_HEALTH);double current=player.getHealth(),maximum=attribute==null?Double.NaN:attribute.getValue();if(!Double.isFinite(current)||!Double.isFinite(maximum)||maximum<=0||current>=maximum){result.accept(new Outcome(Status.FULL_HEALTH,BigDecimal.ZERO));return;}int percent=settings.rules().healingPercent().get(product);double proposed=Math.min(maximum-current,maximum*percent/100d);if(!Double.isFinite(proposed)||proposed<=0){result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO));return;}var event=new EntityRegainHealthEvent(player,proposed,EntityRegainHealthEvent.RegainReason.CUSTOM);healingEvents.accept(event);double accepted=Math.min(proposed,Math.min(maximum-current,event.getAmount()));if(event.isCancelled()||!Double.isFinite(accepted)||accepted<=0){result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO));return;}UUID operation=UUID.randomUUID();BigInteger expectedQuantity=inventory.quantity(captured.record().id(),product);BigDecimal before=BigDecimal.valueOf(current),limit=BigDecimal.valueOf(maximum).min(NativeHealthLimit.read()),target=BigDecimal.valueOf(current+accepted).min(limit);inventory.reserveUse(captured.record().id(),product,operation,expectedQuantity,before,limit,percent).whenComplete((reservation,reserveFailure)->{if(reserveFailure!=null){report(reserveFailure);publish(()->result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO)));return;}if(reservation.status()!=RareInventoryDirectory.UseStatus.RESERVED){publish(()->result.accept(new Outcome(reservation.status()==RareInventoryDirectory.UseStatus.NOT_AVAILABLE?Status.NOT_AVAILABLE:Status.STALE,BigDecimal.ZERO)));return;}characters.saveHealth(captured.record().id(),captured.record().revision(),target,limit).whenComplete((saved,saveFailure)->publish(()->complete(player,captured,operation,product,percent,expectedQuantity,accepted,saved,saveFailure,result)));});}
private void complete(Player player,Context captured,UUID operation,RareProduct product,int percent,BigInteger quantityBefore,double accepted,Optional<CharacterRecord> saved,Throwable failure,Consumer<Outcome> result){if(failure!=null||saved==null||saved.isEmpty()){if(failure!=null)report(failure);inventory.refundUse(operation).whenComplete((ignored,refundFailure)->{if(refundFailure!=null)report(refundFailure);publish(()->result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO)));});return;}var persisted=saved.orElseThrow();var current=context(player);boolean acknowledged=false;try{acknowledged=current.isPresent()&&current.orElseThrow().active().session().equals(captured.active().session())&&sessions.acknowledgeProgress(captured.active(),captured.record().revision(),persisted);}catch(RuntimeException uncertain){report(uncertain);}if(!acknowledged){inventory.commitUse(operation).whenComplete((ignored,commitFailure)->{if(commitFailure!=null)report(commitFailure);publish(()->result.accept(new Outcome(Status.STALE,BigDecimal.ZERO)));});return;}var attribute=player.getAttribute(Attribute.MAX_HEALTH);double now=player.getHealth(),maximum=attribute==null?Double.NaN:attribute.getValue(),desired=Math.min(maximum,persisted.health().doubleValue()),restored=Math.min(accepted,desired-now);if(!Double.isFinite(now)||!Double.isFinite(maximum)||!Double.isFinite(restored)||restored<=0){inventory.refundUse(operation).whenComplete((ignored,refundFailure)->{if(refundFailure!=null)report(refundFailure);publish(()->result.accept(new Outcome(Status.NO_EFFECT,BigDecimal.ZERO)));});return;}try{player.setHealth(Math.min(maximum,now+restored));}catch(RuntimeException uncertain){report(uncertain);inventory.commitUse(operation);result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO));return;}var audit=events;var actor=audit==null?null:safePotionContext(audit,player,captured);var nativeBefore=BigDecimal.valueOf(now);var nativeAfter=BigDecimal.valueOf(now+restored);var nativeMaximum=BigDecimal.valueOf(maximum);inventory.commitUse(operation).whenComplete((status,commitFailure)->{if(commitFailure!=null){report(commitFailure);publish(()->result.accept(new Outcome(Status.FAILED,BigDecimal.ZERO)));return;}if(status==RareInventoryDirectory.UseStatus.COMMITTED&&audit!=null&&actor!=null)audit.potionUsed(operation,product,actor,quantityBefore,percent,nativeBefore,nativeAfter,nativeMaximum,BigDecimal.valueOf(accepted),BigDecimal.valueOf(restored),captured.record().revision(),persisted.revision());publish(()->result.accept(new Outcome(Status.APPLIED,BigDecimal.valueOf(restored).stripTrailingZeros())));});}
private games.dmg.fantasystick.events.EventInput.Context safePotionContext(games.dmg.fantasystick.events.RareEventBridge audit,Player player,Context captured){
try{return audit.potionContext(player,captured.record().id(),captured.record().name(),captured.active().session());}
catch(RuntimeException failure){report(failure);return null;}
}
private Optional<Context> context(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);return record==null?Optional.empty():Optional.of(new Context(active,record));}
private void publish(Runnable action){try{nextTick.accept(action);}catch(RuntimeException failure){report(failure);}}private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}
}
@@ -18,6 +18,8 @@ public final class RareReskillService {
BigInteger quantity, BigInteger refund) { }
public record Outcome(Status status, Optional<CharacterRecord> record) { }
private record Context(EquipmentSessions.Active active, CharacterRecord record) { }
private record NativeAudit(games.dmg.fantasystick.events.EventInput.Context actor,
java.math.BigDecimal health, java.math.BigDecimal maximum) { }
private final RareInventoryDirectory inventory;
private final CharacterStorage characters;
@@ -25,6 +27,12 @@ public final class RareReskillService {
private final Consumer<Runnable> nextTick;
private final Consumer<CharacterRecord> cleanup;
private final Consumer<Throwable> failures;
private games.dmg.fantasystick.events.RareEventBridge events;
public void attachEvents(games.dmg.fantasystick.events.RareEventBridge events) {
if (this.events != null) throw new IllegalStateException("Reskill events already attached");
this.events = Objects.requireNonNull(events);
}
public RareReskillService(RareInventoryDirectory inventory, CharacterStorage characters,
EquipmentSessions sessions, Consumer<Runnable> nextTick,
@@ -42,35 +50,52 @@ public final class RareReskillService {
if (context == null) return Optional.empty();
var quantity = inventory.quantity(context.record().id(), RareProduct.RESKILL);
if (quantity.signum() <= 0) return Optional.empty();
return Optional.of(new Preview(UUID.randomUUID(), context.active(), context.record(), quantity,
refund(context.record())));
var preview = new Preview(UUID.randomUUID(), context.active(), context.record(), quantity,
refund(context.record()));
var captured = events == null ? null : nativeAudit(events, player, preview);
if (captured != null) {
events.reskillAction(preview.operation(), captured.actor(), "preview", "observed", "requested");
events.reskillAction(preview.operation(), captured.actor(), "preview",
preview.refund().signum() == 0 ? "refused" : "previewed",
preview.refund().signum() == 0 ? "nothing_to_reset" : "confirmation_required");
}
return Optional.of(preview);
}
public void confirm(Player player, Preview preview, Consumer<Outcome> result) {
Objects.requireNonNull(player); Objects.requireNonNull(preview); Objects.requireNonNull(result);
var captured = events == null ? null : nativeAudit(events, player, preview);
if (captured != null) events.reskillAction(preview.operation(), captured.actor(), "confirmation", "observed", "requested");
Consumer<Outcome> auditedResult = outcome -> {
if (captured != null && outcome.status() != Status.APPLIED)
events.reskillAction(preview.operation(), captured.actor(), "confirmation",
outcome.status() == Status.FAILED ? "failed" : "refused",
outcome.status().name().toLowerCase(java.util.Locale.ROOT));
result.accept(outcome);
};
var current = context(player).orElse(null);
if (current == null || !current.active().session().equals(preview.active().session())
|| !current.record().revision().equals(preview.record().revision())
|| !inventory.quantity(current.record().id(), RareProduct.RESKILL).equals(preview.quantity())) {
result.accept(new Outcome(Status.STALE, Optional.ofNullable(current).map(Context::record)));
auditedResult.accept(new Outcome(Status.STALE, Optional.ofNullable(current).map(Context::record)));
return;
}
if (preview.refund().signum() == 0) {
result.accept(new Outcome(Status.NOTHING_TO_RESET, Optional.of(current.record())));
auditedResult.accept(new Outcome(Status.NOTHING_TO_RESET, Optional.of(current.record())));
return;
}
var nativeLimit = NativeHealthLimit.read();
inventory.reserveUse(current.record().id(), RareProduct.RESKILL, preview.operation(), preview.quantity())
.whenComplete((reservation, reserveFailure) -> {
if (reserveFailure != null) { fail(reserveFailure, result); return; }
if (reserveFailure != null) { fail(reserveFailure, auditedResult); return; }
if (reservation.status() != RareInventoryDirectory.UseStatus.RESERVED) {
publish(() -> result.accept(new Outcome(
publish(() -> auditedResult.accept(new Outcome(
reservation.status() == RareInventoryDirectory.UseStatus.NOT_AVAILABLE
? Status.NOT_AVAILABLE : Status.STALE, Optional.empty())));
return;
}
characters.reskill(current.record().id(), current.record().revision(), preview.operation(), nativeLimit)
.whenComplete((reset, resetFailure) -> complete(player, preview, reset, resetFailure, result));
.whenComplete((reset, resetFailure) -> complete(player, preview, reset, resetFailure, auditedResult));
});
}
@@ -106,6 +131,8 @@ public final class RareReskillService {
return;
}
publish(() -> {
var audit = events;
var before = audit == null ? null : nativeAudit(audit, player, preview);
boolean acknowledged = false;
try {
var current = context(player);
@@ -116,13 +143,29 @@ public final class RareReskillService {
} catch (RuntimeException uncertain) { report(uncertain); }
if (acknowledged) try { cleanup.accept(preview.record()); } catch (RuntimeException uncertain) { report(uncertain); acknowledged=false; }
boolean currentSession = acknowledged;
inventory.commitUse(preview.operation()).whenComplete((ignored, commitFailure) -> {
var after = acknowledged && before != null ? nativeAudit(audit, player, preview) : null;
inventory.commitUse(preview.operation()).whenComplete((commitStatus, commitFailure) -> {
if (commitFailure != null) { report(commitFailure); publish(() -> result.accept(new Outcome(Status.FAILED, reset.record()))); }
else publish(() -> result.accept(new Outcome(currentSession ? Status.APPLIED : Status.STALE, reset.record())));
else {
if (currentSession && before != null && after != null
&& reset.status() == CharacterStorage.ReskillStatus.APPLIED
&& commitStatus == RareInventoryDirectory.UseStatus.COMMITTED)
audit.reskillUsed(preview.operation(), before.actor(), preview.quantity(), preview.record(),
reset.record().orElseThrow(), before.health(), after.health(), before.maximum(), after.maximum());
publish(() -> result.accept(new Outcome(currentSession ? Status.APPLIED : Status.STALE, reset.record())));
}
});
});
}
private NativeAudit nativeAudit(games.dmg.fantasystick.events.RareEventBridge audit, Player player, Preview preview) {
try {
return new NativeAudit(audit.potionContext(player, preview.record().id(), preview.record().name(),
preview.active().session()), java.math.BigDecimal.valueOf(player.getHealth()),
java.math.BigDecimal.valueOf(player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH).getValue()));
} catch (RuntimeException failure) { report(failure); return null; }
}
private void refund(UUID operation, Runnable after) {
inventory.refundUse(operation).whenComplete((ignored, failure) -> {
if (failure != null) report(failure);
@@ -20,7 +20,12 @@ public final class RareShop {
public void attachEvents(games.dmg.fantasystick.events.RareEventBridge events){if(this.events!=null)throw new IllegalStateException("Rare events already attached");this.events=Objects.requireNonNull(events);}
public Optional<CharacterRecord> active(Player player,UUID shop){return context(player,shop).map(Context::record);}public Optional<UUID> activeSession(Player player,UUID shop){return context(player,shop).map(value->value.active().session());}public Instant now(){return market.now();}
public void view(Player player,UUID shopId,Consumer<Optional<View>> result){var captured=context(player,shopId);if(captured.isEmpty()){result.accept(Optional.empty());return;}market.current().whenComplete((catalog,failure)->{if(failure!=null){report(failure);publish(()->result.accept(Optional.empty()));return;}publish(()->{var current=context(player,shopId);if(current.isEmpty()||!current.orElseThrow().active().session().equals(captured.orElseThrow().active().session()))result.accept(Optional.empty());else result.accept(Optional.of(new View(current.orElseThrow().record(),current.orElseThrow().active().session(),catalog)));});});}
public void buy(Player player,UUID shopId,UUID catalogRevision,RareProduct product,Consumer<Outcome> result){var lease=shops.acquire(shopId);if(lease.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var held=lease.orElseThrow();var context=context(player,shopId);if(context.isEmpty()){held.close();result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var captured=context.orElseThrow();market.purchase(captured.record().id(),captured.record().revision(),catalogRevision,product).whenComplete((purchase,failure)->{if(failure!=null){report(failure);publishHeld(held,()->result.accept(new Outcome(Status.FAILED,Optional.empty())));return;}publishHeld(held,()->{var current=context(player,shopId);if(current.isEmpty()||!current.orElseThrow().active().session().equals(captured.active().session())){result.accept(new Outcome(Status.STALE,purchase.record()));return;}var status=switch(purchase.status()){case APPLIED->Status.APPLIED;case SOLD_OUT->Status.SOLD_OUT;case INSUFFICIENT->Status.INSUFFICIENT;case STALE->Status.STALE;case UNAVAILABLE,FAILED->Status.FAILED;};if(purchase.status()==RareMarket.PurchaseStatus.APPLIED&&purchase.record().isPresent()){sessions.acknowledgeProgress(captured.active(),captured.record().revision(),purchase.record().orElseThrow());if(product==RareProduct.PHOENIX_DOWN&&purchase.proof()!=null&&events!=null){var after=inventory.quantity(captured.record().id(),product);events.purchased(purchase.proof().operation(),purchase.catalog().revision(),player.getUniqueId(),player.getName(),captured.record().id(),captured.record().name(),captured.active().session(),purchase.proof().price(),purchase.proof().stockAfter()+1,purchase.proof().stockAfter(),after.subtract(java.math.BigInteger.ONE),after);}}result.accept(new Outcome(status,purchase.record()));});});}
public void buy(Player player,UUID shopId,UUID catalogRevision,RareProduct product,Consumer<Outcome> result){var lease=shops.acquire(shopId);if(lease.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var held=lease.orElseThrow();var context=context(player,shopId);if(context.isEmpty()){held.close();result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var captured=context.orElseThrow();var auditLocation=(product==RareProduct.RESKILL||product==RareProduct.MAGIC_CORE)?purchaseLocation(player,captured):null;market.purchase(captured.record().id(),captured.record().revision(),catalogRevision,product).whenComplete((purchase,failure)->{if(failure!=null){report(failure);publishHeld(held,()->result.accept(new Outcome(Status.FAILED,Optional.empty())));return;}publishHeld(held,()->{var current=context(player,shopId);if(current.isEmpty()||!current.orElseThrow().active().session().equals(captured.active().session())){result.accept(new Outcome(Status.STALE,purchase.record()));return;}var status=switch(purchase.status()){case APPLIED->Status.APPLIED;case SOLD_OUT->Status.SOLD_OUT;case INSUFFICIENT->Status.INSUFFICIENT;case STALE->Status.STALE;case UNAVAILABLE,FAILED->Status.FAILED;};if(purchase.status()==RareMarket.PurchaseStatus.APPLIED&&purchase.record().isPresent()){sessions.acknowledgeProgress(captured.active(),captured.record().revision(),purchase.record().orElseThrow());if((product.heals()||product==RareProduct.PHOENIX_DOWN||product==RareProduct.RESKILL||product==RareProduct.MAGIC_CORE)&&purchase.proof()!=null&&events!=null){var after=inventory.quantity(captured.record().id(),product);var saved=purchase.record().orElseThrow();events.purchased(product,purchase.proof().operation(),purchase.catalog().revision(),shopId,player.getUniqueId(),player.getName(),captured.record().id(),captured.record().name(),captured.active().session(),purchase.proof().price(),purchase.proof().stockAfter()+1,purchase.proof().stockAfter(),after.subtract(java.math.BigInteger.ONE),after,captured.record().coins(),saved.coins(),captured.record().revision(),saved.revision(),auditLocation);}}result.accept(new Outcome(status,purchase.record()));});});}
private games.dmg.fantasystick.events.EventInput.Location purchaseLocation(Player player,Context captured){
if(events==null)return null;
try{return events.potionContext(player,captured.record().id(),captured.record().name(),captured.active().session()).location();}
catch(RuntimeException failure){report(failure);return null;}
}
private Optional<Context> context(Player player,UUID shopId){var shop=shops.find(shopId).orElse(null);var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);if(shop==null||record==null)return Optional.empty();var location=player.getLocation();if(location.getWorld()==null||!location.getWorld().getUID().equals(shop.world()))return Optional.empty();double x=location.getX()-shop.x(),y=location.getY()-shop.y(),z=location.getZ()-shop.z();return x*x+y*y+z*z<=36?Optional.of(new Context(active,record,shop)):Optional.empty();}
private void publishHeld(RareMerchantDirectory.Lease lease,Runnable action){try{nextTick.accept(()->{try{action.run();}finally{lease.close();}});}catch(RuntimeException failure){lease.close();report(failure);}}private void publish(Runnable action){try{nextTick.accept(action);}catch(RuntimeException failure){report(failure);}}private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}
}
@@ -10,6 +10,14 @@ import org.bukkit.entity.Player;
public interface TrainingDamage {
Optional<BigDecimal> apply(Player source, LivingEntity target, Runnable nativeDamage);
/** Caller-owned input identity; this metadata cannot authorize or replay an attack. */
record Source(java.util.UUID actionId, java.util.UUID playerId, java.util.UUID characterId,
String characterName, java.util.UUID sessionId, String attackName, String abilityId) {}
default Optional<BigDecimal> apply(Player source, LivingEntity target, Source metadata, Runnable nativeDamage) {
return apply(source, target, nativeDamage);
}
default boolean target(org.bukkit.entity.Entity entity) { return false; }
static TrainingDamage passthrough() {
@@ -4,7 +4,10 @@ import java.util.Objects;
import java.util.UUID;
/** Durable desired location for one administrator-managed training dummy. */
public record TrainingDummy(UUID id, String name, UUID world, double x, double y, double z, float yaw, float pitch) {
public record TrainingDummy(UUID id, String name, UUID world, double x, double y, double z, float yaw, float pitch, UUID revision) {
public TrainingDummy(UUID id, String name, UUID world, double x, double y, double z, float yaw, float pitch) {
this(id, name, world, x, y, z, yaw, pitch, null);
}
public TrainingDummy {
Objects.requireNonNull(id); Objects.requireNonNull(name); Objects.requireNonNull(world);
name = name.strip();
@@ -31,9 +31,13 @@ public final class TrainingDummyController implements Listener, TrainingDamage,
private final java.util.Set<Zombie> loaded = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
private Attempt pending;
private boolean closed;
private games.dmg.fantasystick.events.TrainingEventBridge events;
private static final class Attempt {
final LivingEntity target; BigDecimal amount;
final UUID causation = UUID.randomUUID();
games.dmg.fantasystick.events.TrainingEventBridge.Capture capture;
games.dmg.fantasystick.events.EventInput measurement;
Attempt(LivingEntity target) { this.target = target; }
}
@@ -49,6 +53,21 @@ public final class TrainingDummyController implements Listener, TrainingDamage,
}
}
public record Observed(String chunkStatus, java.util.List<UUID> ids, String quality) {}
public Observed observed(TrainingDummy value) {
try {
var world = plugin.getServer().getWorld(value.world());
if (world == null) return new Observed("unavailable", java.util.List.of(), "unavailable");
if (!world.isChunkLoaded(chunk(value.x()), chunk(value.z())))
return new Observed("unloaded", java.util.List.of(), "native");
var entities = world.getEntities();
if (entities == null) return new Observed("loaded", java.util.List.of(), "unavailable");
var ids = entities.stream().filter(this::tagged).filter(entity -> value.id().equals(id(entity)))
.map(Entity::getUniqueId).sorted().toList();
return new Observed("loaded", ids, "native");
} catch (RuntimeException unavailable) { return new Observed("unavailable", java.util.List.of(), "unavailable"); }
}
public boolean tagged(Entity entity) {
var data = entity.getPersistentDataContainer();
return data != null && TYPE.equals(data.get(typeKey, PersistentDataType.STRING));
@@ -93,13 +112,25 @@ public final class TrainingDummyController implements Listener, TrainingDamage,
}
}
public void attachEvents(games.dmg.fantasystick.events.TrainingEventBridge bridge) {
if (closed || events != null) throw new IllegalStateException("Training events already attached or controller closed");
events = Objects.requireNonNull(bridge);
}
@Override public Optional<BigDecimal> apply(Player source, LivingEntity target, Runnable nativeDamage) {
return apply(source, target, null, nativeDamage);
}
@Override public Optional<BigDecimal> apply(Player source, LivingEntity target, TrainingDamage.Source metadata, Runnable nativeDamage) {
Objects.requireNonNull(source); Objects.requireNonNull(target); Objects.requireNonNull(nativeDamage);
if (closed || !managed(target)) { nativeDamage.run(); return Optional.empty(); }
if (pending != null) throw new IllegalStateException("Nested training damage is unavailable");
var attempt = new Attempt(target); pending = attempt;
var attempt = new Attempt(target);
try { if (events != null && metadata != null) attempt.capture = events.capture(source, metadata); }
catch (RuntimeException ignored) { /* Unavailable audit context cannot change interception. */ }
pending = attempt;
try { nativeDamage.run(); }
finally { if (pending == attempt) pending = null; }
if (events != null) events.measured(attempt.measurement);
return Optional.ofNullable(attempt.amount);
}
@@ -171,6 +202,12 @@ public final class TrainingDummyController implements Listener, TrainingDamage,
if (attempt != null && attempt.target == event.getEntity() && managed(event.getEntity())
&& Double.isFinite(event.getFinalDamage()) && event.getFinalDamage() > 0) {
attempt.amount = BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros();
try {
if (events != null && attempt.capture != null) {
var dummy = directory.find(id(event.getEntity())).orElse(null);
if (dummy != null) attempt.measurement = events.measurement(attempt.capture, attempt.causation, dummy, event, attempt.amount);
}
} catch (RuntimeException ignored) { /* Audit cannot interrupt native damage containment. */ }
}
event.setCancelled(true);
}
@@ -31,7 +31,7 @@ public final class TrainingDummyDirectory implements AutoCloseable {
}
public CompletableFuture<TrainingDummy> place(String name, UUID world, double x, double y, double z,
float yaw, float pitch) {
var value = new TrainingDummy(UUID.randomUUID(), name, world, x, y, z, yaw, pitch);
var value = new TrainingDummy(UUID.randomUUID(), name, world, x, y, z, yaw, pitch, UUID.randomUUID());
return submit(() -> {
var next = new ArrayList<>(snapshot); next.add(value); repository.save(next);
snapshot = List.copyOf(next); return 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);}});}
}
@@ -393,11 +393,14 @@ class AdventureQuestNativeTest {
await(restarted,()->!fishing.trophies(carrier).isEmpty());
assertEquals(finalEnemy.toString(),fishing.trophies(carrier).getFirst().audit()
.get("final_enemy_id"));
awaitCampExport(restarted,data,receiptId);
restarted.plugin.setEnabled(false);
}
var earned=exportedCampEvents(data).stream().filter(event->event.get("type").getAsString()
.endsWith("adventure.trophy_earned")).toList();
assertEquals(1,earned.size());
assertEquals(1,earned.size(),()->"Exported camp earned count="+earned.size()+" records="
+earned.stream().map(event->"id="+event.get("id").getAsString()+",receipt="
+event.getAsJsonObject("data").get("receipt_id").getAsString()).toList());
var details=earned.getFirst().getAsJsonObject("data");
assertEquals(receiptId.toString(),details.get("receipt_id").getAsString());
assertEquals(finalEnemy.toString(),details.get("final_enemy_id").getAsString());
@@ -779,6 +782,30 @@ class AdventureQuestNativeTest {
}
}
private static void awaitCampExport(FantasyStickLifecycleNativeTest.Fixture fixture,Path root,
UUID receipt)throws Exception{
var eventId=UUID.nameUUIDFromBytes(("fantasystick:fishing:"+receipt
+":adventure.trophy_earned").getBytes(java.nio.charset.StandardCharsets.UTF_8));
var ownerIntent=root.resolve("fishing-event-intents").resolve(eventId+".pending");
var logIntent=root.resolve("events/pending").resolve(eventId+".pending");
for(int attempt=0;attempt<200;attempt++){
if(hasExportedCampReceipt(root,receipt)&&java.nio.file.Files.notExists(ownerIntent)
&&java.nio.file.Files.notExists(logIntent))return;
var task=fixture.tasks.poll(25,java.util.concurrent.TimeUnit.MILLISECONDS);
if(task!=null)task.run();
}
assertTrue(hasExportedCampReceipt(root,receipt)
&&java.nio.file.Files.notExists(ownerIntent)&&java.nio.file.Files.notExists(logIntent),
"Committed camp receipt "+receipt+" did not complete exported JSONL/intent handoff before shutdown"
+"; owner_pending="+java.nio.file.Files.exists(ownerIntent)
+"; log_pending="+java.nio.file.Files.exists(logIntent));
}
private static boolean hasExportedCampReceipt(Path root,UUID receipt){
try{return exportedCampEvents(root).stream().anyMatch(event->event.get("type").getAsString()
.endsWith("adventure.trophy_earned")&&event.getAsJsonObject("data")
.get("receipt_id").getAsString().equals(receipt.toString()));}
catch(Exception transientExport){return false;}
}
private static List<com.google.gson.JsonObject> exportedCampEvents(Path root)throws Exception{
try(var files=java.nio.file.Files.walk(root.resolve("events"))){
return files.filter(path->path.toString().endsWith(".jsonl"))
@@ -0,0 +1,94 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static games.dmg.fantasystick.WitchAuditTestSupport.*;
import java.nio.file.Path;
import java.util.ArrayList;
import org.bukkit.damage.DamageSource;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntityDeathEvent;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ArenaAttemptEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void actualAcceptedRectangleOutsideThenExactCornerActivationFreezesOriginalActorGeometryThroughLaterDefinitionAndNativeDefeat()throws Exception{
try(var championFixture=new ChampionAuditNativeFixture(data)){
var fixture=championFixture.base;var f=fixture.f;var actor=fixture.actor;
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","place","champion","--mode","individual-only","8","16","Edge Champion"});
ReskillEventsNativeTest.await(f,()->fixture.directory.snapshot().sites().size()==1);var site=fixture.directory.snapshot().sites().getFirst();
fixture.giver();fixture.resetCatalog();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();
var menu=fixture.open(actor);actor.core.location.setX(site.x()+4.01);actor.core.location.setZ(site.z()+8);
fixture.click(actor,menu,false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();
fixture.pulse();f.tick();
assertTrue(fixture.spawned.stream().noneMatch(org.bukkit.entity.Vindicator.class::isInstance),"An accepted but horizontally outside holder cannot activate the rectangle");
actor.core.location.setX(site.x()+4);actor.core.location.setY(site.y()+40);fixture.pulse();
ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(org.bukkit.entity.Vindicator.class::isInstance).count()==1);var champion=fixture.spawned.stream().filter(org.bukkit.entity.Vindicator.class::isInstance).findFirst().orElseThrow();
String attempt=champion.getPersistentDataContainer().get(new org.bukkit.NamespacedKey(f.plugin,"adventure_attempt_id"),org.bukkit.persistence.PersistentDataType.STRING);
assertTrue(fixture.controller.hasActiveEncounter());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
var source=mock(DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);
var cancelled=new EntityDamageByEntityEvent(actor.player,champion,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);cancelled.setCancelled(true);f.dispatcher.accept(cancelled);assertTrue(cancelled.isCancelled());
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","mode",site.id().toString(),"party-required"});
ReskillEventsNativeTest.await(f,()->fixture.directory.findSite(site.id()).orElseThrow().participationMode()==games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.PARTY_REQUIRED);
assertNotEquals(site.registrationRevision(),fixture.directory.findSite(site.id()).orElseThrow().registrationRevision());assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());
var hit=new EntityDamageByEntityEvent(actor.player,champion,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);when(champion.getLastDamageCause()).thenReturn(hit);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());
var death=new EntityDeathEvent(champion,source,new ArrayList<>(),7);f.dispatcher.accept(death);f.dispatcher.accept(death);
ReskillEventsNativeTest.await(f,()->fixture.directory.active().isEmpty());assertTrue(death.getDrops().isEmpty());assertEquals(0,death.getDroppedExp());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
ReskillEventsNativeTest.await(f,()->{try{return rows("adventure.champion.spawned").size()==1&&rows("adventure.champion.defeated").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");var start=rows("adventure.champion.spawned").getFirst().getAsJsonObject("data");var defeat=rows("adventure.champion.defeated").getFirst().getAsJsonObject("data");
var arena=start.getAsJsonObject("arena");assertNotNull(arena,"Native activation must export the exact captured arena, not only generic attempt identity");
assertEquals("rectangle",arena.get("shape").getAsString());assertEquals("explicit",arena.get("source").getAsString());assertEquals(8,arena.get("x_blocks").getAsInt());assertEquals(16,arena.get("z_blocks").getAsInt());
assertEquals(site.registrationRevision().toString(),start.get("arena_revision").getAsString());assertEquals(arena,defeat.getAsJsonObject("arena"));assertEquals(start.get("arena_revision"),defeat.get("arena_revision"));
var activation=start.getAsJsonObject("activation");assertNotNull(activation);assertEquals("edge",activation.get("boundary_result").getAsString());
assertEquals(actor.core.id.toString(),activation.get("player_id").getAsString());assertEquals(before.id().toString(),activation.get("character_id").getAsString());assertEquals(session.toString(),activation.get("session_id").getAsString());
assertEquals(quest.id().toString(),activation.get("quest_id").getAsString());assertEquals(quest.unit().toString(),activation.get("unit_id").getAsString());
assertEquals(site.world().toString(),activation.get("location_world_id").getAsString());assertEquals(site.x()+4,activation.get("location_x").getAsDouble());assertEquals(site.y()+40,activation.get("location_y").getAsDouble());assertEquals(site.z()+8,activation.get("location_z").getAsDouble());
assertEquals("rectangle",activation.get("arena_shape").getAsString());assertEquals(8,activation.get("arena_x_blocks").getAsInt());assertEquals(16,activation.get("arena_z_blocks").getAsInt());
assertEquals(activation,defeat.getAsJsonObject("activation"));assertEquals(attempt,start.get("attempt_id").getAsString());assertEquals(attempt,defeat.get("attempt_id").getAsString());
assertEquals(1,rows("adventure.champion.spawned").size());assertEquals(1,rows("adventure.champion.defeated").size());assertTrue(rows("adventure.champion.reset").isEmpty());
assertEquals(champion.getLocation().getX(),defeat.getAsJsonObject("final_enemy_location").get("x").getAsDouble());
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.CsvSource({"camp,circle,edge","camp,rectangle,inside","camp,default,inside","champion,circle,inside","champion,rectangle,edge","champion,default,edge"})
void actualAcceptedArenaVariantsExcludeOutsideAndUnacceptedPlayersThenPreserveGeometryActivationAndQuietPulsesThroughReset(String kind,String form,String result)throws Exception{
try(var championFixture=new ChampionAuditNativeFixture(data)){
var fixture=championFixture.base;var f=fixture.f;var actor=fixture.actor;
var args=new java.util.ArrayList<>(java.util.List.of("adventure-site","place",kind,"--mode","individual-only"));
if(form.equals("circle"))args.add("10");else if(form.equals("rectangle")){args.add("20");args.add("30");}args.add("Arena Variant");
f.adminCommand.execute(actor.player,"fstickadmin",args.toArray(String[]::new));ReskillEventsNativeTest.await(f,()->fixture.directory.snapshot().sites().size()==1);var site=fixture.directory.snapshot().sites().getFirst();
fixture.giver();fixture.resetCatalog();f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);var arena=fixture.controller.arena(site);double halfX=form.equals("rectangle")?10:arena.xBlocks();
var menu=fixture.open(actor);actor.core.location.setX(site.x()+halfX+0.01);fixture.click(actor,menu,false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();
var outsider=new FantasyStickLifecycleNativeTest.Actor(data);outsider.core.location.setWorld(actor.world);outsider.core.location.setX(site.x());outsider.core.location.setY(site.y());outsider.core.location.setZ(site.z());f.players.put(outsider.core.id,outsider.player);f.activate(outsider);
fixture.pulse();f.tick();assertFalse(fixture.controller.hasActiveEncounter(),"Neither an outside accepted holder nor an inside unaccepted living character can activate");
assertTrue(fixture.spawned.stream().noneMatch(entity->fixture.controller.encounterOrigin(entity).isPresent()));
double offset=result.equals("edge")?halfX:halfX/2;actor.core.location.setX(site.x()+offset);actor.core.location.setY(site.y()+40);fixture.pulse();
ReskillEventsNativeTest.await(f,fixture.controller::hasActiveEncounter);ReskillEventsNativeTest.await(f,()->{
var roster=fixture.directory.snapshot().rosters().get(site.id());
return (kind.equals("champion")||roster!=null)&&fixture.spawned.stream().filter(entity->fixture.controller.encounterOrigin(entity).isPresent()).count()==(kind.equals("champion")?1:roster.size());
});
String startType=kind.equals("champion")?"adventure.champion.spawned":"adventure.camp.started",resetType="adventure."+kind+".reset";
ReskillEventsNativeTest.await(f,()->{try{return rows(startType).size()==1;}catch(Exception failure){throw new AssertionError(failure);}});var start=rows(startType).getFirst();var values=start.getAsJsonObject("data");var originalGeometry=values.getAsJsonObject("arena").deepCopy();var activation=values.getAsJsonObject("activation").deepCopy();
assertEquals(result,activation.get("boundary_result").getAsString());assertEquals(actor.core.id.toString(),activation.get("player_id").getAsString());assertEquals(before.id().toString(),activation.get("character_id").getAsString());assertEquals(quest.id().toString(),activation.get("quest_id").getAsString());
assertEquals(site.x()+offset,activation.get("location_x").getAsDouble());assertEquals(site.y()+40,activation.get("location_y").getAsDouble());assertEquals(site.z(),activation.get("location_z").getAsDouble());
assertEquals(form.equals("rectangle")?"rectangle":"circle",originalGeometry.get("shape").getAsString());assertEquals(form.equals("default")?"configured_default":"explicit",originalGeometry.get("source").getAsString());assertEquals(arena.xBlocks(),originalGeometry.get("x_blocks").getAsInt());assertEquals(arena.zBlocks(),originalGeometry.get("z_blocks").getAsInt());
actor.particleCalls.clear();for(int index=0;index<3;index++)fixture.pulse();assertFalse(actor.particleCalls.isEmpty(),"Actual accepted perimeter presentation remains wired");assertEquals(java.util.List.of(start),rows(startType));
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config","adventure-site-radius","32"});ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Saved adventure-site-radius=32")));
actor.core.location.setX(site.x()+halfX+1);f.dispatcher.accept(new org.bukkit.event.world.ChunkUnloadEvent(actor.world.getChunkAt(0,-1),false));assertFalse(fixture.controller.hasActiveEncounter());assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
assertTrue(((games.dmg.fantasystick.adventure.FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing")).trophies(quest.unit()).isEmpty());
ReskillEventsNativeTest.await(f,()->{try{return rows(resetType).size()==1;}catch(Exception failure){throw new AssertionError(failure);}});f.plugin.setEnabled(false);f.awaitWorkers();drain(f,kind.equals("camp")?"campAuditDrain":"championAuditDrain");
var reset=rows(resetType).getFirst().getAsJsonObject("data");assertEquals(originalGeometry,reset.getAsJsonObject("arena"));assertEquals(activation,reset.getAsJsonObject("activation"));assertEquals(values.get("attempt_id"),reset.get("attempt_id"));assertEquals(values.get("arena_revision"),reset.get("arena_revision"));assertEquals("chunk_unload",reset.get("reason").getAsString());
assertEquals(1,rows(startType).size());assertEquals(1,rows(resetType).size());assertTrue(rows(kind.equals("champion")?"adventure.champion.defeated":"adventure.camp.cleared").isEmpty());
}
}
private java.util.List<com.google.gson.JsonObject> rows(String type)throws Exception{
return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)).toList();
}
}
@@ -0,0 +1,102 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static games.dmg.fantasystick.WitchAuditTestSupport.*;
import games.dmg.fantasystick.adventure.AdventureQuestState;
import games.dmg.fantasystick.persistence.YamlAdventureQuestRepository;
import java.nio.file.Path;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ArenaRegistrationEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void actualRegisteredExplicitCirclePreservesSavedGeometryAndOriginalAdministratorArenaContextInJsonl()throws Exception{
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);
var session=((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions"))
.active(actor.core.id).orElseThrow().session();
assertTrue(f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","place","champion","--mode","individual-only","8","Round Arena"}));
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Placed champion")));
var saved=fixture.directory.snapshot().sites().getFirst();assertEquals(AdventureQuestState.Arena.circle(8),saved.arena());
assertEquals(saved,new YamlAdventureQuestRepository(data).load().sites().getFirst());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
ReskillEventsNativeTest.await(f,()->{try{return rows().size()==1;}catch(Exception failure){throw new AssertionError(failure);}});
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");var rows=rows();assertEquals(1,rows.size());
var row=rows.getFirst();var values=row.getAsJsonObject("data");var arena=values.getAsJsonObject("arena");
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(saved.id().toString(),values.get("site_id").getAsString());
assertEquals(saved.world().toString(),values.get("world_id").getAsString());assertEquals(12,values.get("x").getAsDouble());
assertEquals(70,values.get("y").getAsDouble());assertEquals(-8,values.get("z").getAsDouble());assertEquals("circle",arena.get("shape").getAsString());
assertEquals("explicit",arena.has("source")?arena.get("source").getAsString():null,
"Acknowledged registration must distinguish persisted explicit geometry from configured-default context in its actual JSONL record");
assertEquals(8,arena.get("radius").getAsInt());assertEquals(saved.registrationRevision().toString(),values.get("arena_revision").getAsString());
assertEquals("persisted_site_definition",values.get("arena_revision_capture_quality").getAsString());
assertEquals("individual-only",values.get("mode_after").getAsString());assertEquals("acknowledged",values.get("outcome").getAsString());
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.CsvSource({"camp,circle","camp,rectangle","camp,default","champion,circle","champion,rectangle","champion,default"})
void allRegisteredArenaFormsRetainOriginalEffectiveGeometryAcrossLaterConfigurationAndReload(String kind,String form)throws Exception{
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config","adventure-site-radius","24"});
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Saved adventure-site-radius=24")));
var args=new java.util.ArrayList<>(java.util.List.of("adventure-site","place",kind,"--mode","individual-only"));
if(form.equals("circle"))args.add("10");else if(form.equals("rectangle")){args.add("20");args.add("30");}args.add("Geometry Original");
f.adminCommand.execute(actor.player,"fstickadmin",args.toArray(String[]::new));
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Placed "+kind)));
var site=fixture.directory.snapshot().sites().getFirst();assertEquals(site,new YamlAdventureQuestRepository(data).load().sites().getFirst());
if(form.equals("default"))assertNull(site.arena());else assertEquals(form.equals("circle")?AdventureQuestState.Arena.circle(10):AdventureQuestState.Arena.rectangle(20,30),site.arena());
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config","adventure-site-radius","32"});
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Saved adventure-site-radius=32")));
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","list"});fixture.pulse();fixture.pulse();
ReskillEventsNativeTest.await(f,()->{try{return rows(kind).size()==1;}catch(Exception failure){throw new AssertionError(failure);}});
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,kind.equals("camp")?"campAuditDrain":"championAuditDrain");
var row=rows(kind).getFirst();var values=row.getAsJsonObject("data");var geometry=values.getAsJsonObject("arena");
assertEquals(form.equals("rectangle")?"rectangle":"circle",geometry.get("shape").getAsString());assertEquals(form.equals("default")?"configured_default":"explicit",geometry.get("source").getAsString());
assertEquals(form.equals("default")?24:form.equals("rectangle")?20:10,geometry.get("x_blocks").getAsInt());assertEquals(form.equals("default")?24:form.equals("rectangle")?30:10,geometry.get("z_blocks").getAsInt());
if(form.equals("rectangle"))assertFalse(geometry.has("radius"));else assertEquals(form.equals("default")?24:10,geometry.get("radius").getAsInt());
assertEquals(site.registrationRevision().toString(),values.get("arena_revision").getAsString());assertEquals(actor.core.id.toString(),row.get("playerid").getAsString());assertEquals(before.id().toString(),row.get("characterid").getAsString());
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());
assertEquals(site.world().toString(),values.get("world_id").getAsString());assertEquals(1,rows(kind).size());assertTrue(fixture.spawned.isEmpty());
var bytes=java.nio.file.Files.readAllBytes(data.resolve("adventure-quests.yml"));f.players.clear();f.plugin.setEnabled(true);
ReskillEventsNativeTest.await(f,()->((games.dmg.fantasystick.adventure.AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests")).findSite(site.id()).isPresent());
assertArrayEquals(bytes,java.nio.file.Files.readAllBytes(data.resolve("adventure-quests.yml")));assertEquals(site,((games.dmg.fantasystick.adventure.AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests")).findSite(site.id()).orElseThrow());
assertEquals(before,((games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters")).find(before.id()).get().orElseThrow());
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,kind.equals("camp")?"campAuditDrain":"championAuditDrain");assertEquals(java.util.List.of(row),rows(kind));
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings={"camp","champion"})
void actualMalformedDeniedAndFailedRegistrationProduceNoArenaSuccessWhileRetryAndListRemainExact(String kind)throws Exception{
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;f.activate(actor);
actor.allowed=false;f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","place",kind,"10","Denied"});actor.allowed=true;
for(var dimensions:java.util.List.of(new String[]{"3"},new String[]{"65"},new String[]{"20","7"},new String[]{"20","129"},new String[]{"1.5"},new String[]{"--mode","invalid","10"})){
var args=new java.util.ArrayList<>(java.util.List.of("adventure-site","place",kind));args.addAll(java.util.List.of(dimensions));f.adminCommand.execute(actor.player,"fstickadmin",args.toArray(String[]::new));
}
assertTrue(fixture.directory.snapshot().sites().isEmpty());
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","place",kind,"10","Original"});ReskillEventsNativeTest.await(f,()->fixture.directory.snapshot().sites().size()==1);
var original=fixture.directory.snapshot().sites().getFirst();var file=data.resolve("adventure-quests.yml");var bytes=java.nio.file.Files.readAllBytes(file);java.nio.file.Files.delete(file);java.nio.file.Files.createDirectory(file);
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","place",kind,"20","30","Retry"});ReskillEventsNativeTest.await(f,()->actor.messages.contains("Adventure site save failed; desired state is unchanged."));
assertEquals(java.util.List.of(original),fixture.directory.snapshot().sites());java.nio.file.Files.delete(file);java.nio.file.Files.write(file,bytes);
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","place",kind,"20","30","Retry"});ReskillEventsNativeTest.await(f,()->fixture.directory.snapshot().sites().size()==2);
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","list"});fixture.pulse();fixture.pulse();
ReskillEventsNativeTest.await(f,()->{try{return rows(kind).size()==2;}catch(Exception failure){throw new AssertionError(failure);}});f.plugin.setEnabled(false);f.awaitWorkers();drain(f,kind.equals("camp")?"campAuditDrain":"championAuditDrain");
var exported=rows(kind);assertEquals(2,exported.size());assertEquals(java.util.Set.of("Original","Retry"),exported.stream().map(row->row.getAsJsonObject("data").get("site_name").getAsString()).collect(java.util.stream.Collectors.toSet()));
assertEquals(fixture.directory.snapshot().sites().stream().map(site->site.id().toString()).collect(java.util.stream.Collectors.toSet()),exported.stream().map(row->row.getAsJsonObject("data").get("site_id").getAsString()).collect(java.util.stream.Collectors.toSet()));
assertTrue(fixture.spawned.isEmpty());
}
}
private java.util.List<com.google.gson.JsonObject> rows(String kind)throws Exception{
return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.site.registered")
&&row.getAsJsonObject("data").get("site_kind").getAsString().equals(kind)).toList();
}
private java.util.List<com.google.gson.JsonObject> rows()throws Exception{
return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.site.registered")
&&row.getAsJsonObject("data").get("site_kind").getAsString().equals("champion")).toList();
}
}
@@ -0,0 +1,141 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import com.google.gson.JsonObject;
import games.dmg.fantasystick.persistence.GearSettings;
import games.dmg.fantasystick.shop.ArmorMarket;
import java.nio.file.Path;
import java.util.List;
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 ArmorConfigurationEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void everyRegisteredGearReadExportsExactNextRestockPolicyNotDeniedOrInvalidCommands() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket");
var catalog = market.current().toCompletableFuture().get(5, TimeUnit.SECONDS);
var settings = (GearSettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "gearSettings");
var sender = new EquipmentAdminCommandNativeTest.Sender();
var denied = new EquipmentAdminCommandNativeTest.Sender(); denied.allowed = false;
f.adminCommand.execute(denied.port, "fstickadmin", new String[]{"config", "gear-price-iron"});
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", "gear-price-variance-percent", "101"});
assertEquals(catalog, market.current().toCompletableFuture().get());
for (var key : GearSettings.keys().stream().sorted().toList()) {
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", key});
assertTrue(sender.messages.stream().anyMatch(message -> message.startsWith(key + "=" + settings.value(key))));
}
for (int attempt = 0; attempt < 100 && rows("config.read").size() < GearSettings.keys().size(); attempt++) Thread.sleep(10);
assertEquals(GearSettings.keys().size(), rows("config.read").size(), "Every actual registered gear read needs one owner JSONL record");
for (var row : rows("config.read")) {
var values = row.getAsJsonObject("data"); var key = values.get("key").getAsString();
assertTrue(GearSettings.keys().contains(key)); assertEquals(settings.value(key), values.get("after").getAsString());
assertEquals("next_armor_restock", values.get("application").getAsString()); assertEquals("console", values.get("actor_kind").getAsString());
assertTrue(values.has("application_scope"), "Approved gear configuration events must include their next-catalog application scope");
assertEquals("next_catalog", values.get("application_scope").getAsString());
assertEquals("acknowledged", values.get("outcome").getAsString()); assertFalse(values.has("before"));
}
assertTrue(rows("config.changed").isEmpty()); assertEquals(catalog, market.current().toCompletableFuture().get());
f.plugin.setEnabled(false); f.awaitWorkers();
}
}
@Test void queuedPublishedGearChangesUseActualBeforeValueAndLockedSaveCannotClaimSuccessOrRestock() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket");
var catalog = market.current().toCompletableFuture().get(5, TimeUnit.SECONDS);
var settings = (GearSettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "gearSettings");
var initial = settings.value(GearSettings.VARIANCE);
java.nio.file.Files.writeString(data.resolve("config.yml"), "\nus054-unrelated: retain-me\n", java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.APPEND);
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", GearSettings.VARIANCE, "5"});
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", GearSettings.VARIANCE, "6"});
ReskillEventsNativeTest.await(f, () -> sender.messages.stream().anyMatch(message -> message.startsWith("Saved " + GearSettings.VARIANCE + "=6")));
assertEquals("6", settings.value(GearSettings.VARIANCE)); assertEquals(catalog, market.current().toCompletableFuture().get());
for (int attempt = 0; attempt < 100 && rows("config.changed").size() < 2; attempt++) Thread.sleep(10);
assertEquals(2, rows("config.changed").size(), "Only acknowledged native policy publications need configuration change records");
var first = rows("config.changed").stream().map(row -> row.getAsJsonObject("data")).filter(values -> values.get("after").getAsString().equals("5")).findFirst().orElseThrow();
var second = rows("config.changed").stream().map(row -> row.getAsJsonObject("data")).filter(values -> values.get("after").getAsString().equals("6")).findFirst().orElseThrow();
assertEquals(initial, first.get("before").getAsString());
assertEquals("5", second.get("before").getAsString(), "Queued history starts at the prior actual native publication, not the request-time snapshot");
assertEquals("next_armor_restock", second.get("application").getAsString());
assertTrue(second.has("application_scope")); assertEquals("next_catalog", second.get("application_scope").getAsString());
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"), java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
var lock = channel.lock()) {
assertTrue(lock.isValid());
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", GearSettings.VARIANCE, "7"});
ReskillEventsNativeTest.await(f, () -> sender.messages.contains("Armor market setting save failed; effective settings and the current catalog are unchanged."));
assertEquals("6", settings.value(GearSettings.VARIANCE)); assertEquals(catalog, market.current().toCompletableFuture().get());
assertEquals(2, rows("config.changed").size());
}
var yaml = org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(data.resolve("config.yml").toFile());
assertEquals(6, yaml.getInt(GearSettings.VARIANCE)); assertEquals("retain-me", yaml.getString("us054-unrelated"));
f.plugin.setEnabled(false); f.awaitWorkers();
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain"))
.toCompletableFuture().get(5, TimeUnit.SECONDS);
}
assertEquals(2, rows("config.changed").size());
}
@Test void rejectedGearReadAndChangeHistoryReplaysWithoutApplyingOldPolicyRestockingOrRepeatingFeedback() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket");
var catalog = market.current().toCompletableFuture().get(5, TimeUnit.SECONDS);
ReskillEventsNativeTest.await(f, () -> hasStartupCatalog());
var settings = (GearSettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "gearSettings"); var initial = settings.value(GearSettings.VARIANCE);
((games.dmg.fantasystick.events.LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close();
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", GearSettings.VARIANCE});
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", GearSettings.VARIANCE, "5"});
ReskillEventsNativeTest.await(f, () -> sender.messages.stream().anyMatch(message -> message.startsWith("Saved " + GearSettings.VARIANCE + "=5")));
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", GearSettings.VARIANCE});
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", GearSettings.VARIANCE, "6"});
ReskillEventsNativeTest.await(f, () -> sender.messages.stream().anyMatch(message -> message.startsWith("Saved " + GearSettings.VARIANCE + "=6")));
assertEquals("6", settings.value(GearSettings.VARIANCE)); assertEquals(catalog, market.current().toCompletableFuture().get());
f.plugin.setEnabled(false); f.awaitWorkers();
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain")).toCompletableFuture().get(5, TimeUnit.SECONDS);
var intents = new java.util.ArrayList<java.util.Properties>();
try (var files = java.nio.file.Files.list(data.resolve("armor-event-intents"))) {
for (var path : files.filter(value -> value.getFileName().toString().endsWith(".pending")).toList()) {
var properties = new java.util.Properties(); try (var input = java.nio.file.Files.newInputStream(path)) { properties.load(input); } intents.add(properties);
}
}
assertEquals(4, intents.size()); assertTrue(rows("config.read").isEmpty()); assertTrue(rows("config.changed").isEmpty());
var bytes = java.nio.file.Files.readAllBytes(data.resolve("config.yml")); var marketBytes = java.nio.file.Files.readAllBytes(data.resolve("armor-catalog.yml"));
var feedback = List.copyOf(sender.messages);
f.plugin.setEnabled(true); market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket");
assertEquals(catalog, market.current().toCompletableFuture().get());
for (int attempt = 0; attempt < 100 && rows("config.read").size() + rows("config.changed").size() < 4; attempt++) Thread.sleep(10);
assertEquals(2, rows("config.read").size()); assertEquals(2, rows("config.changed").size());
assertArrayEquals(bytes, java.nio.file.Files.readAllBytes(data.resolve("config.yml"))); assertArrayEquals(marketBytes, java.nio.file.Files.readAllBytes(data.resolve("armor-catalog.yml")));
assertEquals(feedback, sender.messages); assertEquals("6", ((GearSettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "gearSettings")).value(GearSettings.VARIANCE));
var all = new java.util.ArrayList<>(rows("config.read")); all.addAll(rows("config.changed"));
for (var intent : intents) {
var replay = all.stream().filter(row -> row.get("id").getAsString().equals(intent.getProperty("id"))).findFirst().orElseThrow();
assertEquals(intent.getProperty("time"), replay.get("time").getAsString()); assertEquals(intent.getProperty("action"), replay.getAsJsonObject("data").get("action_id").getAsString());
}
assertTrue(rows("config.read").stream().anyMatch(row -> row.getAsJsonObject("data").get("after").getAsString().equals(initial)));
var changed = rows("config.changed").stream().map(row -> row.getAsJsonObject("data")).filter(values -> values.get("after").getAsString().equals("6")).findFirst().orElseThrow();
assertEquals("5", changed.get("before").getAsString()); assertEquals("next_armor_restock", changed.get("application").getAsString());
assertEquals("next_catalog", changed.get("application_scope").getAsString());
f.plugin.setEnabled(false); f.awaitWorkers();
}
}
private boolean hasStartupCatalog() {
try { return ReskillEventsNativeTest.records(data).stream().anyMatch(row -> row.get("type").getAsString().endsWith("armor.catalog_refreshed")); }
catch (Exception failure) { throw new AssertionError("Cannot parse actual armor exports", failure); }
}
private List<JsonObject> rows(String type) throws Exception {
return ReskillEventsNativeTest.records(data).stream().filter(row -> row.get("type").getAsString().endsWith(type))
.filter(row -> row.getAsJsonObject("data").has("key")
&& row.getAsJsonObject("data").get("key").getAsString().startsWith("gear-")).toList();
}
}
@@ -0,0 +1,560 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import com.google.gson.JsonObject;
import games.dmg.fantasystick.events.LocalEventLog;
import games.dmg.fantasystick.persistence.GearSlot;
import games.dmg.fantasystick.persistence.GearTier;
import games.dmg.fantasystick.shop.ArmorCatalog;
import games.dmg.fantasystick.shop.ArmorMarket;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
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 ArmorEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void startupAndRegisteredRestockExportOneCompleteSharedCatalogButRetainedRestartDoesNotRefresh() throws Exception {
ArmorCatalog original, replacement;
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f);
var market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket");
original = market.current().toCompletableFuture().get(5, TimeUnit.SECONDS);
assertEquals(18, original.products().size());
for (int attempt = 0; attempt < 100 && catalogs().isEmpty(); attempt++) Thread.sleep(10);
assertEquals(1, catalogs().size(), "A saved complete shared armor catalog must reach its owner JSONL record");
verifyCatalog(original, catalogs().getFirst());
assertEquals(original, market.current().toCompletableFuture().get());
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"shop", "restock"});
ReskillEventsNativeTest.await(f, () -> sender.messages.stream().anyMatch(message -> message.contains("Restocked all Fantasy Armorers"))
&& catalogs().size() == 2);
replacement = market.current().toCompletableFuture().get();
assertNotEquals(original.revision(), replacement.revision());
verifyCatalog(replacement, catalogs().stream().filter(row -> row.getAsJsonObject("data")
.get("catalog_revision").getAsString().equals(replacement.revision().toString())).findFirst().orElseThrow());
f.plugin.setEnabled(false); f.awaitWorkers();
f.plugin.setEnabled(true); ready(f);
var restarted = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket");
assertEquals(replacement, restarted.current().toCompletableFuture().get());
f.plugin.setEnabled(false); f.awaitWorkers();
}
assertEquals(2, catalogs().size(), "A retained-current restart/read cannot invent a new refresh");
}
@Test void rejectedRegisteredRestockReplaysOriginalCatalogWithoutRestockingOrRepeatingFeedback() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f);
var market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket");
var initial = market.current().toCompletableFuture().get(5, TimeUnit.SECONDS);
ReskillEventsNativeTest.await(f, () -> catalogs().size() == 1);
((LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close();
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"shop", "restock"});
ReskillEventsNativeTest.await(f, () -> sender.messages.stream().anyMatch(message -> message.contains("Restocked all Fantasy Armorers")));
var committed = market.current().toCompletableFuture().get();
assertNotEquals(initial.revision(), committed.revision());
var feedback = List.copyOf(sender.messages);
var bytes = java.nio.file.Files.readAllBytes(data.resolve("armor-catalog.yml"));
f.plugin.setEnabled(false); f.awaitWorkers();
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain"))
.toCompletableFuture().get(5, TimeUnit.SECONDS);
var pending = new java.util.Properties();
try (var files = java.nio.file.Files.list(data.resolve("armor-event-intents"))) {
var retained = files.filter(path -> path.getFileName().toString().endsWith(".pending")).toList();
assertEquals(1, retained.size());
try (var input = java.nio.file.Files.newInputStream(retained.getFirst())) { pending.load(input); }
}
assertEquals(1, catalogs().size(), "Rejected delivery is not an exported restock record");
f.plugin.setEnabled(true); ready(f);
ReskillEventsNativeTest.await(f, () -> catalogs().size() == 2);
var restarted = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket");
assertEquals(committed, restarted.current().toCompletableFuture().get());
assertArrayEquals(bytes, java.nio.file.Files.readAllBytes(data.resolve("armor-catalog.yml")), "Replay cannot restock or rewrite the market");
assertEquals(feedback, sender.messages, "Replay cannot deliver administration feedback again");
var replay = catalogs().stream().filter(row -> row.get("id").getAsString().equals(pending.getProperty("id")))
.findFirst().orElseThrow();
assertEquals(pending.getProperty("time"), replay.get("time").getAsString());
assertEquals(pending.getProperty("action"), replay.getAsJsonObject("data").get("action_id").getAsString());
verifyCatalog(committed, replay);
f.plugin.setEnabled(false); f.awaitWorkers();
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain"))
.toCompletableFuture().get(5, TimeUnit.SECONDS);
}
assertEquals(2, catalogs().size());
}
@Test void registeredPlacementAndRemovalExportActualSavedRevisionAndLoadedEntity() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f);
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(admin.core.id, admin.player); doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
var anchor = admin.core.location.clone();
int x = anchor.getBlockX() >> 4, z = anchor.getBlockZ() >> 4;
var npc = mock(org.bukkit.entity.Villager.class); var entityId = java.util.UUID.randomUUID();
when(npc.getUniqueId()).thenReturn(entityId); when(npc.getLocation()).thenReturn(anchor);
var tags = new java.util.HashMap<org.bukkit.NamespacedKey, String>();
var pdc = mock(org.bukkit.persistence.PersistentDataContainer.class);
when(npc.getPersistentDataContainer()).thenReturn(pdc);
doAnswer(call -> { tags.put(call.getArgument(0), call.getArgument(2)); return null; }).when(pdc)
.set(any(org.bukkit.NamespacedKey.class), eq(org.bukkit.persistence.PersistentDataType.STRING), anyString());
when(pdc.get(any(org.bukkit.NamespacedKey.class), eq(org.bukkit.persistence.PersistentDataType.STRING)))
.thenAnswer(call -> tags.get(call.getArgument(0)));
var present = new java.util.concurrent.atomic.AtomicBoolean();
var chunk = mock(org.bukkit.Chunk.class);
when(chunk.getWorld()).thenReturn(admin.world); when(chunk.getX()).thenReturn(x); when(chunk.getZ()).thenReturn(z);
when(chunk.getEntities()).thenAnswer(call -> present.get() ? new org.bukkit.entity.Entity[]{npc} : new org.bukkit.entity.Entity[0]);
when(admin.world.getEntities()).thenAnswer(call -> present.get() ? List.of(npc) : List.of());
when(admin.world.isChunkLoaded(x, z)).thenReturn(true); when(admin.world.getChunkAt(x, z)).thenReturn(chunk);
doAnswer(call -> { java.util.function.Consumer<org.bukkit.entity.Villager> configure = call.getArgument(2);
configure.accept(npc); present.set(true); return npc; }).when(admin.world)
.spawn(any(org.bukkit.Location.class), eq(org.bukkit.entity.Villager.class), org.mockito.ArgumentMatchers.<java.util.function.Consumer<? super org.bukkit.entity.Villager>>any());
doAnswer(call -> { present.set(false); return null; }).when(npc).remove();
f.adminCommand.execute(admin.player, "fstickadmin", new String[]{"shop", "place", "Revision Forge"});
admin.core.location.add(20, 0, 20); // Audit must retain registered input, not recapture the moved administrator.
var directory = (games.dmg.fantasystick.shop.FantasyArmorerDirectory)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorers");
ReskillEventsNativeTest.await(f, () -> !directory.all().isEmpty() && present.get());
var shop = directory.all().getFirst();
assertNotNull(shop.revision()); assertEquals(List.of(shop), new games.dmg.fantasystick.persistence.YamlFantasyArmorerRepository(data).load());
for (int attempt = 0; attempt < 100 && rows("armorer.placed").isEmpty(); attempt++) Thread.sleep(10);
assertEquals(1, rows("armorer.placed").size(), "Durable registration and actual native materialization need an owner record");
var placed = rows("armorer.placed").getFirst(); var values = placed.getAsJsonObject("data");
assertEquals(shop.id().toString(), values.get("armorer_id").getAsString());
assertEquals(shop.revision().toString(), values.get("armorer_revision").getAsString());
assertEquals("persisted", values.get("revision_capture_quality").getAsString());
assertEquals(entityId.toString(), values.get("entity_id").getAsString());
assertEquals("materialized", values.get("entity_outcome").getAsString());
assertEquals(admin.core.id.toString(), placed.get("playerid").getAsString());
assertEquals(shop.world().toString(), values.get("world_id").getAsString());
assertEquals(anchor.getX(), values.get("x").getAsDouble()); assertEquals(anchor.getY(), values.get("y").getAsDouble());
assertEquals(anchor.getZ(), values.get("z").getAsDouble()); assertEquals(anchor.getYaw(), values.get("yaw").getAsDouble());
assertEquals(anchor.getPitch(), values.get("pitch").getAsDouble());
f.dispatcher.accept(new org.bukkit.event.world.ChunkLoadEvent(chunk, false));
verify(admin.world, times(1)).spawn(any(org.bukkit.Location.class), eq(org.bukkit.entity.Villager.class),
org.mockito.ArgumentMatchers.<java.util.function.Consumer<? super org.bukkit.entity.Villager>>any());
assertEquals(1, rows("armorer.placed").size(), "Loaded-chunk reconciliation is not another durable registration");
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"shop", "remove", shop.id().toString()});
ReskillEventsNativeTest.await(f, () -> directory.all().isEmpty() && !present.get() && rows("armorer.removed").size() == 1);
var removed = rows("armorer.removed").getFirst().getAsJsonObject("data");
assertEquals(shop.revision().toString(), removed.get("armorer_revision").getAsString());
assertEquals(entityId.toString(), removed.get("entity_id").getAsString());
assertEquals("removed", removed.get("entity_outcome").getAsString());
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"shop", "remove", shop.id().toString()});
f.plugin.setEnabled(false); f.awaitWorkers();
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain"))
.toCompletableFuture().get(5, TimeUnit.SECONDS);
}
assertEquals(1, rows("armorer.placed").size()); assertEquals(1, rows("armorer.removed").size());
}
@Test void registeredShopPurchaseExportsExactStockCoinsTierAndReceiptNotDuplicateClicksOrRendering() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f);
var ports = npc(f); var player = ports.player();
var old = player.core.record; var balance = java.math.BigInteger.ONE.shiftLeft(100);
var funded = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
old.stats(), old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(),
old.cooldowns(), old.restrictedKills(), old.dead(), old.deathId(), old.diedAt(), balance, true,
old.rewardReceipts(), old.pvpCoinEscrow(), old.gear(), old.sheetGeneration());
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(funded, old.revision());
f.activate(player);
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"shop", "place", "Purchase Forge"});
var directory = (games.dmg.fantasystick.shop.FantasyArmorerDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorers");
ReskillEventsNativeTest.await(f, () -> !directory.all().isEmpty() && ports.present().get());
var shop = directory.all().getFirst();
var storage = (games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var before = storage.find(old.id()).get(); assertTrue(before.isPresent()); var previous = before.orElseThrow();
var market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket");
var catalog = market.current().toCompletableFuture().get();
var product = new games.dmg.fantasystick.shop.ArmorProduct(GearSlot.CHESTPLATE, GearTier.IRON);
var offer = catalog.offer(product);
var session = ((games.dmg.fantasystick.session.EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"))
.active(player.core.id).orElseThrow().session();
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(player.player, ports.entity(), org.bukkit.inventory.EquipmentSlot.HAND));
ReskillEventsNativeTest.await(f, () -> !player.inventories.isEmpty());
var menu = player.inventories.getLast();
var view = mock(org.bukkit.inventory.InventoryView.class);
when(view.getTopInventory()).thenReturn(menu); when(view.getBottomInventory()).thenReturn(player.core.inventory);
when(view.getPlayer()).thenReturn(player.player); when(view.getType()).thenReturn(org.bukkit.event.inventory.InventoryType.CHEST);
when(view.getItem(27)).thenReturn(menu.getItem(27)); when(view.getInventory(27)).thenReturn(menu); when(view.convertSlot(27)).thenReturn(27);
var click = new org.bukkit.event.inventory.InventoryClickEvent(view, org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,
27, org.bukkit.event.inventory.ClickType.LEFT, org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);
f.dispatcher.accept(click); assertTrue(click.isCancelled());
ReskillEventsNativeTest.await(f, () -> storage.find(old.id()).join().orElseThrow().gear().chestplate() == GearTier.IRON
&& player.core.inventory.getChestplate() != null && player.core.inventory.getChestplate().getType() == org.bukkit.Material.IRON_CHESTPLATE);
var saved = storage.find(old.id()).get().orElseThrow();
assertEquals(balance.subtract(offer.price()), saved.coins()); assertTrue(player.core.inventory.getChestplate().getItemMeta().isUnbreakable());
var afterCatalog = market.current().toCompletableFuture().get();
assertEquals(offer.remaining() - 1, afterCatalog.offer(product).remaining());
var receipts = new java.util.HashSet<>(saved.rewardReceipts()); receipts.removeAll(previous.rewardReceipts()); assertEquals(1, receipts.size());
for (int attempt = 0; attempt < 100 && rows("armor.purchased").isEmpty(); attempt++) Thread.sleep(10);
assertEquals(1, rows("armor.purchased").size(), "Acknowledged stock, coin and gear mutation must reach one purchase owner record");
var row = rows("armor.purchased").getFirst(); var values = row.getAsJsonObject("data");
assertEquals(receipts.iterator().next().toString(), values.get("receipt_id").getAsString());
assertEquals(shop.id().toString(), values.get("armorer_id").getAsString());
assertEquals(catalog.revision().toString(), values.get("catalog_revision").getAsString());
assertEquals(player.core.id.toString(), row.get("playerid").getAsString()); assertEquals(old.id().toString(), row.get("characterid").getAsString());
assertEquals(session.toString(), values.get("session_id").getAsString());
assertEquals("chestplate.iron", values.get("product_id").getAsString());
assertEquals("none", values.get("previous_tier").getAsString()); assertEquals("iron", values.get("new_tier").getAsString());
assertEquals(offer.price().toString(), values.get("price").getAsString());
assertEquals(balance.toString(), values.get("balance_before").getAsString()); assertEquals(saved.coins().toString(), values.get("balance_after").getAsString());
assertEquals(Integer.toString(offer.remaining()), values.get("stock_before").getAsString());
assertEquals(Integer.toString(offer.remaining() - 1), values.get("stock_after").getAsString());
assertEquals(previous.revision().toString(), values.get("before_revision").getAsString()); assertEquals(saved.revision().toString(), values.get("after_revision").getAsString());
assertEquals(ports.anchor().getX(), values.getAsJsonObject("location").get("x").getAsDouble());
var duplicate = new org.bukkit.event.inventory.InventoryClickEvent(view, org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,
27, org.bukkit.event.inventory.ClickType.LEFT, org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);
f.dispatcher.accept(duplicate); assertTrue(duplicate.isCancelled()); market.current().toCompletableFuture().get(); PartyBookNativeTest.drain(f);
assertEquals(saved, storage.find(old.id()).get().orElseThrow());
PartyBookNativeTest.open(f, player); assertEquals(1, rows("armor.purchased").size());
f.plugin.setEnabled(false); f.awaitWorkers();
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain"))
.toCompletableFuture().get(5, TimeUnit.SECONDS);
}
assertEquals(1, rows("armor.purchased").size());
}
@Test void realSharedMarketMidnightPublishesOneCompleteScheduledCatalogNotPerProductRecords() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f);
var market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket");
var initial = market.current().toCompletableFuture().get();
ReskillEventsNativeTest.await(f, () -> catalogs().size() == 1);
var field = ArmorMarket.class.getDeclaredField("clock"); field.setAccessible(true);
field.set(market, java.time.Clock.fixed(initial.day().plusDays(1).atStartOfDay(java.time.ZoneOffset.UTC).toInstant(), java.time.ZoneOffset.UTC));
var next = market.current().toCompletableFuture().get();
assertEquals(initial.day().plusDays(1), next.day()); assertNotEquals(initial.revision(), next.revision());
assertEquals(ArmorCatalog.RefreshReason.SCHEDULED, next.reason());
ReskillEventsNativeTest.await(f, () -> catalogs().size() == 2);
verifyCatalog(next, catalogs().stream().filter(row -> row.getAsJsonObject("data").get("catalog_revision").getAsString().equals(next.revision().toString())).findFirst().orElseThrow());
assertEquals(next, market.current().toCompletableFuture().get());
f.plugin.setEnabled(false); f.awaitWorkers();
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain")).toCompletableFuture().get(5, TimeUnit.SECONDS);
}
assertEquals(2, catalogs().size());
}
@Test void rejectedRealPurchaseReplaysExactReceiptOfflineWithoutChargingEquippingRestockingOrFeedback() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f);
var ports = npc(f); var player = ports.player(); var old = player.core.record;
var balance = java.math.BigInteger.ONE.shiftLeft(100);
var funded = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
old.stats(), old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(),
old.cooldowns(), old.restrictedKills(), old.dead(), old.deathId(), old.diedAt(), balance, true,
old.rewardReceipts(), old.pvpCoinEscrow(), old.gear(), old.sheetGeneration());
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(funded, old.revision()); f.activate(player);
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"shop", "place", "Offline Receipt Forge"});
var directory = (games.dmg.fantasystick.shop.FantasyArmorerDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorers");
ReskillEventsNativeTest.await(f, () -> !directory.all().isEmpty() && ports.present().get() && rows("armorer.placed").size() == 1 && catalogs().size() == 1);
var shop = directory.all().getFirst();
var market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket"); var catalog = market.current().toCompletableFuture().get();
var product = new games.dmg.fantasystick.shop.ArmorProduct(GearSlot.CHESTPLATE, GearTier.IRON); var offer = catalog.offer(product);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var previous = storage.find(old.id()).get().orElseThrow();
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(player.player, ports.entity(), org.bukkit.inventory.EquipmentSlot.HAND));
ReskillEventsNativeTest.await(f, () -> !player.inventories.isEmpty()); var menu = player.inventories.getLast();
var view = mock(org.bukkit.inventory.InventoryView.class); when(view.getTopInventory()).thenReturn(menu);
when(view.getBottomInventory()).thenReturn(player.core.inventory); when(view.getPlayer()).thenReturn(player.player);
when(view.getType()).thenReturn(org.bukkit.event.inventory.InventoryType.CHEST); when(view.getItem(27)).thenReturn(menu.getItem(27));
when(view.getInventory(27)).thenReturn(menu); when(view.convertSlot(27)).thenReturn(27);
((LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close();
f.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view, org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,
27, org.bukkit.event.inventory.ClickType.LEFT, org.bukkit.event.inventory.InventoryAction.PICKUP_ALL));
ReskillEventsNativeTest.await(f, () -> player.messages.contains("Purchased Iron Chestplate.")
&& player.core.inventory.getChestplate() != null && player.core.inventory.getChestplate().getType() == org.bukkit.Material.IRON_CHESTPLATE);
var saved = storage.find(old.id()).get().orElseThrow(); assertEquals(balance.subtract(offer.price()), saved.coins());
assertEquals(GearTier.IRON, saved.gear().chestplate()); assertEquals(offer.remaining() - 1, market.current().toCompletableFuture().get().offer(product).remaining());
var receipts = new java.util.HashSet<>(saved.rewardReceipts()); receipts.removeAll(previous.rewardReceipts()); assertEquals(1, receipts.size());
f.plugin.setEnabled(false); f.awaitWorkers();
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain")).toCompletableFuture().get(5, TimeUnit.SECONDS);
var pending = new java.util.Properties();
try (var files = java.nio.file.Files.list(data.resolve("armor-event-intents"))) {
var intents = files.filter(path -> path.getFileName().toString().endsWith(".pending")).toList(); assertEquals(1, intents.size());
try (var input = java.nio.file.Files.newInputStream(intents.getFirst())) { pending.load(input); }
}
assertTrue(rows("armor.purchased").isEmpty());
var marketBytes = java.nio.file.Files.readAllBytes(data.resolve("armor-catalog.yml"));
var registrationBytes = java.nio.file.Files.readAllBytes(data.resolve("fantasy-armorers.yml"));
var character = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(old.id()).orElseThrow();
var feedback = List.copyOf(player.messages); var inventory = player.core.snapshot();
f.players.clear(); player.core.online = false; clearInvocations(player.player); clearInvocations(player.world); clearInvocations(ports.entity());
f.plugin.setEnabled(true); ready(f); ReskillEventsNativeTest.await(f, () -> rows("armor.purchased").size() == 1);
assertEquals(character, new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(old.id()).orElseThrow());
assertArrayEquals(marketBytes, java.nio.file.Files.readAllBytes(data.resolve("armor-catalog.yml")));
assertArrayEquals(registrationBytes, java.nio.file.Files.readAllBytes(data.resolve("fantasy-armorers.yml")));
assertArrayEquals(inventory, player.core.snapshot()); assertEquals(feedback, player.messages);
verifyNoInteractions(player.player, player.world, ports.entity());
var row = rows("armor.purchased").getFirst(); var values = row.getAsJsonObject("data");
assertEquals(pending.getProperty("id"), row.get("id").getAsString()); assertEquals(pending.getProperty("time"), row.get("time").getAsString());
assertEquals(pending.getProperty("action"), values.get("action_id").getAsString());
assertEquals(receipts.iterator().next().toString(), values.get("receipt_id").getAsString());
assertEquals(shop.id().toString(), values.get("armorer_id").getAsString()); assertEquals(catalog.revision().toString(), values.get("catalog_revision").getAsString());
assertEquals(balance.toString(), values.get("balance_before").getAsString()); assertEquals(saved.coins().toString(), values.get("balance_after").getAsString());
assertEquals(previous.revision().toString(), values.get("before_revision").getAsString()); assertEquals(saved.revision().toString(), values.get("after_revision").getAsString());
assertEquals("none", values.get("previous_tier").getAsString()); assertEquals("iron", values.get("new_tier").getAsString());
assertEquals(Integer.toString(offer.remaining()), values.get("stock_before").getAsString()); assertEquals(Integer.toString(offer.remaining()-1), values.get("stock_after").getAsString());
f.players.put(player.core.id, player.player); player.core.online = true;
f.reenter(player); // Restored markers need a fresh slot observation, not another unchanged-marker request.
assertEquals(org.bukkit.Material.IRON_CHESTPLATE, player.core.inventory.getChestplate().getType());
assertTrue(player.core.inventory.getChestplate().getItemMeta().isUnbreakable());
assertEquals(saved.coins(), new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(old.id()).orElseThrow().coins());
assertEquals(1, rows("armor.purchased").size(), "Reloading permanent native gear is not another purchase");
f.plugin.setEnabled(false); f.awaitWorkers();
}
assertEquals(1, rows("armor.purchased").size());
}
@Test void registeredPlacementSaveAndNativeSpawnFailuresCannotExportSuccessOrLeaveRegistration() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f); var ports = npc(f); var player = ports.player();
var directory = (games.dmg.fantasystick.shop.FantasyArmorerDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorers");
java.nio.file.Files.createDirectory(data.resolve("fantasy-armorers.yml"));
java.nio.file.Files.writeString(data.resolve("fantasy-armorers.yml/block-save"), "nonempty directory must not be replaced");
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"shop", "place", "Unsaved Forge"});
try { ReskillEventsNativeTest.await(f, () -> player.messages.contains("Fantasy Armorer placement failed; no registration or entity changed.")); }
catch (AssertionError failure) { throw new AssertionError("Expected failed save: messages=" + player.messages + "; registrations=" + directory.all(), failure); }
assertTrue(directory.all().isEmpty()); assertFalse(ports.present().get()); assertTrue(rows("armorer.placed").isEmpty());
verify(player.world, never()).spawn(any(org.bukkit.Location.class), eq(org.bukkit.entity.Villager.class),
org.mockito.ArgumentMatchers.<java.util.function.Consumer<? super org.bukkit.entity.Villager>>any());
java.nio.file.Files.delete(data.resolve("fantasy-armorers.yml/block-save"));
java.nio.file.Files.delete(data.resolve("fantasy-armorers.yml")); player.messages.clear();
doThrow(new IllegalStateException("Injected native Armorer spawn failure")).when(player.world).spawn(any(org.bukkit.Location.class),
eq(org.bukkit.entity.Villager.class), org.mockito.ArgumentMatchers.<java.util.function.Consumer<? super org.bukkit.entity.Villager>>any());
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"shop", "place", "Unmaterialized Forge"});
ReskillEventsNativeTest.await(f, () -> directory.all().isEmpty()
&& player.messages.contains("Fantasy Armorer placement failed; no registration or entity changed."));
assertTrue(new games.dmg.fantasystick.persistence.YamlFantasyArmorerRepository(data).load().isEmpty());
assertFalse(ports.present().get()); assertTrue(rows("armorer.placed").isEmpty());
f.plugin.setEnabled(false); f.awaitWorkers();
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain")).toCompletableFuture().get(5, TimeUnit.SECONDS);
}
assertTrue(rows("armorer.placed").isEmpty());
}
@Test void registeredNativeRemovalFailureRestoresExactRevisionAndCannotExportRemovedSuccess() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f); var ports = npc(f); var player = ports.player();
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"shop", "place", "Rollback Forge"});
var directory = (games.dmg.fantasystick.shop.FantasyArmorerDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorers");
ReskillEventsNativeTest.await(f, () -> !directory.all().isEmpty() && ports.present().get() && rows("armorer.placed").size() == 1);
var saved = directory.all().getFirst(); var bytes = java.nio.file.Files.readAllBytes(data.resolve("fantasy-armorers.yml"));
doThrow(new IllegalStateException("Injected native Armorer removal failure")).when(ports.entity()).remove();
try {
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"shop", "remove", saved.id().toString()});
ReskillEventsNativeTest.await(f, () -> directory.all().equals(List.of(saved))
&& player.messages.contains("Fantasy Armorer removal failed; registration and entity are unchanged."));
assertArrayEquals(bytes, java.nio.file.Files.readAllBytes(data.resolve("fantasy-armorers.yml")));
assertTrue(ports.present().get()); assertEquals(saved.revision(), directory.all().getFirst().revision());
assertTrue(rows("armorer.removed").isEmpty()); assertEquals(1, rows("armorer.placed").size());
} finally { doAnswer(call -> { ports.present().set(false); return null; }).when(ports.entity()).remove(); }
f.plugin.setEnabled(false); f.awaitWorkers();
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain")).toCompletableFuture().get(5, TimeUnit.SECONDS);
}
assertTrue(rows("armorer.removed").isEmpty());
}
@Test void realConcurrentViewsCannotOversellOrClaimCancelledSoldOutStaleAndClosedPurchasesWithFeedbackOff() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f); var ports = npc(f); var first = ports.player();
var second = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(second.core.id, second.player);
second.core.location.setWorld(first.world);
var sender = new EquipmentAdminCommandNativeTest.Sender();
for (var key : List.of("gear-stock-iron-min", "gear-stock-iron-max")) {
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", key, "1"});
ReskillEventsNativeTest.await(f, () -> sender.messages.stream().anyMatch(message -> message.startsWith("Saved " + key + "=1")));
}
f.adminCommand.execute(first.player, "fstickadmin", new String[]{"shop", "place", "Shared Last Item Forge"});
var directory = (games.dmg.fantasystick.shop.FantasyArmorerDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorers");
ReskillEventsNativeTest.await(f, () -> !directory.all().isEmpty() && ports.present().get());
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"shop", "restock"});
ReskillEventsNativeTest.await(f, () -> sender.messages.stream().anyMatch(message -> message.startsWith("Restocked all Fantasy Armorers")));
var market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket"); var catalog = market.current().toCompletableFuture().get();
var product = new games.dmg.fantasystick.shop.ArmorProduct(GearSlot.CHESTPLATE, GearTier.IRON); assertEquals(1, catalog.offer(product).remaining());
// Ordinary/native-cancelled interactions are not purchase authorization or another menu.
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(first.player, ports.entity(), org.bukkit.inventory.EquipmentSlot.HAND));
assertTrue(first.inventories.isEmpty()); assertTrue(rows("armor.purchased").isEmpty());
var balance = java.math.BigInteger.ONE.shiftLeft(100); fund(first, balance); fund(second, balance); f.activate(first); f.activate(second);
f.feedbackCommand.execute(first.player, "fstickfeedback", new String[]{"off"});
var cancelled = new org.bukkit.event.player.PlayerInteractEntityEvent(first.player, ports.entity(), org.bukkit.inventory.EquipmentSlot.HAND);
cancelled.setCancelled(true); f.dispatcher.accept(cancelled); assertTrue(first.inventories.isEmpty());
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(first.player, ports.entity(), org.bukkit.inventory.EquipmentSlot.HAND));
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(second.player, ports.entity(), org.bukkit.inventory.EquipmentSlot.HAND));
ReskillEventsNativeTest.await(f, () -> !first.inventories.isEmpty() && !second.inventories.isEmpty());
var firstView = purchaseView(first, first.inventories.getLast()); var secondView = purchaseView(second, second.inventories.getLast());
var veto = purchaseClick(firstView); veto.setCancelled(true); f.dispatcher.accept(veto); assertTrue(rows("armor.purchased").isEmpty());
f.dispatcher.accept(purchaseClick(firstView)); f.dispatcher.accept(purchaseClick(secondView));
ReskillEventsNativeTest.await(f, () -> first.messages.contains("Purchased Iron Chestplate.") && second.messages.contains("Armor purchase refused: sold out.") && rows("armor.purchased").size() == 1);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var winner = storage.find(first.core.record.id()).get().orElseThrow(); var loser = storage.find(second.core.record.id()).get().orElseThrow();
assertEquals(balance.subtract(catalog.offer(product).price()), winner.coins()); assertEquals(GearTier.IRON, winner.gear().chestplate());
assertEquals(balance, loser.coins()); assertEquals(GearTier.NONE, loser.gear().chestplate()); assertEquals(0, market.current().toCompletableFuture().get().offer(product).remaining());
assertEquals(first.core.id.toString(), rows("armor.purchased").getFirst().get("playerid").getAsString());
f.dispatcher.accept(new org.bukkit.event.inventory.InventoryCloseEvent(firstView));
assertEquals(winner, storage.find(first.core.record.id()).get().orElseThrow()); assertEquals(1, rows("armor.purchased").size());
sender.messages.clear(); f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"shop", "restock"});
ReskillEventsNativeTest.await(f, () -> sender.messages.stream().anyMatch(message -> message.startsWith("Restocked all Fantasy Armorers")));
var current = market.current().toCompletableFuture().get();
f.dispatcher.accept(purchaseClick(secondView));
ReskillEventsNativeTest.await(f, () -> second.messages.contains("Armor purchase refused: stale."));
assertEquals(loser, storage.find(second.core.record.id()).get().orElseThrow()); assertEquals(current, market.current().toCompletableFuture().get());
f.plugin.setEnabled(false); f.awaitWorkers();
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain")).toCompletableFuture().get(5, TimeUnit.SECONDS);
}
assertEquals(1, rows("armor.purchased").size());
}
@Test void failedRegisteredCatalogSaveCannotRefreshOrMutateAnySharedOffer() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f);
var market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket"); var original = market.current().toCompletableFuture().get();
ReskillEventsNativeTest.await(f, () -> catalogs().size() == 1);
var path = data.resolve("armor-catalog.yml"); var backup = data.resolve("armor-catalog.saved-test");
var bytes = java.nio.file.Files.readAllBytes(path); java.nio.file.Files.move(path, backup); java.nio.file.Files.createDirectory(path);
java.nio.file.Files.writeString(path.resolve("block-save"), "nonreplaceable save target");
try {
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"shop", "restock"});
ReskillEventsNativeTest.await(f, () -> sender.messages.contains("Fantasy Armorer restock failed; the prior catalog is unchanged."));
assertEquals(original, market.current().toCompletableFuture().get()); assertEquals(1, catalogs().size());
assertFalse(sender.messages.stream().anyMatch(message -> message.startsWith("Restocked all Fantasy Armorers")));
assertArrayEquals(bytes, java.nio.file.Files.readAllBytes(backup));
} finally { java.nio.file.Files.delete(path.resolve("block-save")); java.nio.file.Files.delete(path); java.nio.file.Files.move(backup, path); }
f.plugin.setEnabled(false); f.awaitWorkers();
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain")).toCompletableFuture().get(5, TimeUnit.SECONDS);
}
assertEquals(1, catalogs().size());
}
@Test void realPlanAndAtomicCoinGearSaveFailuresCannotClaimPurchaseAndRetainRecoverablePlan() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f); var ports = npc(f); var player = ports.player(); fund(player, java.math.BigInteger.ONE.shiftLeft(100)); f.activate(player);
f.adminCommand.execute(player.player, "fstickadmin", new String[]{"shop", "place", "Store Fault Forge"});
var directory = (games.dmg.fantasystick.shop.FantasyArmorerDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorers");
ReskillEventsNativeTest.await(f, () -> !directory.all().isEmpty() && ports.present().get());
var storage = (games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var before = storage.find(player.core.record.id()).get().orElseThrow();
var market = (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket"); var catalog = market.current().toCompletableFuture().get();
var product = new games.dmg.fantasystick.shop.ArmorProduct(GearSlot.CHESTPLATE, GearTier.IRON);
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(player.player, ports.entity(), org.bukkit.inventory.EquipmentSlot.HAND));
ReskillEventsNativeTest.await(f, () -> !player.inventories.isEmpty()); var view = purchaseView(player, player.inventories.getLast());
var nativeBefore = player.core.snapshot();
java.nio.file.Files.writeString(data.resolve("armor-purchase-operations"), "Block required operation directory");
try {
f.dispatcher.accept(purchaseClick(view));
ReskillEventsNativeTest.await(f, () -> player.messages.contains("Armor purchase refused: failed."));
assertEquals(before, storage.find(before.id()).get().orElseThrow()); assertEquals(catalog, market.current().toCompletableFuture().get());
assertArrayEquals(nativeBefore, player.core.snapshot()); assertTrue(rows("armor.purchased").isEmpty());
} finally { java.nio.file.Files.delete(data.resolve("armor-purchase-operations")); }
player.messages.clear();
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters/.records.lock"), java.nio.file.StandardOpenOption.WRITE);
var lock = channel.lock()) {
assertTrue(lock.isValid()); f.dispatcher.accept(purchaseClick(view));
ReskillEventsNativeTest.await(f, () -> player.messages.contains("Armor purchase refused: failed."));
assertEquals(before, storage.find(before.id()).get().orElseThrow()); assertArrayEquals(nativeBefore, player.core.snapshot()); assertTrue(rows("armor.purchased").isEmpty());
var reserved = market.current().toCompletableFuture().get(); assertEquals(catalog.revision(), reserved.revision());
assertEquals(1, reserved.transactions().size()); assertEquals(catalog.offer(product).remaining()-1, reserved.offer(product).remaining());
var operation = reserved.transactions().keySet().iterator().next();
assertEquals(ArmorCatalog.TransactionState.RESERVED, reserved.transactions().get(operation).state());
try (var files = java.nio.file.Files.list(data.resolve("armor-purchase-operations"))) {
var plans = files.filter(path -> path.getFileName().toString().endsWith(".yml")).toList(); assertEquals(1, plans.size());
var plan = org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(plans.getFirst().toFile());
assertEquals(operation.toString(), plan.getString("operation-id")); assertEquals(before.id().toString(), plan.getString("character"));
assertEquals(before.revision().toString(), plan.getString("expected-character-revision"));
assertEquals(catalog.offer(product).price().toString(), plan.getString("price"));
}
}
f.plugin.setEnabled(false); f.awaitWorkers();
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain")).toCompletableFuture().get(5, TimeUnit.SECONDS);
}
assertTrue(rows("armor.purchased").isEmpty());
}
private void fund(FantasyStickLifecycleNativeTest.Actor actor, java.math.BigInteger balance) throws Exception {
var old = actor.core.record;
var funded = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
old.stats(), old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(),
old.cooldowns(), old.restrictedKills(), old.dead(), old.deathId(), old.diedAt(), balance, true, old.rewardReceipts(), old.pvpCoinEscrow(), old.gear(), old.sheetGeneration());
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(funded, old.revision());
}
private static org.bukkit.inventory.InventoryView purchaseView(FantasyStickLifecycleNativeTest.Actor actor, org.bukkit.inventory.Inventory menu) {
var view = mock(org.bukkit.inventory.InventoryView.class); when(view.getTopInventory()).thenReturn(menu); when(view.getBottomInventory()).thenReturn(actor.core.inventory);
when(view.getPlayer()).thenReturn(actor.player); when(view.getType()).thenReturn(org.bukkit.event.inventory.InventoryType.CHEST);
when(view.getItem(27)).thenReturn(menu.getItem(27)); when(view.getInventory(27)).thenReturn(menu); when(view.convertSlot(27)).thenReturn(27); return view;
}
private static org.bukkit.event.inventory.InventoryClickEvent purchaseClick(org.bukkit.inventory.InventoryView view) {
return new org.bukkit.event.inventory.InventoryClickEvent(view, org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER, 27,
org.bukkit.event.inventory.ClickType.LEFT, org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);
}
private record Npc(FantasyStickLifecycleNativeTest.Actor player, org.bukkit.entity.Villager entity,
org.bukkit.Location anchor, java.util.concurrent.atomic.AtomicBoolean present) {}
private Npc npc(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
var player = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(player.core.id, player.player); doReturn(true).when(player.player).hasPermission("fantasystick.admin");
var anchor = player.core.location.clone(); int x = anchor.getBlockX() >> 4, z = anchor.getBlockZ() >> 4;
var entity = mock(org.bukkit.entity.Villager.class); var present = new java.util.concurrent.atomic.AtomicBoolean();
when(entity.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); when(entity.getLocation()).thenReturn(anchor);
when(entity.getType()).thenReturn(org.bukkit.entity.EntityType.VILLAGER);
var tags = new java.util.HashMap<org.bukkit.NamespacedKey, String>(); var pdc = mock(org.bukkit.persistence.PersistentDataContainer.class);
when(entity.getPersistentDataContainer()).thenReturn(pdc);
doAnswer(call -> { tags.put(call.getArgument(0), call.getArgument(2)); return null; }).when(pdc)
.set(any(org.bukkit.NamespacedKey.class), eq(org.bukkit.persistence.PersistentDataType.STRING), anyString());
when(pdc.get(any(org.bukkit.NamespacedKey.class), eq(org.bukkit.persistence.PersistentDataType.STRING))).thenAnswer(call -> tags.get(call.getArgument(0)));
var chunk = mock(org.bukkit.Chunk.class); when(chunk.getWorld()).thenReturn(player.world); when(chunk.getX()).thenReturn(x); when(chunk.getZ()).thenReturn(z);
when(chunk.getEntities()).thenAnswer(call -> present.get() ? new org.bukkit.entity.Entity[]{entity} : new org.bukkit.entity.Entity[0]);
when(player.world.getEntities()).thenAnswer(call -> present.get() ? List.of(entity) : List.of());
when(player.world.isChunkLoaded(x, z)).thenReturn(true); when(player.world.getChunkAt(x, z)).thenReturn(chunk);
doAnswer(call -> { java.util.function.Consumer<org.bukkit.entity.Villager> configure = call.getArgument(2);
configure.accept(entity); present.set(true); return entity; }).when(player.world).spawn(any(org.bukkit.Location.class),
eq(org.bukkit.entity.Villager.class), org.mockito.ArgumentMatchers.<java.util.function.Consumer<? super org.bukkit.entity.Villager>>any());
doAnswer(call -> { present.set(false); return null; }).when(entity).remove();
return new Npc(player, entity, anchor, present);
}
private List<JsonObject> rows(String type) {
try { return ReskillEventsNativeTest.records(data).stream()
.filter(row -> row.get("type").getAsString().endsWith(type)).toList(); }
catch (Exception failure) { throw new AssertionError("Cannot parse exported armor evidence", failure); }
}
private static void verifyCatalog(ArmorCatalog catalog, JsonObject row) {
var values = row.getAsJsonObject("data");
assertEquals(catalog.revision().toString(), values.get("catalog_revision").getAsString());
assertEquals(catalog.day().toString(), values.get("catalog_day").getAsString());
assertEquals(catalog.reason().name().toLowerCase(Locale.ROOT), values.get("refresh_reason").getAsString());
assertEquals(catalog.refreshedAt().toString(), row.get("time").getAsString());
assertEquals(catalog.day().plusDays(1).atStartOfDay(java.time.ZoneOffset.UTC).toInstant().toString(),
values.get("next_midnight").getAsString());
var products = values.getAsJsonArray("products"); assertEquals(18, products.size());
var seen = new java.util.HashSet<String>();
for (var element : products) {
var item = element.getAsJsonObject(); var id = item.get("product_id").getAsString();
assertTrue(seen.add(id), "Each product appears once in the complete publication");
var parts = id.split("\\."); assertEquals(2, parts.length);
var product = new games.dmg.fantasystick.shop.ArmorProduct(GearSlot.valueOf(parts[0].toUpperCase(Locale.ROOT)),
GearTier.valueOf(parts[1].toUpperCase(Locale.ROOT)));
var saved = catalog.offer(product);
assertEquals(saved.price().toString(), item.get("price").getAsString());
assertEquals(Integer.toString(saved.remaining()), item.get("stock").getAsString());
}
}
private static void ready(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
((LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog"))
.ready().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
private List<JsonObject> catalogs() {
try { return ReskillEventsNativeTest.records(data).stream()
.filter(row -> row.get("type").getAsString().endsWith("armor.catalog_refreshed")).toList(); }
catch (Exception failure) { throw new AssertionError("Cannot parse exported armor catalogue evidence", failure); }
}
}
@@ -0,0 +1,53 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ArmorMetadataNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
private boolean hasPlacement() {
try { return ReskillEventsNativeTest.records(data).stream().anyMatch(row -> row.get("type").getAsString().endsWith("armorer.placed")); }
catch (Exception failure) { throw new AssertionError("Cannot parse actual Armorer exports", failure); }
}
@Test void unavailableAuditActorNameCannotInterruptAuthorizedDurableRegistration() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(actor.core.id, actor.player);
doReturn(true).when(actor.player).hasPermission("fantasystick.admin");
var name = actor.player.getName();
doThrow(new IllegalStateException("Unavailable audit actor name")).when(actor.player).getName();
try {
assertDoesNotThrow(() -> f.adminCommand.execute(actor.player, "fstickadmin", new String[]{"shop", "place", "Metadata Forge"}),
"Audit actor metadata cannot interrupt an authorized Armorer registration");
var directory = (games.dmg.fantasystick.shop.FantasyArmorerDirectory)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorers");
ReskillEventsNativeTest.await(f, () -> directory.all().size() == 1);
var registration = directory.all().getFirst();
assertEquals(java.util.List.of(registration), new games.dmg.fantasystick.persistence.YamlFantasyArmorerRepository(data).load());
assertNotNull(registration.revision());
ReskillEventsNativeTest.await(f, () -> hasPlacement());
var row = ReskillEventsNativeTest.records(data).stream()
.filter(value -> value.get("type").getAsString().endsWith("armorer.placed")).findFirst().orElseThrow();
var values = row.getAsJsonObject("data");
assertEquals("system", values.get("actor_kind").getAsString(), "Common writer requires complete player identity for its player context");
assertEquals("player", values.get("request_actor_kind").getAsString());
assertEquals(actor.core.id.toString(), values.get("request_player_id").getAsString());
assertFalse(values.has("request_player_name")); assertFalse(values.has("player_name")); assertFalse(row.has("playerid"));
assertEquals("unavailable", values.get("actor_capture_quality").getAsString());
assertEquals("unloaded", values.get("chunk_status").getAsString());
assertEquals("unloaded", values.get("entity_outcome").getAsString());
assertFalse(values.has("entity_id"));
verify(actor.world, never()).getChunkAt(anyInt(), anyInt());
} finally { doReturn(name).when(actor.player).getName(); }
f.plugin.setEnabled(false); f.awaitWorkers();
}
}
}
@@ -0,0 +1,96 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import com.google.gson.JsonObject;
import games.dmg.fantasystick.events.LocalEventLog;
import games.dmg.fantasystick.persistence.YamlFantasyArmorerRepository;
import games.dmg.fantasystick.shop.FantasyArmorer;
import games.dmg.fantasystick.shop.FantasyArmorerDirectory;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Properties;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
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 ArmorRegistrationReplayNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void realLegacyStartupDoesNotRewriteOrInventRevisionAndRegisteredRemovalReportsUnavailable() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
var actor = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(actor.core.id, actor.player);
when(actor.world.getEntities()).thenReturn(List.of());
var point = actor.core.location;
var legacy = new FantasyArmorer(UUID.randomUUID(), "Legacy Forge", actor.world.getUID(), point.getX(), point.getY(), point.getZ(), point.getYaw(), point.getPitch());
var repository = new YamlFantasyArmorerRepository(data); repository.save(List.of(legacy));
var bytes = Files.readAllBytes(data.resolve("fantasy-armorers.yml"));
f.plugin.setEnabled(true);
var directory = (FantasyArmorerDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorers");
assertEquals(List.of(legacy), directory.all()); assertNull(directory.all().getFirst().revision());
assertArrayEquals(bytes, Files.readAllBytes(data.resolve("fantasy-armorers.yml")));
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"shop", "remove", legacy.id().toString()});
ReskillEventsNativeTest.await(f, () -> rows("armorer.removed").size() == 1);
var values = rows("armorer.removed").getFirst().getAsJsonObject("data");
assertEquals(legacy.id().toString(), values.get("armorer_id").getAsString());
assertEquals("unavailable", values.get("revision_capture_quality").getAsString()); assertFalse(values.has("armorer_revision"));
assertEquals("unloaded", values.get("chunk_status").getAsString()); assertEquals("none_loaded", values.get("entity_outcome").getAsString());
assertFalse(values.has("entity_id")); verify(actor.world, never()).getChunkAt(anyInt(), anyInt());
assertTrue(directory.all().isEmpty()); assertTrue(rows("armorer.placed").isEmpty());
f.plugin.setEnabled(false); f.awaitWorkers();
}
}
@Test void rejectedRealPlacementAndRemovalReplayOnlyOriginalAuditWithoutRecreatingRegistrationOrNpc() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
ReskillEventsNativeTest.await(f, () -> rows("armor.catalog_refreshed").size() == 1);
var actor = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(actor.core.id, actor.player);
doReturn(true).when(actor.player).hasPermission("fantasystick.admin"); when(actor.world.getEntities()).thenReturn(List.of());
((LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close();
var directory = (FantasyArmorerDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorers");
f.adminCommand.execute(actor.player, "fstickadmin", new String[]{"shop", "place", "Replay Forge"});
ReskillEventsNativeTest.await(f, () -> actor.messages.stream().anyMatch(message -> message.startsWith("Placed Fantasy Armorer")));
var saved = directory.all().getFirst(); assertNotNull(saved.revision());
f.adminCommand.execute(actor.player, "fstickadmin", new String[]{"shop", "remove", saved.id().toString()});
ReskillEventsNativeTest.await(f, () -> directory.all().isEmpty() && actor.messages.stream().anyMatch(message -> message.startsWith("Removed Fantasy Armorer")));
f.plugin.setEnabled(false); f.awaitWorkers();
((CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorAuditDrain")).toCompletableFuture().get(5, TimeUnit.SECONDS);
var registrations = Files.readAllBytes(data.resolve("fantasy-armorers.yml"));
var catalog = Files.readAllBytes(data.resolve("armor-catalog.yml")); var feedback = List.copyOf(actor.messages);
var intents = new java.util.ArrayList<Properties>();
try (var files = Files.list(data.resolve("armor-event-intents"))) {
for (var path : files.filter(value -> value.getFileName().toString().endsWith(".pending")).toList()) {
var properties = new Properties(); try (var input = Files.newInputStream(path)) { properties.load(input); } intents.add(properties);
}
}
assertEquals(2, intents.size()); assertTrue(rows("armorer.placed").isEmpty()); assertTrue(rows("armorer.removed").isEmpty());
f.players.clear(); actor.core.online = false; clearInvocations(actor.world); clearInvocations(actor.player);
f.plugin.setEnabled(true);
ReskillEventsNativeTest.await(f, () -> rows("armorer.placed").size() == 1 && rows("armorer.removed").size() == 1);
assertTrue(((FantasyArmorerDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorers")).all().isEmpty());
assertArrayEquals(registrations, Files.readAllBytes(data.resolve("fantasy-armorers.yml"))); assertArrayEquals(catalog, Files.readAllBytes(data.resolve("armor-catalog.yml")));
assertEquals(feedback, actor.messages); verifyNoInteractions(actor.world, actor.player);
for (var intent : intents) {
var replay = rows("armorer.placed").stream().filter(row -> row.get("id").getAsString().equals(intent.getProperty("id"))).findFirst()
.orElseGet(() -> rows("armorer.removed").stream().filter(row -> row.get("id").getAsString().equals(intent.getProperty("id"))).findFirst().orElseThrow());
assertEquals(intent.getProperty("time"), replay.get("time").getAsString());
assertEquals(intent.getProperty("action"), replay.getAsJsonObject("data").get("action_id").getAsString());
assertEquals(saved.id().toString(), replay.getAsJsonObject("data").get("armorer_id").getAsString());
assertEquals(saved.revision().toString(), replay.getAsJsonObject("data").get("armorer_revision").getAsString());
assertEquals(actor.core.id.toString(), replay.get("playerid").getAsString());
}
f.plugin.setEnabled(false); f.awaitWorkers();
}
}
private List<JsonObject> rows(String suffix) {
try { return ReskillEventsNativeTest.records(data).stream().filter(row -> row.get("type").getAsString().endsWith(suffix)).toList(); }
catch (Exception failure) { throw new AssertionError("Cannot parse Armorer replay evidence", failure); }
}
}
@@ -0,0 +1,133 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.adventure.FishingDirectory;
import games.dmg.fantasystick.adventure.FishingState;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.bukkit.entity.Villager;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class CampAttemptEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void actualChunkUnloadAndDisableResetTransientAttemptsWithoutRerollingRosterCancellingQuestOrRewarding()throws Exception{
var config=new org.bukkit.configuration.file.YamlConfiguration();config.set("adventure-camp-enemies",4);config.save(data.resolve("config.yml").toFile());
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;fixture.camp("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(v->v.startsWith("Accepted Same Camp;")));
var quest=fixture.directory.active().getFirst();var before=CombatExperienceNativeTest.record(f,actor);fixture.pulse();
ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(entity->!(entity instanceof Villager)).count()==4);
var first=fixture.spawned.stream().filter(entity->!(entity instanceof Villager)).toList();
var key=new org.bukkit.NamespacedKey(f.plugin,"adventure_attempt_id");
String original=first.getFirst().getPersistentDataContainer().get(key,org.bukkit.persistence.PersistentDataType.STRING);
var roster=List.copyOf(fixture.directory.snapshot().rosters().get(quest.site()));
f.dispatcher.accept(new org.bukkit.event.world.ChunkUnloadEvent(actor.world.getChunkAt(0,-1),false));
assertFalse(fixture.controller.hasActiveEncounter());assertTrue(first.stream().noneMatch(org.bukkit.entity.Entity::isValid));
assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());assertEquals(roster,fixture.directory.snapshot().rosters().get(quest.site()));
fixture.pulse();ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(entity->!(entity instanceof Villager)).count()==8);
var second=fixture.spawned.stream().filter(entity->!(entity instanceof Villager)).skip(4).toList();
String successor=second.getFirst().getPersistentDataContainer().get(key,org.bukkit.persistence.PersistentDataType.STRING);
assertNotEquals(original,successor);assertEquals(roster,second.stream().map(org.bukkit.entity.Entity::getType).toList());
assertEquals(before,CombatExperienceNativeTest.record(f,actor));
ReskillEventsNativeTest.await(f,()->{
try{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.camp.reset")).count()==1;}
catch(Exception failure){throw new AssertionError(failure);}
});
f.plugin.setEnabled(false);f.awaitWorkers();
((java.util.concurrent.CompletionStage<?>)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"campAuditDrain"))
.toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
// A closed writer may reject the last shutdown export; immutable audit replay, never gameplay replay, finishes it.
f.plugin.setEnabled(true);
ReskillEventsNativeTest.await(f,()->{
try{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.camp.reset")).count()==2;}
catch(Exception failure){throw new AssertionError(failure);}
});
assertFalse(((games.dmg.fantasystick.adventure.AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController")).hasActiveEncounter());
f.plugin.setEnabled(false);f.awaitWorkers();
var rows=ReskillEventsNativeTest.records(data);var resets=rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.camp.reset")).toList();
assertEquals(2,resets.size(),"Actual chunk-unload and orderly disable must each export the original transient native attempt reset");
assertEquals(java.util.Set.of(original,successor),resets.stream().map(row->row.getAsJsonObject("data").get("attempt_id").getAsString()).collect(java.util.stream.Collectors.toSet()));
assertEquals(java.util.Set.of("chunk_unload","plugin_disable"),resets.stream().map(row->row.getAsJsonObject("data").get("reason").getAsString()).collect(java.util.stream.Collectors.toSet()));
for(var row:resets){var values=row.getAsJsonObject("data");assertEquals("succeeded",values.get("native_outcome").getAsString());
assertEquals(quest.catalog().toString(),values.get("catalog_id").getAsString());assertEquals(4,values.get("roster_total").getAsInt());assertEquals(0,values.get("remaining_entity_count").getAsInt());}
assertTrue(rows.stream().noneMatch(row->row.get("type").getAsString().endsWith("adventure.camp.cleared")||row.get("type").getAsString().endsWith("adventure.trophy_earned")));
}
}
@Test void registeredPersonalCampUsesAcknowledgedRosterRealNativeAttemptAndExistingSpoilsTurnInClaimsWithoutCopies()throws Exception{
var config=new org.bukkit.configuration.file.YamlConfiguration();config.set("adventure-camp-enemies",4);
config.save(data.resolve("config.yml").toFile());
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;fixture.camp("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);
var before=CombatExperienceNativeTest.record(f,actor);fixture.click(actor,fixture.open(actor),false);
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(v->v.startsWith("Accepted Same Camp;")));
var quest=fixture.directory.active().getFirst();fixture.pulse();
ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(entity->!(entity instanceof Villager)).count()==4);
var enemies=fixture.spawned.stream().filter(entity->!(entity instanceof Villager)).toList();
var roster=fixture.directory.snapshot().rosters().get(quest.site());assertEquals(4,roster.size());
assertEquals(roster,enemies.stream().map(org.bukkit.entity.Entity::getType).toList());
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);
var outsider=new FantasyStickLifecycleNativeTest.Actor(data);outsider.core.location.setWorld(actor.world);f.players.put(outsider.core.id,outsider.player);
var rejected=new EntityDamageByEntityEvent(outsider.player,enemies.getFirst(),EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);
f.dispatcher.accept(rejected);assertTrue(rejected.isCancelled());
for(var enemy:enemies){
var hit=new EntityDamageByEntityEvent(actor.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);
when(enemy.getLastDamageCause()).thenReturn(hit);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());
var death=new org.bukkit.event.entity.EntityDeathEvent(enemy,source,new ArrayList<>(),7);f.dispatcher.accept(death);
assertTrue(death.getDrops().isEmpty());assertEquals(0,death.getDroppedExp());
f.dispatcher.accept(death); // Original duplicate native callback cannot clear or earn again.
}
var trophies=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");
ReskillEventsNativeTest.await(f,()->fixture.directory.active().isEmpty()&&trophies.trophies(quest.unit()).size()==1);
assertEquals(before,CombatExperienceNativeTest.record(f,actor),"Native clear stores spoils, never immediate XP or coins");
var trophy=trophies.trophies(quest.unit()).getFirst();assertEquals(FishingState.TrophyType.CAMP,trophy.type());
assertEquals(java.util.Set.of(quest.unit()),trophy.rewards().keySet());assertEquals(quest.id(),trophy.quest());
var giverMenu=fixture.open(actor);fixture.click(actor,giverMenu,slot(giverMenu,"Turn in quest items"),false);
var turnMenu=actor.inventories.getLast();fixture.click(actor,turnMenu,slot(turnMenu,"Redeem Spoils of"),false);
ReskillEventsNativeTest.await(f,()->trophies.trophies(quest.unit()).isEmpty()&&!trophies.unclaimed(quest.unit()).equals(FishingState.Reward.empty()));
assertEquals(before,CombatExperienceNativeTest.record(f,actor),"Exact item consumption only unlocks its frozen share");
giverMenu=fixture.open(actor);fixture.click(actor,giverMenu,slot(giverMenu,"Turn in quest items"),false);
turnMenu=actor.inventories.getLast();fixture.click(actor,turnMenu,slot(turnMenu,"Claim earned quest rewards"),false);
ReskillEventsNativeTest.await(f,()->trophies.unclaimed(quest.unit()).equals(FishingState.Reward.empty()));
var paid=CombatExperienceNativeTest.record(f,actor);assertEquals(before.experience().add(quest.rewardXp()),paid.experience());
assertEquals(before.coins().add(quest.rewardCoins()),paid.coins());
ReskillEventsNativeTest.await(f,()->{
try{var rows=ReskillEventsNativeTest.records(data);return java.util.stream.Stream.of("adventure.camp.started","adventure.camp.cleared",
"adventure.trophy_earned","adventure.trophy_redeemed","adventure.reward_claimed").allMatch(type->rows.stream().filter(row->row.get("type").getAsString().endsWith(type)).count()==1);}
catch(Exception failure){throw new AssertionError(failure);}
});
f.plugin.setEnabled(false);f.awaitWorkers();
((java.util.concurrent.CompletionStage<?>)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"campAuditDrain"))
.toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
var rows=ReskillEventsNativeTest.records(data);var started=rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.camp.started")).toList();
var cleared=rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.camp.cleared")).toList();
assertEquals(1,started.size(),"Actual saved roster and registered native spawn must export their sole transient camp attempt");
assertEquals(1,cleared.size(),"Actual final native death must export one clear, not duplicate callbacks or reward copies");
for(var row:List.of(started.getFirst(),cleared.getFirst())){var values=row.getAsJsonObject("data");
assertEquals(quest.catalog().toString(),values.get("catalog_id").getAsString());assertEquals(quest.site().toString(),values.get("site_id").getAsString());
assertEquals(trophy.source().toString(),values.get("attempt_id").getAsString());assertEquals("native_transient_not_persisted",values.get("attempt_capture_quality").getAsString());
assertEquals(4,values.get("roster_total").getAsInt());assertEquals(4,values.getAsJsonArray("roster_entries").size());
assertEquals("succeeded",values.get("native_outcome").getAsString());
}
var evidence=cleared.getFirst().getAsJsonObject("data");assertEquals(quest.id().toString(),evidence.getAsJsonArray("represented_quest_ids").get(0).getAsString());
assertEquals(enemies.getLast().getUniqueId().toString(),evidence.get("final_enemy_id").getAsString());
assertEquals(1,rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.trophy_earned")).count());
assertEquals(1,rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.trophy_redeemed")).count());
assertEquals(1,rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.reward_claimed")).count());
assertTrue(rows.stream().noneMatch(row->row.get("type").getAsString().endsWith("adventure.quest.expired")),"Trophy completion is never an expiry");
}
}
static int slot(org.bukkit.inventory.Inventory menu,String prefix){
var plain=net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText();
return java.util.stream.IntStream.range(0,menu.getSize()).filter(index->{var item=menu.getItem(index);
return item!=null&&item.getItemMeta().displayName()!=null&&plain.serialize(item.getItemMeta().displayName()).startsWith(prefix);}).findFirst().orElseThrow();
}
}
@@ -0,0 +1,88 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.adventure.AdventureQuestController;
import games.dmg.fantasystick.adventure.AdventureQuestDirectory;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Chunk;
import org.bukkit.Location;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Villager;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.Inventory;
/** Typed supported camp/giver native ports; real controllers, storage, commands and writer remain installed. */
final class CampAuditNativeFixture implements AutoCloseable {
final FantasyStickLifecycleNativeTest.Fixture f;
final FantasyStickLifecycleNativeTest.Actor actor;
final AdventureQuestDirectory directory;
final AdventureQuestController controller;
final ArrayList<LivingEntity> spawned=new ArrayList<>();
Consumer<LivingEntity> nativeSpawnSetup=ignored->{};
Consumer<LivingEntity> nativeSpawnOutcome=ignored->{};
CampAuditNativeFixture(Path data)throws Exception{
f=new FantasyStickLifecycleNativeTest.Fixture(data);f.plugin.setEnabled(true);
actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);
when(actor.world.getUID()).thenReturn(UUID.randomUUID());when(actor.world.getName()).thenReturn("Camp Realm");
when(actor.world.isChunkLoaded(anyInt(),anyInt())).thenReturn(true);
var chunk=mock(Chunk.class);when(chunk.getWorld()).thenReturn(actor.world);when(chunk.getX()).thenReturn(0);when(chunk.getZ()).thenReturn(-1);
when(chunk.getEntities()).thenAnswer(call->spawned.stream().filter(Entity::isValid).toArray(Entity[]::new));
when(actor.world.getEntities()).thenAnswer(call->new ArrayList<Entity>(spawned.stream().filter(Entity::isValid).toList()));
when(actor.world.getLoadedChunks()).thenReturn(new Chunk[]{chunk});when(actor.world.getChunkAt(anyInt(),anyInt())).thenReturn(chunk);
doAnswer(call->{
Class<? extends LivingEntity> type=call.getArgument(1);LivingEntity entity=mock(type);
var pdc=new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());
when(entity.getUniqueId()).thenReturn(UUID.randomUUID());when(entity.getPersistentDataContainer()).thenReturn(pdc);
when(entity.getWorld()).thenReturn(actor.world);Location at=call.getArgument(0);when(entity.getLocation()).thenReturn(at.clone());
when(entity.isValid()).thenReturn(true);when(entity.getVelocity()).thenReturn(new org.bukkit.util.Vector());
when(entity.getType()).thenReturn(entity instanceof Villager?org.bukkit.entity.EntityType.VILLAGER:
entity instanceof org.bukkit.entity.Husk?org.bukkit.entity.EntityType.HUSK:
entity instanceof org.bukkit.entity.Pillager?org.bukkit.entity.EntityType.PILLAGER:
entity instanceof org.bukkit.entity.Vindicator?org.bukkit.entity.EntityType.VINDICATOR:org.bukkit.entity.EntityType.WITCH);
doAnswer(removal->{when(entity.isValid()).thenReturn(false);return null;}).when(entity).remove();
Consumer<? super LivingEntity> configure=call.getArgument(2);nativeSpawnSetup.accept(entity);configure.accept(entity);nativeSpawnOutcome.accept(entity);spawned.add(entity);return entity;
}).when(actor.world).spawn(any(Location.class),org.mockito.ArgumentMatchers.<Class<LivingEntity>>any(),org.mockito.ArgumentMatchers.<Consumer<? super LivingEntity>>any());
directory=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
controller=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");
}
void camp(String mode)throws Exception{
long count=actor.messages.stream().filter(v->v.startsWith("Placed camp")).count();
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","place","camp","--mode",mode,"Same Camp"});
ReskillEventsNativeTest.await(f,()->actor.messages.stream().filter(v->v.startsWith("Placed camp")).count()==count+1);
}
void giver()throws Exception{
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"quest-giver","place","Guide"});
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(v->v.startsWith("Placed Quest Giver")));
}
void resetCatalog()throws Exception{
long count=actor.messages.stream().filter(v->v.startsWith("Reset daily adventures:")).count();
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure","reroll","confirm"});
ReskillEventsNativeTest.await(f,()->actor.messages.stream().filter(v->v.startsWith("Reset daily adventures:")).count()==count+1);
}
Inventory open(FantasyStickLifecycleNativeTest.Actor holder)throws Exception{
int count=holder.inventories.size();var giver=spawned.stream().filter(Villager.class::isInstance).filter(Entity::isValid).findFirst().orElseThrow();
var event=new org.bukkit.event.player.PlayerInteractEntityEvent(holder.player,giver,org.bukkit.inventory.EquipmentSlot.HAND);
f.dispatcher.accept(event);assertTrue(event.isCancelled());ReskillEventsNativeTest.await(f,()->holder.inventories.size()>count);
return holder.inventories.getLast();
}
void click(FantasyStickLifecycleNativeTest.Actor holder,Inventory menu,boolean right){click(holder,menu,0,right);}
void click(FantasyStickLifecycleNativeTest.Actor holder,Inventory menu,int slot,boolean right){
var view=mock(org.bukkit.inventory.InventoryView.class);
when(view.getTopInventory()).thenReturn(menu);when(view.getBottomInventory()).thenReturn(holder.core.inventory);
when(view.getPlayer()).thenReturn(holder.player);when(view.getType()).thenReturn(menu.getType());
when(view.getItem(slot)).thenReturn(menu.getItem(slot));when(view.getInventory(slot)).thenReturn(menu);
var event=new InventoryClickEvent(view,org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,slot,
right?org.bukkit.event.inventory.ClickType.RIGHT:org.bukkit.event.inventory.ClickType.LEFT,
org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);
f.dispatcher.accept(event);assertTrue(event.isCancelled());
}
void pulse(){for(var task:java.util.List.copyOf(f.pulses))task.run();}
@Override public void close()throws java.io.IOException{f.close();}
}
@@ -0,0 +1,76 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static games.dmg.fantasystick.WitchAuditTestSupport.*;
import games.dmg.fantasystick.events.CampEventBridge;
import games.dmg.fantasystick.events.LocalEventLog;
import games.dmg.fantasystick.persistence.AdventureQuestSettings;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.channels.FileChannel;
import java.nio.file.StandardOpenOption;
import java.util.List;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class CampAuditSafetyReplayNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void deniedInvalidSameValueLockedAndClosedCampSettingsCannotPublishPositiveCopies()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();var denied=new EquipmentAdminCommandNativeTest.Sender();denied.allowed=false;
var settings=(AdventureQuestSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestSettings");
var key=AdventureQuestSettings.DAILY_COUNT;
f.adminCommand.execute(denied.port,"fstickadmin",new String[]{"config",key});f.adminCommand.execute(denied.port,"fstickadmin",new String[]{"config",key,"4"});
for(String invalid:List.of("-1","21","NaN","1.5"))f.adminCommand.execute(sender.port,"fstickadmin",new String[]{"config",key,invalid});
f.adminCommand.execute(sender.port,"fstickadmin",new String[]{"config",key,"4","extra"});assertEquals("3",settings.value(key));
f.adminCommand.execute(sender.port,"fstickadmin",new String[]{"config",key,"3"});
ReskillEventsNativeTest.await(f,()->sender.messages.stream().anyMatch(value->value.startsWith("Saved "+key+"=3")));
var bytes=Files.readAllBytes(data.resolve("config.yml"));
try(var channel=FileChannel.open(data.resolve(".config.lock"),StandardOpenOption.CREATE,StandardOpenOption.WRITE);var lock=channel.lock()){
assertTrue(lock.isValid());f.adminCommand.execute(sender.port,"fstickadmin",new String[]{"config",key,"4"});
ReskillEventsNativeTest.await(f,()->sender.messages.contains("Adventure quest configuration save failed; effective values are unchanged."));
assertEquals("3",settings.value(key));assertArrayEquals(bytes,Files.readAllBytes(data.resolve("config.yml")));
}
sender.messages.clear();settings.close();f.adminCommand.execute(sender.port,"fstickadmin",new String[]{"config",key,"5"});
ReskillEventsNativeTest.await(f,()->sender.messages.contains("Adventure quest configuration save failed; effective values are unchanged."));
assertEquals("3",settings.value(key));assertArrayEquals(bytes,Files.readAllBytes(data.resolve("config.yml")));
f.adminCommand.execute(sender.port,"fstickadmin",new String[]{"config",key});
ReskillEventsNativeTest.await(f,()->{try{return configRows().size()==1;}catch(Exception failure){throw new AssertionError(failure);}});
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"campAuditDrain");assertEquals(1,configRows().size());
assertTrue(configRows().getFirst().get("type").getAsString().endsWith("config.read"));
}
}
@Test void rejectedRealCampReadAndQueuedPublicationsReplayEveryOriginalPropertyOfflineWithoutGameplayOrConfigAccess()throws Exception{
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);
((LocalEventLog)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"eventLog")).close();
String key=AdventureQuestSettings.DAILY_COUNT;
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",key});
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",key,"4"});
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",key,"5"});actor.core.location.setX(77);
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Saved "+key+"=5")));
assertEquals(before,CombatExperienceNativeTest.record(f,actor));
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"campAuditDrain");drain(f,"configurationDrain");
var original=intents(data.resolve("camp-event-intents"));assertEquals(3,original.size());assertTrue(configRows().isEmpty());
var bytes=Files.readAllBytes(data.resolve("config.yml"));var inventory=actor.core.snapshot();var messages=List.copyOf(actor.messages);
f.players.clear();clearInvocations(actor.player);f.plugin.setEnabled(true);
ReskillEventsNativeTest.await(f,()->{try{return configRows().size()==3;}catch(Exception failure){throw new AssertionError(failure);}});
assertArrayEquals(bytes,Files.readAllBytes(data.resolve("config.yml")));assertArrayEquals(inventory,actor.core.snapshot());assertEquals(messages,actor.messages);
var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");
assertEquals(before,storage.find(before.id()).get().orElseThrow());verifyNoInteractions(actor.player);
var rows=configRows();var changed=rows.stream().filter(row->row.get("type").getAsString().endsWith("config.changed")).toList();assertEquals(2,changed.size());
assertEquals(java.util.Map.of("3","4","4","5"),changed.stream().collect(java.util.stream.Collectors.toMap(
row->row.getAsJsonObject("data").get("before").getAsString(),row->row.getAsJsonObject("data").get("after").getAsString())),
"Replay preserves exact acknowledged transitions, not UUID-file arrival order");
for(var intent:original)assertOriginal(intent,rows.stream().filter(row->row.get("id").getAsString().equals(intent.getProperty("id"))).findFirst().orElseThrow());
var bridge=(CampEventBridge)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"campAuditEvents");bridge.replay();bridge.replay();
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"campAuditDrain");assertEquals(3,configRows().size());assertEquals(messages,actor.messages);
}
}
private List<com.google.gson.JsonObject> configRows()throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->
row.getAsJsonObject("data").has("key")&&CampEventBridge.ownsSetting(row.getAsJsonObject("data").get("key").getAsString())).toList();}
}
@@ -0,0 +1,60 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;
import games.dmg.fantasystick.adventure.AdventureQuestDirectory;
import java.nio.file.Path;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class CampCatalogEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void actualRegisteredConfirmedResetPublishesOneCompleteSharedUtcCatalogWithCapturedOfferSettings() throws Exception {
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(actor.core.id,actor.player);when(actor.world.getUID()).thenReturn(UUID.randomUUID());
when(actor.world.getName()).thenReturn("Catalog Realm");
var directory=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
for(int i=0;i<2;i++){
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","place","camp","--mode",i==0?"individual-only":"party-required","Same Name"});
final int count=i+1;ReskillEventsNativeTest.await(f,()->actor.messages.stream().filter(v->v.startsWith("Placed camp")).count()==count);
}
var sites=directory.snapshot().sites();
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure","reroll","confirm"});
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(v->v.startsWith("Reset daily adventures:")));
var catalog=directory.snapshot().catalog();assertNotNull(catalog);assertNull(directory.snapshot().reset());
assertEquals(Set.copyOf(sites.stream().map(v->v.id()).toList()),Set.copyOf(catalog.sites()));
ReskillEventsNativeTest.await(f,()->{
try{return ReskillEventsNativeTest.records(data).stream().anyMatch(row->row.get("type").getAsString().endsWith("adventure.catalog_refreshed")
&&row.getAsJsonObject("data").get("catalog_id").getAsString().equals(catalog.revision().toString()));}
catch(Exception failure){throw new AssertionError(failure);}
});
f.plugin.setEnabled(false);f.awaitWorkers();
((java.util.concurrent.CompletionStage<?>)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"campAuditDrain"))
.toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
var rows=ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.catalog_refreshed")
&&row.getAsJsonObject("data").get("catalog_id").getAsString().equals(catalog.revision().toString())).toList();
assertEquals(1,rows.size(),"Actual complete durable UTC catalog publication must export one owner event, not a menu or pending-reset copy");
var row=rows.getFirst();var values=row.getAsJsonObject("data");
assertEquals(catalog.revision().toString(),values.get("catalog_revision").getAsString());
assertEquals(catalog.day().toString(),values.get("utc_day").getAsString());
assertEquals(catalog.createdAt().toString(),values.get("created_at").getAsString());
assertEquals(catalog.expiresAt().toString(),values.get("next_midnight").getAsString());
assertEquals("admin_full_reroll",values.get("reason").getAsString());assertEquals("acknowledged",values.get("outcome").getAsString());
assertEquals(3,values.getAsJsonObject("offer_settings").get("adventure-daily-site-count").getAsInt());
assertEquals(2,values.getAsJsonArray("selected_sites").size());
for(var site:sites){var selected=java.util.stream.StreamSupport.stream(values.getAsJsonArray("selected_sites").spliterator(),false)
.map(v->v.getAsJsonObject()).filter(v->v.get("site_id").getAsString().equals(site.id().toString())).findFirst().orElseThrow();
assertEquals("camp",selected.get("site_kind").getAsString());assertEquals(site.participationMode().value(),selected.get("mode").getAsString());
assertEquals(site.registrationRevision().toString(),selected.get("site_revision").getAsString());
}
assertEquals(actor.core.id.toString(),row.get("playerid").getAsString());
}
}
}
@@ -0,0 +1,61 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.persistence.AdventureQuestSettings;
import java.nio.file.Path;
import java.util.List;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class CampConfigurationEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
private record Setting(String key,String before,String after,String scope){}
private static final List<Setting> SETTINGS=List.of(
new Setting(AdventureQuestSettings.DAILY_COUNT,"3","4","next_catalog"),
new Setting(AdventureQuestSettings.RADIUS,"16","24","new_sites_and_future_legacy_geometry_attempts"),
new Setting(AdventureQuestSettings.RESET_SECONDS,"30","45","future_attempts"),
new Setting(AdventureQuestSettings.CAMP_ENEMIES,"8","12","future_uncaptured_catalog_day_rosters"),
new Setting(AdventureQuestSettings.CAMP_REWARD_XP,"100","125","future_acceptances"),
new Setting(AdventureQuestSettings.CAMP_REWARD_COINS,"20","35","future_acceptances"));
@Test void allSixRegisteredCampCatalogReadsAndSavedPublicationsExportExactHistoryWithoutRewritingCurrentQuestCatalogOrGeometry()throws Exception{
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;fixture.camp("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(v->v.startsWith("Accepted Same Camp;")));
var quest=fixture.directory.active().getFirst();var catalog=fixture.directory.snapshot().catalog();var site=fixture.directory.findSite(quest.site()).orElseThrow();
var character=CombatExperienceNativeTest.record(f,actor);var settings=(AdventureQuestSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestSettings");
for(var setting:SETTINGS){
actor.core.location.setX(12);f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",setting.key()});
assertTrue(actor.messages.stream().anyMatch(value->value.startsWith(setting.key()+"="+setting.before()+" ")));
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",setting.key(),setting.after()});actor.core.location.setX(77);
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Saved "+setting.key()+"="+setting.after()+" for later")));
assertEquals(setting.after(),settings.value(setting.key()));actor.core.location.setX(12);
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",setting.key()});
}
assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());assertEquals(catalog,fixture.directory.snapshot().catalog());
assertEquals(site,fixture.directory.findSite(quest.site()).orElseThrow());assertEquals(character,CombatExperienceNativeTest.record(f,actor));
ReskillEventsNativeTest.await(f,()->{
try{return ReskillEventsNativeTest.records(data).stream().filter(row->row.getAsJsonObject("data").has("key")
&&SETTINGS.stream().anyMatch(setting->setting.key().equals(row.getAsJsonObject("data").get("key").getAsString()))).count()==18;}
catch(Exception failure){throw new AssertionError(failure);}
});
f.plugin.setEnabled(false);f.awaitWorkers();
((java.util.concurrent.CompletionStage<?>)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"campAuditDrain"))
.toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
var rows=ReskillEventsNativeTest.records(data).stream().filter(row->row.getAsJsonObject("data").has("key")
&&SETTINGS.stream().anyMatch(setting->setting.key().equals(row.getAsJsonObject("data").get("key").getAsString()))).toList();
assertEquals(18,rows.size(),"Every actual registered camp/catalog setting read and acknowledged publication must export its owning JSONL history");
for(var setting:SETTINGS){var owned=rows.stream().filter(row->row.getAsJsonObject("data").get("key").getAsString().equals(setting.key())).toList();
var reads=owned.stream().filter(row->row.get("type").getAsString().endsWith("config.read")).toList();
assertEquals(List.of(setting.before(),setting.after()),reads.stream().map(row->row.getAsJsonObject("data").get("effective").getAsString()).toList());
var changes=owned.stream().filter(row->row.get("type").getAsString().endsWith("config.changed")).toList();assertEquals(1,changes.size());
var values=changes.getFirst().getAsJsonObject("data");assertEquals(setting.before(),values.get("before").getAsString());assertEquals(setting.after(),values.get("after").getAsString());
for(var row:owned){var dataValues=row.getAsJsonObject("data");assertEquals(setting.scope(),dataValues.get("application_scope").getAsString());
assertEquals(setting.before(),dataValues.get("default").getAsString());assertEquals(12,dataValues.getAsJsonObject("location").get("x").getAsDouble());
assertEquals(actor.core.id.toString(),row.get("playerid").getAsString());assertEquals(quest.unit().toString(),row.get("characterid").getAsString());}
}
}
}
}
@@ -0,0 +1,85 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static games.dmg.fantasystick.WitchAuditTestSupport.*;
import java.nio.file.Path;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class CampMetadataFaultNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void nativeCancelledMaterializationCannotBeClaimedAsSuccessfulCampSpawnOrEarnCompletion()throws Exception{
var config=new org.bukkit.configuration.file.YamlConfiguration();config.set("adventure-camp-enemies",4);config.save(data.resolve("config.yml").toFile());
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;fixture.camp("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Accepted Same Camp;")));
var quest=fixture.directory.active().getFirst();var before=CombatExperienceNativeTest.record(f,actor);
fixture.nativeSpawnOutcome=entity->{if(!(entity instanceof org.bukkit.entity.Villager))when(entity.isValid()).thenReturn(false);};
fixture.pulse();ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(entity->!(entity instanceof org.bukkit.entity.Villager)).count()==4);
assertEquals(4,fixture.directory.snapshot().rosters().get(quest.site()).size());assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());
assertEquals(before,CombatExperienceNativeTest.record(f,actor));ReskillEventsNativeTest.await(f,()->rows("adventure.camp.started").size()==1);
var values=rows("adventure.camp.started").getFirst().getAsJsonObject("data");
assertEquals("not_materialized",values.get("native_outcome").getAsString(),"Returned native spawn/configure calls are not accepted materialization when native addition was cancelled");
assertEquals("native_not_materialized",values.get("outcome").getAsString());assertEquals(4,values.get("roster_total").getAsInt());
assertTrue(rows("adventure.camp.cleared").isEmpty());assertTrue(rows("adventure.trophy_earned").isEmpty());
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"campAuditDrain");
}
}
@Test void unavailableOptionalActorNameRetainsKnownPlayerCharacterSessionLocationAndAcknowledgedCampRevision()throws Exception{
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);
doThrow(new IllegalStateException("Unavailable optional actor name")).when(actor.player).getName();fixture.camp("individual-only");
var saved=fixture.directory.snapshot().sites().getFirst();assertNotNull(saved.registrationRevision());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
doReturn("Later restored name").when(actor.player).getName();
ReskillEventsNativeTest.await(f,()->rows("adventure.site.registered").size()==1);
var values=rows("adventure.site.registered").getFirst().getAsJsonObject("data");
assertEquals(actor.core.id.toString(),values.get("request_player_id").getAsString());assertEquals(before.id().toString(),values.get("request_character_id").getAsString());
assertTrue(values.has("request_session_id"));assertFalse(values.has("request_player_name"));assertEquals("unavailable",values.get("actor_capture_quality").getAsString());
assertEquals(12,values.getAsJsonObject("actor_location").get("x").getAsDouble());assertEquals(saved.registrationRevision().toString(),values.get("site_revision").getAsString());
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"campAuditDrain");assertEquals(1,rows("adventure.site.registered").size());
}
}
@Test void unloadedSavedGiverReportsUnavailableNativeMaterializationWithoutForceLoadingOrInventingEntityIdentity()throws Exception{
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;when(actor.world.isChunkLoaded(anyInt(),anyInt())).thenReturn(false);clearInvocations(actor.world);
fixture.giver();assertTrue(fixture.spawned.isEmpty());var saved=fixture.directory.snapshot().givers().getFirst();assertNotNull(saved.registrationRevision());
ReskillEventsNativeTest.await(f,()->rows("adventure.quest_giver.placed").size()==1);
var values=rows("adventure.quest_giver.placed").getFirst().getAsJsonObject("data");
assertEquals(saved.id().toString(),values.get("giver_id").getAsString());assertEquals(saved.registrationRevision().toString(),values.get("giver_revision").getAsString());
assertEquals("not_loaded",values.get("entity_outcome").getAsString());assertTrue(values.getAsJsonArray("entity_ids_after").isEmpty());
verify(actor.world,never()).getChunkAt(anyInt(),anyInt());
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"campAuditDrain");
}
}
@Test void nonDirectoryOwnerIntentPathCannotPreventRealSavedCampCatalogNativeClearOrExistingSpoils()throws Exception{
java.nio.file.Files.writeString(data.resolve("camp-event-intents"),"occupied");
var config=new org.bukkit.configuration.file.YamlConfiguration();config.set("adventure-camp-enemies",4);config.save(data.resolve("config.yml").toFile());
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;fixture.camp("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Accepted Same Camp;")));
var quest=fixture.directory.active().getFirst();var before=CombatExperienceNativeTest.record(f,actor);fixture.pulse();
ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(entity->!(entity instanceof org.bukkit.entity.Villager)).count()==4);
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);
for(var enemy:fixture.spawned.stream().filter(entity->!(entity instanceof org.bukkit.entity.Villager)).toList()){
var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);
when(enemy.getLastDamageCause()).thenReturn(hit);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(enemy,source,new java.util.ArrayList<>(),0));
}
var trophies=(games.dmg.fantasystick.adventure.FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");
ReskillEventsNativeTest.await(f,()->fixture.directory.active().isEmpty()&&trophies.trophies(quest.unit()).size()==1);
assertEquals(before,CombatExperienceNativeTest.record(f,actor));assertEquals("occupied",java.nio.file.Files.readString(data.resolve("camp-event-intents")));
ReskillEventsNativeTest.await(f,()->rows("adventure.trophy_earned").size()==1);
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"campAuditDrain");
assertTrue(rows("adventure.camp.started").isEmpty());assertTrue(rows("adventure.camp.cleared").isEmpty());
assertEquals(1,rows("adventure.trophy_earned").size(),"Existing trophy owner remains independent of failed camp audit storage");
}
}
private java.util.List<com.google.gson.JsonObject> rows(String type){try{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)).toList();}
catch(Exception failure){throw new AssertionError(failure);}}
}
@@ -0,0 +1,57 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static games.dmg.fantasystick.WitchAuditTestSupport.*;
import games.dmg.fantasystick.events.LocalEventLog;
import java.nio.file.Path;
import java.util.List;
import org.bukkit.entity.Villager;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class CampMultipartReplayNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void actualSavedSixtyFourEnemyNativeAttemptExportsEveryOriginalRosterEntityAndResetPartThenReplaysOfflineWithoutSpawning()throws Exception{
var config=new org.bukkit.configuration.file.YamlConfiguration();config.set("adventure-camp-enemies",64);config.save(data.resolve("config.yml").toFile());
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;fixture.camp("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Accepted Same Camp;")));
var quest=fixture.directory.active().getFirst();var before=CombatExperienceNativeTest.record(f,actor);fixture.pulse();
ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(entity->!(entity instanceof Villager)).count()==64);
var enemies=fixture.spawned.stream().filter(entity->!(entity instanceof Villager)).toList();var roster=fixture.directory.snapshot().rosters().get(quest.site());
assertEquals(64,roster.size());assertEquals(roster,enemies.stream().map(org.bukkit.entity.Entity::getType).toList());
ReskillEventsNativeTest.await(f,()->rows("adventure.camp.started").size()==4);
var starts=rows("adventure.camp.started");assertEquals(4,starts.stream().map(row->row.get("id").getAsString()).distinct().count());
var entries=new java.util.TreeMap<Integer,String>();var ids=new java.util.HashSet<String>();
for(var row:starts){var values=row.getAsJsonObject("data");assertEquals(4,values.get("part_count").getAsInt());assertEquals(64,values.get("roster_total").getAsInt());
for(var entry:values.getAsJsonArray("roster_entries")){var fact=entry.getAsJsonObject();assertNull(entries.put(fact.get("roster_index").getAsInt(),fact.get("entity_type").getAsString()));}
for(var entity:values.getAsJsonArray("native_entities"))assertTrue(ids.add(entity.getAsJsonObject().get("entity_id").getAsString()));}
assertEquals(roster.stream().map(Enum::name).toList(),List.copyOf(entries.values()));
assertEquals(enemies.stream().map(entity->entity.getUniqueId().toString()).collect(java.util.stream.Collectors.toSet()),ids);
((LocalEventLog)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"eventLog")).close();
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"campAuditDrain");
var originals=intents(data.resolve("camp-event-intents"));assertEquals(4,originals.size());
assertTrue(originals.stream().allMatch(intent->intent.getProperty("type").equals("adventure.camp.reset")));
var bytes=java.nio.file.Files.readAllBytes(data.resolve("adventure-quests.yml"));var inventory=actor.core.snapshot();var messages=List.copyOf(actor.messages);
f.players.clear();clearInvocations(actor.player);f.plugin.setEnabled(true);ReskillEventsNativeTest.await(f,()->rows("adventure.camp.reset").size()==4);
var resets=rows("adventure.camp.reset");for(var original:originals){
var decoded=new java.util.Properties();decoded.putAll(original);
// Existing legacy slot-list encoding also represents empty lists; normalize only that exact empty value for this reader.
for(String key:decoded.stringPropertyNames())if(key.startsWith("data.")&&decoded.getProperty(key).equals("l"))decoded.setProperty(key,"j[]");
assertOriginal(decoded,resets.stream().filter(row->row.get("id").getAsString().equals(original.getProperty("id"))).findFirst().orElseThrow());
}
assertArrayEquals(bytes,java.nio.file.Files.readAllBytes(data.resolve("adventure-quests.yml")));assertArrayEquals(inventory,actor.core.snapshot());assertEquals(messages,actor.messages);verifyNoInteractions(actor.player);
var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(before,storage.find(before.id()).get().orElseThrow());
assertFalse(((games.dmg.fantasystick.adventure.AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController")).hasActiveEncounter());
assertEquals(64,fixture.spawned.stream().filter(entity->!(entity instanceof Villager)).count());
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"campAuditDrain");assertEquals(4,rows("adventure.camp.reset").size());
assertTrue(rows("adventure.camp.cleared").isEmpty());assertTrue(rows("adventure.trophy_earned").isEmpty());
}
}
private List<com.google.gson.JsonObject> rows(String type){try{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)).toList();}
catch(Exception failure){throw new AssertionError(failure);}}
}
@@ -0,0 +1,148 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static games.dmg.fantasystick.CampAttemptEventsNativeTest.slot;
import games.dmg.fantasystick.adventure.AdventureQuestState;
import java.nio.file.Path;
import java.util.Set;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
class CampPartyEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void mutedRegisteredPartyClearExcludesCancelledSoloAndAddedMemberThenRedeemsOfflineLeadersFrozenShare()throws Exception{
var config=new org.bukkit.configuration.file.YamlConfiguration();config.set("adventure-camp-enemies",4);config.save(data.resolve("config.yml").toFile());
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var leader=fixture.actor;f.activate(leader);
var member=new FantasyStickLifecycleNativeTest.Actor(data);member.core.location.setWorld(leader.world);f.players.put(member.core.id,member.player);f.activate(member);
var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(leader.core.id).orElseThrow();
assertTrue(parties.accept(member.core.id,parties.invite(leader.core.id,party.id(),member.core.id).orElseThrow().id()));
fixture.camp("party-available");fixture.giver();fixture.resetCatalog();fixture.click(leader,fixture.open(leader),true);
ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();
var solo=new FantasyStickLifecycleNativeTest.Actor(data);solo.core.location.setWorld(leader.world);f.players.put(solo.core.id,solo.player);f.activate(solo);
fixture.click(solo,fixture.open(solo),false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==2);
var soloQuest=fixture.directory.active().stream().filter(value->value.unitType()==AdventureQuestState.UnitType.SOLO).findFirst().orElseThrow();
var added=new FantasyStickLifecycleNativeTest.Actor(data);added.core.location.setWorld(leader.world);f.players.put(added.core.id,added.player);f.activate(added);
assertTrue(parties.accept(added.core.id,parties.invite(leader.core.id,party.id(),added.core.id).orElseThrow().id()));
assertFalse(quest.snapshot().contains(added.core.record.id()));
for(var holder:java.util.List.of(leader,member)){
assertTrue(f.playerCommand.execute(holder.player,"fstick",new String[]{"feedback","off"}));
ReskillEventsNativeTest.await(f,()->holder.messages.contains("Fantasy feedback is now off."));
}
var preferences=(games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"feedbackPreferences");
assertFalse(preferences.enabled(leader.core.id));assertFalse(preferences.enabled(member.core.id));
var leaderBefore=CombatExperienceNativeTest.record(f,leader);var memberBefore=CombatExperienceNativeTest.record(f,member);
var addedBefore=CombatExperienceNativeTest.record(f,added);var soloBefore=CombatExperienceNativeTest.record(f,solo);
fixture.pulse();ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(entity->!(entity instanceof org.bukkit.entity.Villager)).count()==4);
var enemies=fixture.spawned.stream().filter(entity->!(entity instanceof org.bukkit.entity.Villager)).toList();
var cancelledSource=mock(org.bukkit.damage.DamageSource.class);when(cancelledSource.getCausingEntity()).thenReturn(solo.player);when(cancelledSource.getDirectEntity()).thenReturn(solo.player);
var cancelled=new org.bukkit.event.entity.EntityDamageByEntityEvent(solo.player,enemies.getFirst(),org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,cancelledSource,1);
cancelled.setCancelled(true);f.dispatcher.accept(cancelled);assertTrue(cancelled.isCancelled());
var rejectedSource=mock(org.bukkit.damage.DamageSource.class);when(rejectedSource.getCausingEntity()).thenReturn(added.player);when(rejectedSource.getDirectEntity()).thenReturn(added.player);
var rejected=new org.bukkit.event.entity.EntityDamageByEntityEvent(added.player,enemies.getFirst(),org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,rejectedSource,1);
f.dispatcher.accept(rejected);assertTrue(rejected.isCancelled());
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(member.player);when(source.getDirectEntity()).thenReturn(member.player);
doReturn(false).when(leader.player).isOnline();
for(var enemy:enemies){
var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(member.player,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);
when(enemy.getLastDamageCause()).thenReturn(hit);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());
var death=new org.bukkit.event.entity.EntityDeathEvent(enemy,source,new java.util.ArrayList<>(),0);f.dispatcher.accept(death);f.dispatcher.accept(death);
}
var trophies=(games.dmg.fantasystick.adventure.FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");
ReskillEventsNativeTest.await(f,()->fixture.directory.findQuest(quest.id()).isEmpty()&&trophies.trophies(leaderBefore.id()).size()==1);
var trophy=trophies.trophies(leaderBefore.id()).getFirst();assertEquals(quest.id(),trophy.quest());assertEquals(party.id(),quest.unit());
assertEquals(Set.of(memberBefore.id()),trophy.rewards().keySet());assertEquals(quest.rewardXp(),trophy.total().xp());assertEquals(quest.rewardCoins(),trophy.total().coins());
assertEquals(quest.snapshot(),Set.of(leaderBefore.id(),memberBefore.id()));assertEquals(soloQuest,fixture.directory.findQuest(soloQuest.id()).orElseThrow());
assertEquals(leaderBefore,CombatExperienceNativeTest.record(f,leader));assertEquals(memberBefore,CombatExperienceNativeTest.record(f,member));
assertEquals(addedBefore,CombatExperienceNativeTest.record(f,added));assertEquals(soloBefore,CombatExperienceNativeTest.record(f,solo));
doReturn(true).when(leader.player).isOnline();var menu=fixture.open(leader);fixture.click(leader,menu,slot(menu,"Turn in quest items"),false);
menu=leader.inventories.getLast();fixture.click(leader,menu,slot(menu,"Redeem Spoils of"),false);
ReskillEventsNativeTest.await(f,()->trophies.trophies(leaderBefore.id()).isEmpty()&&!trophies.unclaimed(memberBefore.id()).equals(games.dmg.fantasystick.adventure.FishingState.Reward.empty()));
assertEquals(leaderBefore,CombatExperienceNativeTest.record(f,leader));assertEquals(memberBefore,CombatExperienceNativeTest.record(f,member));
menu=fixture.open(member);fixture.click(member,menu,slot(menu,"Turn in quest items"),false);menu=member.inventories.getLast();fixture.click(member,menu,slot(menu,"Claim earned quest rewards"),false);
ReskillEventsNativeTest.await(f,()->trophies.unclaimed(memberBefore.id()).equals(games.dmg.fantasystick.adventure.FishingState.Reward.empty()));
var paid=CombatExperienceNativeTest.record(f,member);assertEquals(memberBefore.experience().add(quest.rewardXp()),paid.experience());assertEquals(memberBefore.coins().add(quest.rewardCoins()),paid.coins());
assertEquals(leaderBefore,CombatExperienceNativeTest.record(f,leader));assertEquals(addedBefore,CombatExperienceNativeTest.record(f,added));assertEquals(soloBefore,CombatExperienceNativeTest.record(f,solo));
ReskillEventsNativeTest.await(f,()->{
try{var rows=ReskillEventsNativeTest.records(data);return java.util.stream.Stream.of("adventure.camp.started","adventure.camp.cleared","adventure.trophy_earned","adventure.trophy_redeemed","adventure.reward_claimed")
.allMatch(type->rows.stream().filter(row->row.get("type").getAsString().endsWith(type)).count()==1);}
catch(Exception failure){throw new AssertionError(failure);}
});
var rows=ReskillEventsNativeTest.records(data);var cleared=rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.camp.cleared")).findFirst().orElseThrow().getAsJsonObject("data");
var represented=cleared.getAsJsonArray("represented_units");assertEquals(1,represented.size());var unit=represented.get(0).getAsJsonObject();
assertEquals(quest.id().toString(),unit.get("quest_id").getAsString());assertEquals(party.id().toString(),unit.get("unit_id").getAsString());assertEquals("party",unit.get("unit_kind").getAsString());
assertEquals(trophy.source().toString(),cleared.get("attempt_id").getAsString());assertFalse(preferences.enabled(member.core.id));
assertTrue(rows.stream().noneMatch(row->row.get("type").getAsString().endsWith("adventure.quest.expired")));
f.plugin.setEnabled(false);f.awaitWorkers();games.dmg.fantasystick.WitchAuditTestSupport.drain(f,"campAuditDrain");
}
}
@ParameterizedTest @ValueSource(strings={"individual-only","party-available","party-required"})
void registeredExplicitModesKeepCharacterOrPersistentPartyAuthorityFrozenAcrossTransferAdditionAndDisband(String mode)throws Exception{
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var leader=fixture.actor;var member=new FantasyStickLifecycleNativeTest.Actor(data);
member.core.location.setWorld(leader.world);f.players.put(member.core.id,member.player);f.activate(leader);f.activate(member);
var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(leader.core.id).orElseThrow();
assertTrue(parties.accept(member.core.id,parties.invite(leader.core.id,party.id(),member.core.id).orElseThrow().id()));
// Durable US-052 party is a completed prerequisite; camp administration and all acceptance choices use actual registered interfaces.
fixture.camp(mode);fixture.giver();fixture.resetCatalog();
boolean personal=mode.equals("individual-only");
var leaderBefore=CombatExperienceNativeTest.record(f,leader);var memberBefore=CombatExperienceNativeTest.record(f,member);
if(personal){fixture.click(leader,fixture.open(leader),true);
assertTrue(leader.messages.contains("Quest acceptance refused: this site is individual-only."));assertTrue(fixture.directory.active().isEmpty());
fixture.click(member,fixture.open(member),false);
}else{
fixture.click(member,fixture.open(member),true);assertTrue(member.messages.contains("Only the active party leader may accept a party quest."));
assertTrue(fixture.directory.active().isEmpty());
if(mode.equals("party-required")){fixture.click(leader,fixture.open(leader),false);
assertTrue(leader.messages.contains("Quest acceptance refused: this site is party-required."));assertTrue(fixture.directory.active().isEmpty());}
fixture.click(leader,fixture.open(leader),true);
}
var accepting=personal?member:leader;
ReskillEventsNativeTest.await(f,()->accepting.messages.stream().anyMatch(value->value.startsWith("Accepted Same Camp;")));
var quest=fixture.directory.active().getFirst();assertEquals(mode,quest.participationMode().value());
assertEquals(personal?AdventureQuestState.UnitType.SOLO:AdventureQuestState.UnitType.PARTY,quest.unitType());
assertEquals(personal?member.core.record.id():party.id(),quest.unit());
assertEquals(personal?Set.of(member.core.record.id()):Set.of(leader.core.record.id(),member.core.record.id()),quest.snapshot());
var durable=(games.dmg.fantasystick.party.CharacterPartyDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterParties");
assertEquals(games.dmg.fantasystick.party.CharacterPartyDirectory.Status.APPLIED,
durable.transfer(leader.core.record.id(),party.id(),member.core.record.id()).get(5,java.util.concurrent.TimeUnit.SECONDS).status());
var added=new FantasyStickLifecycleNativeTest.Actor(data);added.core.location.setWorld(leader.world);f.players.put(added.core.id,added.player);f.activate(added);
assertTrue(parties.accept(added.core.id,parties.invite(member.core.id,party.id(),added.core.id).orElseThrow().id()));
assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());assertFalse(quest.snapshot().contains(added.core.record.id()));
assertTrue(parties.disband(member.core.id,party.id()));fixture.pulse();
if(personal)assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());
else ReskillEventsNativeTest.await(f,()->fixture.directory.findQuest(quest.id()).isEmpty());
assertEquals(leaderBefore,CombatExperienceNativeTest.record(f,leader));assertEquals(memberBefore,CombatExperienceNativeTest.record(f,member));
ReskillEventsNativeTest.await(f,()->{
try{var rows=ReskillEventsNativeTest.records(data);return rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.quest.accepted")).count()==1
&&(personal||rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.quest.expired")).count()==1);}
catch(Exception failure){throw new AssertionError(failure);}
});
f.plugin.setEnabled(false);f.awaitWorkers();
((java.util.concurrent.CompletionStage<?>)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"campAuditDrain"))
.toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
var rows=ReskillEventsNativeTest.records(data);var accepted=rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.quest.accepted")).toList();
assertEquals(1,accepted.size());var values=accepted.getFirst().getAsJsonObject("data");
assertEquals(quest.id().toString(),values.get("quest_id").getAsString());assertEquals(mode,values.get("captured_mode").getAsString());
assertEquals(quest.unit().toString(),values.get("unit_id").getAsString());assertEquals(personal?"solo":"party",values.get("unit_kind").getAsString());
assertEquals(quest.snapshot().stream().map(Object::toString).collect(java.util.stream.Collectors.toSet()),
java.util.stream.StreamSupport.stream(values.getAsJsonArray("accepted_roster").spliterator(),false).map(v->v.getAsString()).collect(java.util.stream.Collectors.toSet()));
assertEquals(accepting.core.id.toString(),accepted.getFirst().get("playerid").getAsString());
if(personal){assertFalse(values.has("party_id"));assertFalse(values.has("accepting_leader_character_id"));}
else{assertEquals(party.id().toString(),values.get("party_id").getAsString());
assertEquals(leader.core.record.id().toString(),values.get("accepting_leader_character_id").getAsString());}
var expired=rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.quest.expired")).toList();assertEquals(personal?0:1,expired.size());
if(!personal){var ended=expired.getFirst().getAsJsonObject("data");assertEquals("party_invalidated",ended.get("reason").getAsString());
assertEquals(quest.id().toString(),ended.get("quest_id").getAsString());assertEquals(mode,ended.get("captured_mode").getAsString());
assertEquals(values.getAsJsonArray("accepted_roster"),ended.getAsJsonArray("accepted_roster"));}
assertTrue(rows.stream().noneMatch(row->row.get("type").getAsString().endsWith("adventure.trophy_earned")||row.get("type").getAsString().endsWith("adventure.reward_claimed")));
}
}
}
@@ -0,0 +1,78 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Path;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class CampQuestEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void registeredFullRerollExpiresOnlyTheUnfinishedCampAuthorityWithoutRewritingCapturedModeOrRewarding()throws Exception{
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;fixture.camp("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);
var before=CombatExperienceNativeTest.record(f,actor);fixture.click(actor,fixture.open(actor),false);
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(v->v.startsWith("Accepted Same Camp;")));
var quest=fixture.directory.active().getFirst();
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","mode",quest.site().toString(),"party-required"});
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(v->v.startsWith("Updated adventure site")));
assertEquals("individual-only",fixture.directory.findQuest(quest.id()).orElseThrow().participationMode().value());
fixture.resetCatalog();assertTrue(fixture.directory.active().isEmpty());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
ReskillEventsNativeTest.await(f,()->{
try{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.quest.expired")).count()==1;}
catch(Exception failure){throw new AssertionError(failure);}
});
f.plugin.setEnabled(false);f.awaitWorkers();
((java.util.concurrent.CompletionStage<?>)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"campAuditDrain"))
.toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
var rows=ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.quest.expired")).toList();
assertEquals(1,rows.size(),"An actual registered full-day reset must export the acknowledged expiry of its original unfinished camp quest");
var values=rows.getFirst().getAsJsonObject("data");
assertEquals(quest.id().toString(),values.get("quest_id").getAsString());assertEquals(quest.catalog().toString(),values.get("catalog_id").getAsString());
assertEquals(quest.unit().toString(),values.get("unit_id").getAsString());assertEquals("individual-only",values.get("captured_mode").getAsString());
assertEquals("full_day_reroll",values.get("reason").getAsString());assertEquals(quest.expiresAt().toString(),values.get("deadline").getAsString());
assertTrue(ReskillEventsNativeTest.records(data).stream().noneMatch(row->row.get("type").getAsString().endsWith("adventure.reward_claimed")
||row.get("type").getAsString().endsWith("adventure.trophy_earned")));
}
}
@Test void registeredGiverPersonalAcceptanceAndAbandonmentExportTheCapturedCharacterSessionAndQuestAuthority()throws Exception{
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;fixture.camp("party-available");fixture.giver();fixture.resetCatalog();f.activate(actor);
var before=CombatExperienceNativeTest.record(f,actor);var menu=fixture.open(actor);
assertEquals(org.bukkit.Material.PAPER,menu.getItem(0).getType());
fixture.click(actor,menu,false);actor.core.location.setX(13);
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(v->v.startsWith("Accepted Same Camp;")));
var quest=fixture.directory.active().getFirst();assertEquals(actor.core.record.id(),quest.unit());
assertEquals(before,CombatExperienceNativeTest.record(f,actor));
var acceptedMenu=fixture.open(actor);fixture.click(actor,acceptedMenu,true);
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(v->v.equals("Adventure quest abandoned.")));
assertTrue(fixture.directory.active().isEmpty());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
ReskillEventsNativeTest.await(f,()->{
try{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.quest.accepted")
||row.get("type").getAsString().endsWith("adventure.quest.abandoned")).count()==2;}
catch(Exception failure){throw new AssertionError(failure);}
});
f.plugin.setEnabled(false);f.awaitWorkers();
((java.util.concurrent.CompletionStage<?>)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"campAuditDrain"))
.toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
var rows=ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.quest.accepted")
||row.get("type").getAsString().endsWith("adventure.quest.abandoned")).toList();
assertEquals(2,rows.size(),"Actual registered personal camp acceptance and acknowledged abandonment must reach their owning JSONL records");
for(var row:rows){var values=row.getAsJsonObject("data");
assertEquals(quest.id().toString(),values.get("quest_id").getAsString());assertEquals(quest.catalog().toString(),values.get("catalog_id").getAsString());
assertEquals(quest.site().toString(),values.get("site_id").getAsString());assertEquals("solo",values.get("unit_kind").getAsString());
assertEquals(quest.unit().toString(),values.get("unit_id").getAsString());assertEquals("party-available",values.get("captured_mode").getAsString());
assertEquals(quest.expiresAt().toString(),values.get("deadline").getAsString());assertEquals("acknowledged",values.get("outcome").getAsString());
assertEquals(actor.core.id.toString(),row.get("playerid").getAsString());assertEquals(quest.unit().toString(),row.get("characterid").getAsString());
assertEquals(java.util.Set.of(quest.unit().toString()),java.util.stream.StreamSupport.stream(values.getAsJsonArray("accepted_roster").spliterator(),false)
.map(v->v.getAsString()).collect(java.util.stream.Collectors.toSet()));
}
var accepted=rows.stream().filter(row->row.get("type").getAsString().endsWith("accepted")).findFirst().orElseThrow();
assertEquals(12,accepted.getAsJsonObject("data").getAsJsonObject("location").get("x").getAsDouble());
assertTrue(actor.messages.stream().filter(v->v.startsWith("Accepted Same Camp;")).noneMatch(v->v.contains("(12, 70, -8)")||v.contains("x=12")||v.contains(quest.site().toString())));
}
}
}
@@ -0,0 +1,91 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;
import games.dmg.fantasystick.adventure.AdventureQuestDirectory;
import games.dmg.fantasystick.persistence.YamlAdventureQuestRepository;
import java.nio.file.Path;
import java.util.Map;
import java.util.UUID;
import org.bukkit.configuration.file.YamlConfiguration;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class CampRegistrationRevisionNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void acknowledgedModeChangeAdvancesRevisionWhileUnchangedStaleAndFailedWritesRetainIt() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(actor.core.id, actor.player);
when(actor.world.getUID()).thenReturn(UUID.randomUUID());
when(actor.world.getName()).thenReturn("Revision Realm");
var directory = (AdventureQuestDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "adventureQuests");
f.adminCommand.execute(actor.player, "fstickadmin", new String[]{"adventure-site", "place", "camp", "Original"});
ReskillEventsNativeTest.await(f, () -> actor.messages.stream().anyMatch(v -> v.startsWith("Placed camp")));
var original = directory.snapshot().sites().getFirst();
f.adminCommand.execute(actor.player, "fstickadmin", new String[]{"adventure-site", "mode", original.id().toString(), "individual-only"});
ReskillEventsNativeTest.await(f, () -> actor.messages.stream().anyMatch(v -> v.startsWith("Updated adventure site")));
var changed = directory.findSite(original.id()).orElseThrow();
assertNotNull(changed.registrationRevision(), "Acknowledged camp mode publication must retain an independent revision");
assertNotEquals(original.registrationRevision(), changed.registrationRevision());
assertEquals(original.id(), changed.id());
assertEquals(changed, new YamlAdventureQuestRepository(data).load().sites().getFirst());
assertEquals(AdventureQuestDirectory.ModeStatus.UNCHANGED, directory.setSiteMode(changed.id(),
changed.participationMode(), changed.participationMode()).get(5, java.util.concurrent.TimeUnit.SECONDS).status());
assertEquals(AdventureQuestDirectory.ModeStatus.STALE, directory.setSiteMode(changed.id(),
original.participationMode(), games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.PARTY_REQUIRED)
.get(5, java.util.concurrent.TimeUnit.SECONDS).status());
assertEquals(changed, directory.findSite(changed.id()).orElseThrow());
var file = data.resolve("adventure-quests.yml"); var saved = java.nio.file.Files.readAllBytes(file);
java.nio.file.Files.delete(file); java.nio.file.Files.createDirectory(file);
assertThrows(java.util.concurrent.ExecutionException.class, () -> directory.setSiteMode(changed.id(),
changed.participationMode(), games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.PARTY_REQUIRED)
.get(5, java.util.concurrent.TimeUnit.SECONDS));
assertEquals(changed, directory.findSite(changed.id()).orElseThrow());
java.nio.file.Files.delete(file); java.nio.file.Files.write(file, saved);
var removed = directory.removeSite(changed.id()).get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
assertEquals(changed, removed); assertTrue(directory.findSite(changed.id()).isEmpty());
f.plugin.setEnabled(false); f.awaitWorkers();
}
}
@Test void registeredCampAndUnloadedGiverPersistIndependentRevisionsBeforeSuccessAndReload() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data); actor.allowed = true;
f.players.put(actor.core.id, actor.player);
UUID world = UUID.randomUUID(); when(actor.world.getUID()).thenReturn(world);
when(actor.world.getName()).thenReturn("Revision Realm");
var directory = (AdventureQuestDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "adventureQuests");
assertTrue(f.adminCommand.getExecutor().onCommand(actor.player, f.adminCommand, "fstickadmin",
new String[]{"adventure-site", "place", "camp", "--mode", "individual-only", "8", "16", "Same Name"}));
ReskillEventsNativeTest.await(f, () -> actor.messages.stream().anyMatch(v -> v.startsWith("Placed camp")));
var site = directory.snapshot().sites().getFirst();
Map<?, ?> row = YamlConfiguration.loadConfiguration(data.resolve("adventure-quests.yml").toFile())
.getMapList("sites").getFirst();
assertNotNull(row.get("registration-revision"),
"Actual registered camp save must persist its independent definition revision before success");
UUID revision = UUID.fromString(row.get("registration-revision").toString());
assertNotEquals(site.id(), revision);
assertEquals(site, new YamlAdventureQuestRepository(data).load().sites().getFirst());
assertTrue(f.adminCommand.getExecutor().onCommand(actor.player, f.adminCommand, "fstickadmin",
new String[]{"quest-giver", "place", "Same Name"}));
ReskillEventsNativeTest.await(f, () -> actor.messages.stream().anyMatch(v -> v.startsWith("Placed Quest Giver")));
var giver = directory.snapshot().givers().getFirst();
var yaml = YamlConfiguration.loadConfiguration(data.resolve("adventure-quests.yml").toFile());
var giverRow = yaml.getMapList("givers").getFirst();
assertNotNull(giverRow.get("registration-revision"));
UUID giverRevision = UUID.fromString(giverRow.get("registration-revision").toString());
assertNotEquals(giver.id(), giverRevision); assertNotEquals(revision, giverRevision);
assertEquals(giver, new YamlAdventureQuestRepository(data).load().givers().getFirst());
assertEquals(revision.toString(), yaml.getMapList("sites").getFirst().get("registration-revision"));
assertEquals(2, yaml.getInt("schema-version"));
f.plugin.setEnabled(false); f.awaitWorkers();
}
}
}
@@ -0,0 +1,111 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;
import games.dmg.fantasystick.adventure.AdventureQuestDirectory;
import java.nio.file.Path;
import java.util.UUID;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class CampSiteEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void sameNameCampModeAndRemovalExportOnlyTheirActualAcknowledgedIdentityAndRetainFailedState() throws Exception {
try (var f=new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(actor.core.id,actor.player);when(actor.world.getUID()).thenReturn(UUID.randomUUID());
when(actor.world.getName()).thenReturn("Camp Realm");
var directory=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
for(int i=0;i<2;i++){
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","place","camp","Same Name"});
final int expected=i+1;ReskillEventsNativeTest.await(f,()->directory.snapshot().sites().size()==expected);
}
var first=directory.snapshot().sites().getFirst();var other=directory.snapshot().sites().getLast();
var console=new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(console.port,"fstickadmin",new String[]{"adventure-site","mode",first.id().toString(),"individual-only"});
ReskillEventsNativeTest.await(f,()->console.messages.stream().anyMatch(v->v.startsWith("Updated adventure site")));
var changed=directory.findSite(first.id()).orElseThrow();
f.adminCommand.execute(console.port,"fstickadmin",new String[]{"adventure-site","mode",first.id().toString(),"individual-only"});
ReskillEventsNativeTest.await(f,()->console.messages.stream().anyMatch(v->v.contains("already individual-only")));
var file=data.resolve("adventure-quests.yml");var bytes=java.nio.file.Files.readAllBytes(file);
java.nio.file.Files.delete(file);java.nio.file.Files.createDirectory(file);
f.adminCommand.execute(console.port,"fstickadmin",new String[]{"adventure-site","mode",first.id().toString(),"party-required"});
ReskillEventsNativeTest.await(f,()->console.messages.stream().anyMatch(v->v.startsWith("Adventure site mode save failed")));
assertEquals(changed,directory.findSite(first.id()).orElseThrow());
java.nio.file.Files.delete(file);java.nio.file.Files.write(file,bytes);
f.adminCommand.execute(console.port,"fstickadmin",new String[]{"adventure-site","remove",first.id().toString()});
ReskillEventsNativeTest.await(f,()->console.messages.stream().anyMatch(v->v.startsWith("Removed adventure site")));
assertTrue(directory.findSite(first.id()).isEmpty());assertEquals(other,directory.findSite(other.id()).orElseThrow());
ReskillEventsNativeTest.await(f,()->{
try{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.site.mode_changed")
||row.get("type").getAsString().endsWith("adventure.site.removed")).count()==2;}
catch(Exception failure){throw new AssertionError(failure);}
});
f.plugin.setEnabled(false);f.awaitWorkers();
((java.util.concurrent.CompletionStage<?>)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"campAuditDrain"))
.toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
var rows=ReskillEventsNativeTest.records(data).stream().filter(row->
row.get("type").getAsString().endsWith("adventure.site.mode_changed")||row.get("type").getAsString().endsWith("adventure.site.removed")).toList();
assertEquals(2,rows.size(),"Actual changed mode and removed camp must each export one owner outcome, never unchanged or failed copies");
var mode=rows.stream().filter(row->row.get("type").getAsString().endsWith("mode_changed")).findFirst().orElseThrow().getAsJsonObject("data");
assertEquals(first.id().toString(),mode.get("site_id").getAsString());
assertEquals(changed.registrationRevision().toString(),mode.get("site_revision").getAsString());
assertEquals("party-available",mode.get("mode_before").getAsString());assertEquals("individual-only",mode.get("mode_after").getAsString());
assertEquals("future_acceptances",mode.get("application_scope").getAsString());assertEquals("console",mode.get("actor_kind").getAsString());
var removed=rows.stream().filter(row->row.get("type").getAsString().endsWith("site.removed")).findFirst().orElseThrow().getAsJsonObject("data");
assertEquals(changed.id().toString(),removed.get("site_id").getAsString());
assertEquals(changed.registrationRevision().toString(),removed.get("site_revision").getAsString());
assertEquals("individual-only",removed.get("mode_before").getAsString());assertEquals("not_applicable",removed.get("mode_after").getAsString());
}
}
@Test void actualRegisteredSavedCampExportsOriginalAdministratorRevisionLocationAndGeometry() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(actor.core.id, actor.player);
UUID world = UUID.randomUUID(); when(actor.world.getUID()).thenReturn(world);
when(actor.world.getName()).thenReturn("Camp Realm");
f.activate(actor);
var directory = (AdventureQuestDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
assertTrue(f.adminCommand.getExecutor().onCommand(actor.player,f.adminCommand,"fstickadmin",
new String[]{"adventure-site","place","camp","--mode","party-required","20","60","Same Name"}));
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(v->v.startsWith("Placed camp")));
var site=directory.snapshot().sites().getFirst();
assertNotNull(site.registrationRevision());
actor.core.location.setX(900);
ReskillEventsNativeTest.await(f,()->{
try{return ReskillEventsNativeTest.records(data).stream().anyMatch(row->row.get("type").getAsString().endsWith("adventure.site.registered")
&&row.getAsJsonObject("data").get("site_id").getAsString().equals(site.id().toString()));}
catch(Exception failure){throw new AssertionError(failure);}
});
f.plugin.setEnabled(false); f.awaitWorkers();
((java.util.concurrent.CompletionStage<?>)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"campAuditDrain"))
.toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
var rows=ReskillEventsNativeTest.records(data).stream().filter(row->
row.get("type").getAsString().endsWith("adventure.site.registered")
&& row.getAsJsonObject("data").get("site_id").getAsString().equals(site.id().toString())).toList();
assertEquals(1,rows.size(),"Actual acknowledged registered camp administration must reach its feature-owned JSONL record");
var row=rows.getFirst(); var values=row.getAsJsonObject("data");
assertEquals(actor.core.id.toString(),row.get("playerid").getAsString());
assertEquals(actor.core.record.id().toString(),row.get("characterid").getAsString());
assertEquals("camp",values.get("site_kind").getAsString());
assertEquals("Same Name",values.get("site_name").getAsString());
assertEquals(site.registrationRevision().toString(),values.get("site_revision").getAsString());
assertEquals("persisted",values.get("revision_capture_quality").getAsString());
assertEquals(world.toString(),values.get("world_id").getAsString());
assertEquals(12,values.get("x").getAsDouble()); assertEquals(70,values.get("y").getAsDouble());
assertEquals(-8,values.get("z").getAsDouble());
assertEquals("rectangle",values.getAsJsonObject("arena").get("shape").getAsString());
assertEquals(20,values.getAsJsonObject("arena").get("x_blocks").getAsInt());
assertEquals(60,values.getAsJsonObject("arena").get("z_blocks").getAsInt());
assertEquals("party-required",values.get("mode_after").getAsString());
assertEquals("acknowledged",values.get("outcome").getAsString());
assertEquals(12,values.getAsJsonObject("location").get("x").getAsDouble());
}
}
}
@@ -0,0 +1,97 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static games.dmg.fantasystick.WitchAuditTestSupport.*;
import games.dmg.fantasystick.adventure.FishingDirectory;
import games.dmg.fantasystick.adventure.FishingState;
import java.nio.file.Path;
import java.util.ArrayList;
import org.bukkit.entity.Vindicator;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ChampionAttemptEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void realSoloChampionNativeConfigurationCancelledOutsiderGenuineRepresentationAndDuplicateDeathUseSoleHeadRedemptionAndClaim()throws Exception{
try(var championFixture=new ChampionAuditNativeFixture(data)){
var fixture=championFixture.base;var f=fixture.f;var actor=fixture.actor;championFixture.champion("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);
var before=CombatExperienceNativeTest.record(f,actor);fixture.click(actor,fixture.open(actor),false);
ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();var site=fixture.directory.findSite(quest.site()).orElseThrow();fixture.pulse();
ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().anyMatch(Vindicator.class::isInstance));var champion=fixture.spawned.stream().filter(Vindicator.class::isInstance).findFirst().orElseThrow();
assertEquals(100,champion.getHealth());assertEquals(100,champion.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH).getBaseValue());assertEquals(8,champion.getAttribute(org.bukkit.attribute.Attribute.ATTACK_DAMAGE).getBaseValue());
assertEquals(net.kyori.adventure.text.Component.text("Daybreak Champion"),champion.customName());assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CHAMPION,fixture.controller.encounterOrigin(champion).orElseThrow().kind());
String attempt=champion.getPersistentDataContainer().get(new org.bukkit.NamespacedKey(f.plugin,"adventure_attempt_id"),org.bukkit.persistence.PersistentDataType.STRING);assertNotNull(attempt);
var outsider=new FantasyStickLifecycleNativeTest.Actor(data);outsider.core.location.setWorld(actor.world);f.players.put(outsider.core.id,outsider.player);
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);
var rejected=new EntityDamageByEntityEvent(outsider.player,champion,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);f.dispatcher.accept(rejected);assertTrue(rejected.isCancelled());
var cancelled=new EntityDamageByEntityEvent(actor.player,champion,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);cancelled.setCancelled(true);f.dispatcher.accept(cancelled);assertTrue(cancelled.isCancelled());
var hit=new EntityDamageByEntityEvent(actor.player,champion,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);when(champion.getLastDamageCause()).thenReturn(hit);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());
var death=new org.bukkit.event.entity.EntityDeathEvent(champion,source,new ArrayList<>(),7);f.dispatcher.accept(death);f.dispatcher.accept(death);assertEquals(0,death.getDroppedExp());assertTrue(death.getDrops().isEmpty());
var trophies=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");ReskillEventsNativeTest.await(f,()->fixture.directory.active().isEmpty()&&trophies.trophies(quest.unit()).size()==1);
assertEquals(before,CombatExperienceNativeTest.record(f,actor));var trophy=trophies.trophies(quest.unit()).getFirst();assertEquals(FishingState.TrophyType.CHAMPION,trophy.type());assertEquals(quest.id(),trophy.quest());assertEquals(java.util.Set.of(quest.unit()),trophy.rewards().keySet());
var menu=fixture.open(actor);fixture.click(actor,menu,CampAttemptEventsNativeTest.slot(menu,"Turn in quest items"),false);menu=actor.inventories.getLast();fixture.click(actor,menu,CampAttemptEventsNativeTest.slot(menu,"Redeem Daybreak Champion Head"),false);
ReskillEventsNativeTest.await(f,()->trophies.trophies(quest.unit()).isEmpty()&&!trophies.unclaimed(quest.unit()).equals(FishingState.Reward.empty()));assertEquals(before,CombatExperienceNativeTest.record(f,actor));
menu=fixture.open(actor);fixture.click(actor,menu,CampAttemptEventsNativeTest.slot(menu,"Turn in quest items"),false);menu=actor.inventories.getLast();fixture.click(actor,menu,CampAttemptEventsNativeTest.slot(menu,"Claim earned quest rewards"),false);
ReskillEventsNativeTest.await(f,()->trophies.unclaimed(quest.unit()).equals(FishingState.Reward.empty()));var paid=CombatExperienceNativeTest.record(f,actor);assertEquals(before.experience().add(quest.rewardXp()),paid.experience());assertEquals(before.coins().add(quest.rewardCoins()),paid.coins());
ReskillEventsNativeTest.await(f,()->{
try{return rows("adventure.trophy_earned").size()==1&&rows("adventure.trophy_redeemed").size()==1&&rows("adventure.reward_claimed").size()==1
&&rows("adventure.champion.spawned").size()==1&&rows("adventure.champion.defeated").size()==1;}catch(Exception failure){throw new AssertionError(failure);}
});
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");var starts=rows("adventure.champion.spawned");var clears=rows("adventure.champion.defeated");
assertEquals(1,starts.size(),"Actual configured loaded-valid champion must export exactly one independent transient start");assertEquals(1,clears.size());
var start=starts.getFirst().getAsJsonObject("data");var clear=clears.getFirst().getAsJsonObject("data");assertEquals(attempt,start.get("attempt_id").getAsString());assertEquals(attempt,clear.get("attempt_id").getAsString());
assertEquals(site.registrationRevision().toString(),start.get("site_revision").getAsString());assertEquals("native_transient_not_persisted",start.get("attempt_capture_quality").getAsString());assertEquals("succeeded",start.get("native_outcome").getAsString());
assertEquals(100,start.get("configured_health").getAsDouble());assertEquals(8,start.get("configured_damage").getAsDouble());assertEquals(champion.getUniqueId().toString(),clear.get("final_enemy_id").getAsString());
assertEquals("Daybreak Champion",start.get("champion_name").getAsString());var nativeSource=clear.getAsJsonObject("defeat_source");
assertEquals(actor.core.id.toString(),nativeSource.get("causing_entity_id").getAsString());assertEquals(actor.core.id.toString(),nativeSource.get("direct_entity_id").getAsString());
assertEquals(actor.core.id.toString(),nativeSource.get("killer_player_id").getAsString());assertEquals(before.id().toString(),nativeSource.get("killer_character_id").getAsString());
assertEquals("captured_native_death",nativeSource.get("capture_quality").getAsString());
assertTrue(rows("adventure.champion.started").isEmpty());assertTrue(rows("adventure.champion.cleared").isEmpty());
assertEquals(1,clear.get("represented_unit_total").getAsInt());assertEquals(quest.id().toString(),clear.getAsJsonArray("represented_quest_ids").get(0).getAsString());assertEquals(0,clear.get("remaining_entity_count").getAsInt());
assertEquals("existing_trophy_receipt_and_beneficiary_claim",clear.get("reward_owner").getAsString());assertTrue(rows("adventure.champion.reset").isEmpty());
}
}
@Test void actualChunkUnloadAndDisableResetDistinctTransientChampionsThenReplayAuditWithoutRestoringEncounterAuthority()throws Exception{
try(var championFixture=new ChampionAuditNativeFixture(data)){
var fixture=championFixture.base;var f=fixture.f;var actor=fixture.actor;championFixture.champion("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();var before=CombatExperienceNativeTest.record(f,actor);fixture.pulse();
ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(Vindicator.class::isInstance).count()==1);var first=fixture.spawned.stream().filter(Vindicator.class::isInstance).findFirst().orElseThrow();
var key=new org.bukkit.NamespacedKey(f.plugin,"adventure_attempt_id");String original=first.getPersistentDataContainer().get(key,org.bukkit.persistence.PersistentDataType.STRING);
f.dispatcher.accept(new org.bukkit.event.world.ChunkUnloadEvent(actor.world.getChunkAt(0,-1),false));assertFalse(first.isValid());assertFalse(fixture.controller.hasActiveEncounter());assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());
fixture.pulse();ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(Vindicator.class::isInstance).count()==2);var second=fixture.spawned.stream().filter(Vindicator.class::isInstance).skip(1).findFirst().orElseThrow();String successor=second.getPersistentDataContainer().get(key,org.bukkit.persistence.PersistentDataType.STRING);assertNotEquals(original,successor);assertEquals(before,CombatExperienceNativeTest.record(f,actor));
ReskillEventsNativeTest.await(f,()->{try{return rows("adventure.champion.spawned").size()==2&&rows("adventure.champion.reset").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");
// Existing immutable shutdown intent is audit-only, never persisted native authority.
f.plugin.setEnabled(true);ReskillEventsNativeTest.await(f,()->{try{return rows("adventure.champion.reset").size()==2;}catch(Exception failure){throw new AssertionError(failure);}});
assertFalse(((games.dmg.fantasystick.adventure.AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController")).hasActiveEncounter());
assertEquals(before,CombatExperienceNativeTest.record(f,actor));
assertEquals(quest,((games.dmg.fantasystick.adventure.AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests")).findQuest(quest.id()).orElseThrow());
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");var resets=rows("adventure.champion.reset");
assertEquals(2,resets.size(),"Actual chunk unload and disable must record their distinct transient champion resets, including immutable shutdown replay");
assertEquals(java.util.Set.of(original,successor),resets.stream().map(row->row.getAsJsonObject("data").get("attempt_id").getAsString()).collect(java.util.stream.Collectors.toSet()));
assertEquals(java.util.Set.of("chunk_unload","plugin_disable"),resets.stream().map(row->row.getAsJsonObject("data").get("reason").getAsString()).collect(java.util.stream.Collectors.toSet()));
for(var row:resets){var values=row.getAsJsonObject("data");assertEquals("succeeded",values.get("native_outcome").getAsString());assertEquals(0,values.get("remaining_entity_count").getAsInt());assertEquals(quest.catalog().toString(),values.get("catalog_id").getAsString());}
assertTrue(rows("adventure.champion.defeated").isEmpty());assertTrue(rows("adventure.champion.cleared").isEmpty());assertTrue(rows("adventure.trophy_earned").isEmpty());
}
}
@Test void returnedSpawnWithCancelledNativeAdditionCannotClaimSuccessfulChampionMaterializationClearOrTrophy()throws Exception{
try(var championFixture=new ChampionAuditNativeFixture(data)){
var fixture=championFixture.base;var f=fixture.f;var actor=fixture.actor;championFixture.champion("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();var before=CombatExperienceNativeTest.record(f,actor);
fixture.nativeSpawnOutcome=entity->{if(entity instanceof Vindicator)when(entity.isValid()).thenReturn(false);};fixture.pulse();ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().anyMatch(Vindicator.class::isInstance));
assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
ReskillEventsNativeTest.await(f,()->{try{return rows("adventure.champion.spawned").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");
var starts=rows("adventure.champion.spawned");assertEquals(1,starts.size(),"A returned cancelled native spawn still needs one truthful observed champion attempt, not silence or invented success");
var values=starts.getFirst().getAsJsonObject("data");assertEquals("not_materialized",values.get("native_outcome").getAsString());assertEquals("native_not_materialized",values.get("outcome").getAsString());assertEquals(0,values.getAsJsonObject("native_materialization").get("remaining_native_entities").getAsInt());
assertTrue(rows("adventure.champion.defeated").isEmpty());assertTrue(rows("adventure.champion.cleared").isEmpty());assertTrue(rows("adventure.trophy_earned").isEmpty());
}
}
private java.util.List<com.google.gson.JsonObject> rows(String type)throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)).toList();}
}
@@ -0,0 +1,39 @@
package games.dmg.fantasystick;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.util.concurrent.atomic.AtomicReference;
import org.bukkit.attribute.Attribute;
import org.bukkit.attribute.AttributeInstance;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Vindicator;
/** Existing supported-interface fixture, extended only with champion health/attribute/name ports. */
final class ChampionAuditNativeFixture implements AutoCloseable {
final CampAuditNativeFixture base;
ChampionAuditNativeFixture(Path data)throws Exception{
base=new CampAuditNativeFixture(data);base.nativeSpawnSetup=ChampionAuditNativeFixture::championPorts;
playerPorts(base.actor);
}
static void playerPorts(FantasyStickLifecycleNativeTest.Actor actor){doReturn(org.bukkit.entity.EntityType.PLAYER).when(actor.player).getType();}
private static void championPorts(LivingEntity entity){
if(!(entity instanceof Vindicator))return;
var health=new AtomicReference<Double>(20.0);var name=new AtomicReference<net.kyori.adventure.text.Component>();
when(entity.getHealth()).thenAnswer(call->health.get());doAnswer(call->{health.set(call.getArgument(0));return null;}).when(entity).setHealth(org.mockito.ArgumentMatchers.anyDouble());
var maximum=attribute(20);var damage=attribute(5);
when(entity.getAttribute(Attribute.MAX_HEALTH)).thenReturn(maximum);when(entity.getAttribute(Attribute.ATTACK_DAMAGE)).thenReturn(damage);
when(entity.customName()).thenAnswer(call->name.get());doAnswer(call->{name.set(call.getArgument(0));return null;}).when(entity).customName(org.mockito.ArgumentMatchers.any(net.kyori.adventure.text.Component.class));
}
private static AttributeInstance attribute(double initial){
var value=new AtomicReference<Double>(initial);var attribute=mock(AttributeInstance.class);
when(attribute.getValue()).thenAnswer(call->value.get());when(attribute.getBaseValue()).thenAnswer(call->value.get());
doAnswer(call->{value.set(call.getArgument(0));return null;}).when(attribute).setBaseValue(org.mockito.ArgumentMatchers.anyDouble());return attribute;
}
void champion(String mode)throws Exception{
var f=base.f;long count=base.actor.messages.stream().filter(value->value.startsWith("Placed champion")).count();
f.adminCommand.execute(base.actor.player,"fstickadmin",new String[]{"adventure-site","place","champion","--mode",mode,"Same Champion"});
ReskillEventsNativeTest.await(f,()->base.actor.messages.stream().filter(value->value.startsWith("Placed champion")).count()==count+1);
}
@Override public void close()throws java.io.IOException{base.close();}
}
@@ -0,0 +1,60 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static games.dmg.fantasystick.WitchAuditTestSupport.*;
import games.dmg.fantasystick.events.ChampionEventBridge;
import games.dmg.fantasystick.events.LocalEventLog;
import games.dmg.fantasystick.persistence.AdventureQuestSettings;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.channels.FileChannel;
import java.nio.file.StandardOpenOption;
import java.util.List;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ChampionAuditSafetyReplayNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void rejectedRegisteredCreationQuestNativeAndConfigurationRecordsReplayEveryOriginalPropertyOfflineWithoutGameplayAccess()throws Exception{
try(var championFixture=new ChampionAuditNativeFixture(data)){
var fixture=championFixture.base;var f=fixture.f;var actor=fixture.actor;f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);
((LocalEventLog)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"eventLog")).close();
championFixture.champion("individual-only");fixture.giver();fixture.resetCatalog();fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();fixture.pulse();
ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().anyMatch(org.bukkit.entity.Vindicator.class::isInstance));var champion=fixture.spawned.stream().filter(org.bukkit.entity.Vindicator.class::isInstance).findFirst().orElseThrow();
String key=AdventureQuestSettings.CHAMPION_HEALTH;f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",key});f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",key,"200"});f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",key,"225"});actor.core.location.setX(19);
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Saved "+key+"=225")));assertEquals(100,champion.getHealth());
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);
var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,champion,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);when(champion.getLastDamageCause()).thenReturn(hit);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());
var death=new org.bukkit.event.entity.EntityDeathEvent(champion,source,new java.util.ArrayList<>(),0);f.dispatcher.accept(death);f.dispatcher.accept(death);
var trophies=(games.dmg.fantasystick.adventure.FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");ReskillEventsNativeTest.await(f,()->fixture.directory.active().isEmpty()&&trophies.trophies(quest.unit()).size()==1);var trophy=trophies.trophies(quest.unit()).getFirst();assertEquals(before,CombatExperienceNativeTest.record(f,actor));
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");drain(f,"configurationDrain");var original=intents(data.resolve("champion-event-intents"));assertEquals(7,original.size());assertTrue(championRows().isEmpty());
var config=Files.readAllBytes(data.resolve("config.yml"));var adventure=Files.readAllBytes(data.resolve("adventure-quests.yml"));var inventory=actor.core.snapshot();var messages=List.copyOf(actor.messages);int spawnCount=fixture.spawned.size();
f.players.clear();clearInvocations(actor.player);f.plugin.setEnabled(true);ReskillEventsNativeTest.await(f,()->{try{return championRows().size()==7;}catch(Exception failure){throw new AssertionError(failure);}});
assertArrayEquals(config,Files.readAllBytes(data.resolve("config.yml")));assertArrayEquals(adventure,Files.readAllBytes(data.resolve("adventure-quests.yml")));assertArrayEquals(inventory,actor.core.snapshot());assertEquals(messages,actor.messages);assertEquals(spawnCount,fixture.spawned.size());verifyNoInteractions(actor.player);
var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(before,storage.find(before.id()).get().orElseThrow());
var reloaded=(games.dmg.fantasystick.adventure.FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");assertEquals(List.of(trophy),reloaded.trophies(quest.unit()));assertFalse(((games.dmg.fantasystick.adventure.AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController")).hasActiveEncounter());
var rows=championRows();for(var intent:original){for(var property:intent.stringPropertyNames())if(intent.getProperty(property).equals("l"))intent.setProperty(property,"j[]");assertOriginal(intent,rows.stream().filter(row->row.get("id").getAsString().equals(intent.getProperty("id"))).findFirst().orElseThrow());}
var changes=rows.stream().filter(row->row.get("type").getAsString().endsWith("config.changed")).map(row->row.getAsJsonObject("data")).toList();assertEquals(java.util.Map.of("100","200","200","225"),changes.stream().collect(java.util.stream.Collectors.toMap(value->value.get("before").getAsString(),value->value.get("after").getAsString())));
var bridge=(ChampionEventBridge)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"championAuditEvents");bridge.replay();bridge.replay();f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");assertEquals(7,championRows().size());assertEquals(messages,actor.messages);verifyNoInteractions(actor.player);
}
}
@Test void deniedInvalidLockedClosedAndUnchangedChampionSettingsCannotPublishPositiveHistory()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();var denied=new EquipmentAdminCommandNativeTest.Sender();denied.allowed=false;
var settings=(AdventureQuestSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestSettings");String key=AdventureQuestSettings.CHAMPION_DAMAGE;
f.adminCommand.execute(denied.port,"fstickadmin",new String[]{"config",key});f.adminCommand.execute(denied.port,"fstickadmin",new String[]{"config",key,"12"});
for(var invalid:List.of("0","2049","NaN","1.5"))f.adminCommand.execute(sender.port,"fstickadmin",new String[]{"config",key,invalid});f.adminCommand.execute(sender.port,"fstickadmin",new String[]{"config",key,"12","extra"});assertEquals("8",settings.value(key));
f.adminCommand.execute(sender.port,"fstickadmin",new String[]{"config",key,"8"});ReskillEventsNativeTest.await(f,()->sender.messages.stream().anyMatch(value->value.startsWith("Saved "+key+"=8")));var bytes=Files.readAllBytes(data.resolve("config.yml"));
try(var channel=FileChannel.open(data.resolve(".config.lock"),StandardOpenOption.CREATE,StandardOpenOption.WRITE);var lock=channel.lock()){
assertTrue(lock.isValid());f.adminCommand.execute(sender.port,"fstickadmin",new String[]{"config",key,"12"});ReskillEventsNativeTest.await(f,()->sender.messages.contains("Adventure quest configuration save failed; effective values are unchanged."));assertEquals("8",settings.value(key));assertArrayEquals(bytes,Files.readAllBytes(data.resolve("config.yml")));
}
settings.close();sender.messages.clear();f.adminCommand.execute(sender.port,"fstickadmin",new String[]{"config",key,"16"});ReskillEventsNativeTest.await(f,()->sender.messages.contains("Adventure quest configuration save failed; effective values are unchanged."));assertEquals("8",settings.value(key));assertArrayEquals(bytes,Files.readAllBytes(data.resolve("config.yml")));
f.adminCommand.execute(sender.port,"fstickadmin",new String[]{"config",key});ReskillEventsNativeTest.await(f,()->{try{return championRows().size()==1;}catch(Exception failure){throw new AssertionError(failure);}});f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");assertEquals(1,championRows().size());assertTrue(championRows().getFirst().get("type").getAsString().endsWith("config.read"));
}
}
private List<com.google.gson.JsonObject> championRows()throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->{var values=row.getAsJsonObject("data");return values.has("site_kind")&&values.get("site_kind").getAsString().equals("champion")||values.has("key")&&ChampionEventBridge.ownsSetting(values.get("key").getAsString());}).toList();}
}
@@ -0,0 +1,70 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static games.dmg.fantasystick.WitchAuditTestSupport.*;
import games.dmg.fantasystick.persistence.AdventureQuestSettings;
import java.nio.file.Path;
import java.util.List;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ChampionConfigurationEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
private record Setting(String key,String before,String after,String scope){}
private static final List<Setting> SETTINGS=List.of(
new Setting(AdventureQuestSettings.CHAMPION_HEALTH,"100","200","future_attempts"),
new Setting(AdventureQuestSettings.CHAMPION_DAMAGE,"8","12","future_attempts"),
new Setting(AdventureQuestSettings.CHAMPION_REWARD_XP,"150","225","future_acceptances"),
new Setting(AdventureQuestSettings.CHAMPION_REWARD_COINS,"40","60","future_acceptances"));
@Test void allFourRegisteredChampionReadsAndPublicationsKeepOriginalRequestContextAndCurrentQuestDefinitionAndNativeStats()throws Exception{
try(var championFixture=new ChampionAuditNativeFixture(data)){
var fixture=championFixture.base;var f=fixture.f;var actor=fixture.actor;championFixture.champion("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);
var quest=fixture.directory.active().getFirst();var catalog=fixture.directory.snapshot().catalog();var site=fixture.directory.findSite(quest.site()).orElseThrow();
var character=CombatExperienceNativeTest.record(f,actor);fixture.pulse();ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().anyMatch(org.bukkit.entity.Vindicator.class::isInstance));
var nativeChampion=fixture.spawned.stream().filter(org.bukkit.entity.Vindicator.class::isInstance).findFirst().orElseThrow();
var settings=(AdventureQuestSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestSettings");
String session=((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(actor.core.id).orElseThrow().session().toString();
for(var setting:SETTINGS){
actor.core.location.setX(12);f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",setting.key()});
assertTrue(actor.messages.stream().anyMatch(value->value.startsWith(setting.key()+"="+setting.before()+" ")));
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",setting.key(),setting.after()});actor.core.location.setX(77);
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Saved "+setting.key()+"="+setting.after()+" for later")));
assertEquals(setting.after(),settings.value(setting.key()));actor.core.location.setX(12);f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",setting.key()});
// These refused/unchanged paths must not become acknowledged configuration changes.
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",setting.key(),setting.after()});
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",setting.key(),"-1"});
actor.allowed=false;f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",setting.key()});f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",setting.key(),setting.before()});actor.allowed=true;
}
assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());assertEquals(catalog,fixture.directory.snapshot().catalog());assertEquals(site,fixture.directory.findSite(quest.site()).orElseThrow());assertEquals(character,CombatExperienceNativeTest.record(f,actor));
assertEquals(100,nativeChampion.getHealth());assertEquals(100,nativeChampion.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH).getBaseValue());assertEquals(8,nativeChampion.getAttribute(org.bukkit.attribute.Attribute.ATTACK_DAMAGE).getBaseValue());
ReskillEventsNativeTest.await(f,()->{try{return ownedRows().size()==12;}catch(Exception failure){throw new AssertionError(failure);}});
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");var rows=ownedRows();
assertEquals(12,rows.size(),"All four actual registered champion read/change/read histories must be owned and exported, with no refused or unchanged duplicates");
for(var setting:SETTINGS){var owned=rows.stream().filter(row->row.getAsJsonObject("data").get("key").getAsString().equals(setting.key())).toList();
var reads=owned.stream().filter(row->row.get("type").getAsString().endsWith("config.read")).toList();assertEquals(2,reads.size());assertEquals(java.util.Set.of(setting.before(),setting.after()),reads.stream().map(row->row.getAsJsonObject("data").get("effective").getAsString()).collect(java.util.stream.Collectors.toSet()));
var changes=owned.stream().filter(row->row.get("type").getAsString().endsWith("config.changed")).toList();assertEquals(1,changes.size());var values=changes.getFirst().getAsJsonObject("data");assertEquals(setting.before(),values.get("before").getAsString());assertEquals(setting.after(),values.get("after").getAsString());
for(var row:owned){var valuesAll=row.getAsJsonObject("data");assertEquals(setting.before(),valuesAll.get("default").getAsString());assertEquals(setting.scope(),valuesAll.get("application_scope").getAsString());assertEquals("champion",valuesAll.get("feature").getAsString());
assertEquals(12,valuesAll.getAsJsonObject("location").get("x").getAsDouble());assertEquals(actor.core.id.toString(),row.get("playerid").getAsString());assertEquals(character.id().toString(),row.get("characterid").getAsString());assertEquals(session,valuesAll.get("session_id").getAsString());}
}
}
}
@Test void queuedChampionSettingPublicationsCaptureAcknowledgedBeforeRatherThanRequestSnapshotAndFailedSaveHasNoHistory()throws Exception{
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);var settings=(AdventureQuestSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestSettings");
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",AdventureQuestSettings.CHAMPION_HEALTH,"200"});f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",AdventureQuestSettings.CHAMPION_HEALTH,"225"});
ReskillEventsNativeTest.await(f,()->settings.value(AdventureQuestSettings.CHAMPION_HEALTH).equals("225"));
var file=data.resolve("config.yml");var bytes=java.nio.file.Files.readAllBytes(file);java.nio.file.Files.delete(file);java.nio.file.Files.createDirectory(file);
int messages=actor.messages.size();f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"config",AdventureQuestSettings.CHAMPION_HEALTH,"300"});ReskillEventsNativeTest.await(f,()->actor.messages.subList(messages,actor.messages.size()).stream().anyMatch(value->value.contains("save failed")));
assertEquals("225",settings.value(AdventureQuestSettings.CHAMPION_HEALTH));assertEquals(before,CombatExperienceNativeTest.record(f,actor));java.nio.file.Files.delete(file);java.nio.file.Files.write(file,bytes);
ReskillEventsNativeTest.await(f,()->{try{return ownedRows().stream().filter(row->row.get("type").getAsString().endsWith("config.changed")).count()==2;}catch(Exception failure){throw new AssertionError(failure);}});
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");var changes=ownedRows().stream().filter(row->row.get("type").getAsString().endsWith("config.changed")).toList();
assertEquals(2,changes.size(),"Exactly two real queued champion publications, never the failed third save, own history");
assertEquals(java.util.Map.of("100","200","200","225"),changes.stream().map(row->row.getAsJsonObject("data")).collect(java.util.stream.Collectors.toMap(value->value.get("before").getAsString(),value->value.get("after").getAsString())));
}
}
private java.util.List<com.google.gson.JsonObject> ownedRows()throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->row.getAsJsonObject("data").has("key")&&SETTINGS.stream().anyMatch(setting->setting.key().equals(row.getAsJsonObject("data").get("key").getAsString()))).toList();}
}
@@ -0,0 +1,71 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static games.dmg.fantasystick.WitchAuditTestSupport.*;
import java.nio.file.Path;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
class ChampionFaultEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@ParameterizedTest @ValueSource(booleans={false,true})
void closedOrUnwritableChampionAuditCannotPreventActualQuestNativeClearOrExistingHeadPersistence(boolean closed)throws Exception{
if(!closed)java.nio.file.Files.writeString(data.resolve("champion-event-intents"),"not a directory");
try(var championFixture=new ChampionAuditNativeFixture(data)){
var fixture=championFixture.base;var f=fixture.f;var actor=fixture.actor;
if(closed)((games.dmg.fantasystick.events.ChampionEventBridge)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"championAuditEvents")).close();
championFixture.champion("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();fixture.pulse();
ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().anyMatch(org.bukkit.entity.Vindicator.class::isInstance));var champion=fixture.spawned.stream().filter(org.bukkit.entity.Vindicator.class::isInstance).findFirst().orElseThrow();
var source=source(actor);var hit=hit(actor,champion,source);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());var death=new org.bukkit.event.entity.EntityDeathEvent(champion,source,new java.util.ArrayList<>(),0);f.dispatcher.accept(death);f.dispatcher.accept(death);
var trophies=(games.dmg.fantasystick.adventure.FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");ReskillEventsNativeTest.await(f,()->fixture.directory.active().isEmpty()&&trophies.trophies(quest.unit()).size()==1);assertEquals(quest.id(),trophies.trophies(quest.unit()).getFirst().quest());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
ReskillEventsNativeTest.await(f,()->{try{return rows("adventure.trophy_earned").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");
assertTrue(rows("adventure.champion.spawned").isEmpty());assertTrue(rows("adventure.champion.defeated").isEmpty());assertEquals(1,rows("adventure.trophy_earned").size());
}
}
@Test void failedTrophyPersistenceDoesNotCompleteOrPayOriginalQuestDespiteGenuineNativeDefeatRecord()throws Exception{
try(var championFixture=new ChampionAuditNativeFixture(data)){
var fixture=championFixture.base;var f=fixture.f;var actor=fixture.actor;championFixture.champion("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();fixture.pulse();ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().anyMatch(org.bukkit.entity.Vindicator.class::isInstance));var champion=fixture.spawned.stream().filter(org.bukkit.entity.Vindicator.class::isInstance).findFirst().orElseThrow();
var trophies=(games.dmg.fantasystick.adventure.FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");trophies.close();var source=source(actor);f.dispatcher.accept(hit(actor,champion,source));f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(champion,source,new java.util.ArrayList<>(),0));
ReskillEventsNativeTest.await(f,()->{try{return rows("adventure.champion.defeated").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());assertTrue(trophies.trophies(quest.unit()).isEmpty());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
assertTrue(rows("adventure.trophy_earned").isEmpty());assertTrue(rows("adventure.reward_claimed").isEmpty());f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");
}
}
@Test void absenceResetsThenUnrepresentedNativeDefeatLeavesQuestAvailableAndMissingOriginalNameUnavailable()throws Exception{
var config=new org.bukkit.configuration.file.YamlConfiguration();config.set("adventure-site-reset-seconds",0);config.save(data.resolve("config.yml").toFile());
try(var championFixture=new ChampionAuditNativeFixture(data)){
var fixture=championFixture.base;var f=fixture.f;var actor=fixture.actor;championFixture.champion("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();
fixture.nativeSpawnOutcome=entity->{if(entity instanceof org.bukkit.entity.Vindicator)doThrow(new IllegalStateException("original native name unavailable")).when(entity).customName();};fixture.pulse();ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(org.bukkit.entity.Vindicator.class::isInstance).count()==1);var first=fixture.spawned.stream().filter(org.bukkit.entity.Vindicator.class::isInstance).findFirst().orElseThrow();
actor.core.location.setX(77);fixture.pulse();fixture.pulse();assertFalse(first.isValid());assertFalse(fixture.controller.hasActiveEncounter());assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());
actor.core.location.setX(12);fixture.pulse();ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().filter(org.bukkit.entity.Vindicator.class::isInstance).count()==2);var second=fixture.spawned.stream().filter(org.bukkit.entity.Vindicator.class::isInstance).skip(1).findFirst().orElseThrow();
var nativeSource=mock(org.bukkit.damage.DamageSource.class);f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(second,nativeSource,new java.util.ArrayList<>(),0));
ReskillEventsNativeTest.await(f,()->{try{return rows("adventure.champion.spawned").size()==2&&rows("adventure.champion.reset").size()==1&&rows("adventure.champion.defeated").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});
assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());assertEquals(before,CombatExperienceNativeTest.record(f,actor));assertTrue(rows("adventure.trophy_earned").isEmpty());
var reset=rows("adventure.champion.reset").getFirst().getAsJsonObject("data");assertEquals("no_eligible_accepted_character_remained",reset.get("reason").getAsString());var defeat=rows("adventure.champion.defeated").getFirst().getAsJsonObject("data");assertEquals(0,defeat.get("represented_unit_total").getAsInt());assertEquals("unavailable",defeat.getAsJsonObject("defeat_source").get("killer_context_capture_quality").getAsString());
for(var row:rows("adventure.champion.spawned")){var values=row.getAsJsonObject("data");assertFalse(values.has("champion_name"));assertEquals("unavailable",values.get("champion_name_capture_quality").getAsString());assertEquals(100,values.get("configured_health").getAsDouble());}
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");
}
}
@Test void failedNativeSpawnAndFailedSavedAcceptanceCannotInventChampionOrQuestSuccess()throws Exception{
try(var championFixture=new ChampionAuditNativeFixture(data)){
var fixture=championFixture.base;var f=fixture.f;var actor=fixture.actor;championFixture.champion("individual-only");fixture.giver();fixture.resetCatalog();f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);
var file=data.resolve("adventure-quests.yml");var bytes=java.nio.file.Files.readAllBytes(file);java.nio.file.Files.delete(file);java.nio.file.Files.createDirectory(file);
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->actor.messages.contains("Quest acceptance failed; retained state is unchanged."));assertTrue(fixture.directory.active().isEmpty());assertTrue(rows("adventure.quest.accepted").isEmpty());java.nio.file.Files.delete(file);java.nio.file.Files.write(file,bytes);
fixture.click(actor,fixture.open(actor),false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();
fixture.nativeSpawnSetup=entity->{if(entity instanceof org.bukkit.entity.Vindicator)throw new IllegalStateException("native spawn rejected before configuration");};fixture.pulse();
ReskillEventsNativeTest.await(f,()->{try{return rows("adventure.champion.spawned").size()==1&&rows("adventure.champion.reset").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});
assertFalse(fixture.controller.hasActiveEncounter());assertEquals(quest,fixture.directory.findQuest(quest.id()).orElseThrow());assertEquals(before,CombatExperienceNativeTest.record(f,actor));var value=rows("adventure.champion.spawned").getFirst().getAsJsonObject("data");
assertEquals("failed",value.get("native_outcome").getAsString());assertEquals("native_failed",value.get("outcome").getAsString());assertEquals("native_spawn_or_configuration_failed",value.get("reason").getAsString());assertFalse(value.has("configured_health"));assertFalse(value.has("champion_name"));
assertEquals(1,rows("adventure.quest.accepted").size());assertTrue(rows("adventure.champion.defeated").isEmpty());assertTrue(rows("adventure.trophy_earned").isEmpty());f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");
}
}
private static org.bukkit.damage.DamageSource source(FantasyStickLifecycleNativeTest.Actor actor){var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);return source;}
private static org.bukkit.event.entity.EntityDamageByEntityEvent hit(FantasyStickLifecycleNativeTest.Actor actor,org.bukkit.entity.LivingEntity target,org.bukkit.damage.DamageSource source){var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,target,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);when(target.getLastDamageCause()).thenReturn(hit);return hit;}
private java.util.List<com.google.gson.JsonObject> rows(String type)throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)).toList();}
}
@@ -0,0 +1,50 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static games.dmg.fantasystick.CampAttemptEventsNativeTest.slot;
import games.dmg.fantasystick.adventure.AdventureQuestState;
import games.dmg.fantasystick.adventure.FishingState;
import java.nio.file.Path;
import java.util.Set;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ChampionPartyEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void mutedSharedChampionClearPreservesUnrepresentedCancelledSoloAndAddedMemberThenRedemptionPaysOnlyOfflineCarriersFrozenMemberShare()throws Exception{
try(var championFixture=new ChampionAuditNativeFixture(data)){
var fixture=championFixture.base;var f=fixture.f;var leader=fixture.actor;f.activate(leader);
var member=new FantasyStickLifecycleNativeTest.Actor(data);member.core.location.setWorld(leader.world);f.players.put(member.core.id,member.player);f.activate(member);ChampionAuditNativeFixture.playerPorts(member);
var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(leader.core.id).orElseThrow();assertTrue(parties.accept(member.core.id,parties.invite(leader.core.id,party.id(),member.core.id).orElseThrow().id()));
championFixture.champion("party-available");fixture.giver();fixture.resetCatalog();fixture.click(leader,fixture.open(leader),true);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var quest=fixture.directory.active().getFirst();
var solo=new FantasyStickLifecycleNativeTest.Actor(data);solo.core.location.setWorld(leader.world);f.players.put(solo.core.id,solo.player);f.activate(solo);fixture.click(solo,fixture.open(solo),false);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==2);
var soloQuest=fixture.directory.active().stream().filter(value->value.unitType()==AdventureQuestState.UnitType.SOLO).findFirst().orElseThrow();
var added=new FantasyStickLifecycleNativeTest.Actor(data);added.core.location.setWorld(leader.world);f.players.put(added.core.id,added.player);f.activate(added);assertTrue(parties.accept(added.core.id,parties.invite(leader.core.id,party.id(),added.core.id).orElseThrow().id()));assertFalse(quest.snapshot().contains(added.core.record.id()));
for(var holder:java.util.List.of(leader,member)){assertTrue(f.playerCommand.execute(holder.player,"fstick",new String[]{"feedback","off"}));ReskillEventsNativeTest.await(f,()->holder.messages.contains("Fantasy feedback is now off."));}
var preferences=(games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"feedbackPreferences");assertFalse(preferences.enabled(leader.core.id));assertFalse(preferences.enabled(member.core.id));
var leaderBefore=CombatExperienceNativeTest.record(f,leader);var memberBefore=CombatExperienceNativeTest.record(f,member);var soloBefore=CombatExperienceNativeTest.record(f,solo);var addedBefore=CombatExperienceNativeTest.record(f,added);
fixture.pulse();ReskillEventsNativeTest.await(f,()->fixture.spawned.stream().anyMatch(org.bukkit.entity.Vindicator.class::isInstance));var champion=fixture.spawned.stream().filter(org.bukkit.entity.Vindicator.class::isInstance).findFirst().orElseThrow();
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(member.player);when(source.getDirectEntity()).thenReturn(member.player);
var cancelledSource=mock(org.bukkit.damage.DamageSource.class);when(cancelledSource.getCausingEntity()).thenReturn(solo.player);when(cancelledSource.getDirectEntity()).thenReturn(solo.player);
var cancelled=new org.bukkit.event.entity.EntityDamageByEntityEvent(solo.player,champion,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,cancelledSource,1);cancelled.setCancelled(true);f.dispatcher.accept(cancelled);assertTrue(cancelled.isCancelled());
var rejected=new org.bukkit.event.entity.EntityDamageByEntityEvent(added.player,champion,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);f.dispatcher.accept(rejected);assertTrue(rejected.isCancelled());
doReturn(false).when(leader.player).isOnline();var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(member.player,champion,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);when(champion.getLastDamageCause()).thenReturn(hit);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());
var death=new org.bukkit.event.entity.EntityDeathEvent(champion,source,new java.util.ArrayList<>(),0);f.dispatcher.accept(death);f.dispatcher.accept(death);
var trophies=(games.dmg.fantasystick.adventure.FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");ReskillEventsNativeTest.await(f,()->fixture.directory.findQuest(quest.id()).isEmpty()&&trophies.trophies(leaderBefore.id()).size()==1);
var trophy=trophies.trophies(leaderBefore.id()).getFirst();assertEquals(FishingState.TrophyType.CHAMPION,trophy.type());assertEquals(quest.id(),trophy.quest());assertEquals(Set.of(memberBefore.id()),trophy.rewards().keySet());assertEquals(quest.rewardXp(),trophy.total().xp());assertEquals(quest.rewardCoins(),trophy.total().coins());
assertEquals(soloQuest,fixture.directory.findQuest(soloQuest.id()).orElseThrow());assertEquals(leaderBefore,CombatExperienceNativeTest.record(f,leader));assertEquals(memberBefore,CombatExperienceNativeTest.record(f,member));assertEquals(soloBefore,CombatExperienceNativeTest.record(f,solo));assertEquals(addedBefore,CombatExperienceNativeTest.record(f,added));
doReturn(true).when(leader.player).isOnline();var menu=fixture.open(leader);fixture.click(leader,menu,slot(menu,"Turn in quest items"),false);menu=leader.inventories.getLast();fixture.click(leader,menu,slot(menu,"Redeem Daybreak Champion Head"),false);
ReskillEventsNativeTest.await(f,()->trophies.trophies(leaderBefore.id()).isEmpty()&&!trophies.unclaimed(memberBefore.id()).equals(FishingState.Reward.empty()));assertEquals(leaderBefore,CombatExperienceNativeTest.record(f,leader));assertEquals(memberBefore,CombatExperienceNativeTest.record(f,member));
menu=fixture.open(member);fixture.click(member,menu,slot(menu,"Turn in quest items"),false);menu=member.inventories.getLast();fixture.click(member,menu,slot(menu,"Claim earned quest rewards"),false);ReskillEventsNativeTest.await(f,()->trophies.unclaimed(memberBefore.id()).equals(FishingState.Reward.empty()));
var paid=CombatExperienceNativeTest.record(f,member);assertEquals(memberBefore.experience().add(quest.rewardXp()),paid.experience());assertEquals(memberBefore.coins().add(quest.rewardCoins()),paid.coins());assertEquals(leaderBefore,CombatExperienceNativeTest.record(f,leader));assertEquals(soloBefore,CombatExperienceNativeTest.record(f,solo));assertEquals(addedBefore,CombatExperienceNativeTest.record(f,added));
ReskillEventsNativeTest.await(f,()->{try{var rows=ReskillEventsNativeTest.records(data);return java.util.stream.Stream.of("adventure.champion.spawned","adventure.champion.defeated","adventure.trophy_earned","adventure.trophy_redeemed","adventure.reward_claimed").allMatch(type->rows.stream().filter(row->row.get("type").getAsString().endsWith(type)).count()==1);}catch(Exception failure){throw new AssertionError(failure);}});
var rows=ReskillEventsNativeTest.records(data);var defeat=rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.champion.defeated")).findFirst().orElseThrow().getAsJsonObject("data");
assertEquals(trophy.source().toString(),defeat.get("attempt_id").getAsString());assertEquals(1,defeat.get("represented_unit_total").getAsInt());var represented=defeat.getAsJsonArray("represented_units").get(0).getAsJsonObject();assertEquals(quest.id().toString(),represented.get("quest_id").getAsString());assertEquals(party.id().toString(),represented.get("unit_id").getAsString());assertEquals(memberBefore.id().toString(),defeat.getAsJsonObject("defeat_source").get("killer_character_id").getAsString());
assertTrue(rows.stream().noneMatch(row->row.get("type").getAsString().endsWith("adventure.quest.expired")));assertFalse(preferences.enabled(member.core.id));f.plugin.setEnabled(false);f.awaitWorkers();games.dmg.fantasystick.WitchAuditTestSupport.drain(f,"championAuditDrain");
}
}
}
@@ -0,0 +1,87 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static games.dmg.fantasystick.WitchAuditTestSupport.*;
import games.dmg.fantasystick.adventure.AdventureQuestState;
import java.nio.file.Path;
import java.math.BigInteger;
import java.util.Set;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
class ChampionQuestEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@ParameterizedTest @ValueSource(strings={"individual-only","party-available","party-required"})
void registeredExplicitChampionModesAbandonmentAndRerollExportOnlyFrozenAcknowledgedSoloOrPersistentPartyAuthority(String mode)throws Exception{
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var leader=fixture.actor;var member=new FantasyStickLifecycleNativeTest.Actor(data);
member.core.location.setWorld(leader.world);f.players.put(member.core.id,member.player);f.activate(leader);f.activate(member);
var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(leader.core.id).orElseThrow();
assertTrue(parties.accept(member.core.id,parties.invite(leader.core.id,party.id(),member.core.id).orElseThrow().id()));
f.adminCommand.execute(leader.player,"fstickadmin",new String[]{"adventure-site","place","champion","--mode",mode,"Same Champion"});
ReskillEventsNativeTest.await(f,()->fixture.directory.snapshot().sites().size()==1);var site=fixture.directory.snapshot().sites().getFirst();
fixture.giver();fixture.resetCatalog();var leaderBefore=CombatExperienceNativeTest.record(f,leader);var memberBefore=CombatExperienceNativeTest.record(f,member);
boolean personal=mode.equals("individual-only");
if(personal){fixture.click(leader,fixture.open(leader),true);assertTrue(fixture.directory.active().isEmpty());assertTrue(leader.messages.contains("Quest acceptance refused: this site is individual-only."));}
else{fixture.click(member,fixture.open(member),true);assertTrue(fixture.directory.active().isEmpty());assertTrue(member.messages.contains("Only the active party leader may accept a party quest."));
if(mode.equals("party-required")){fixture.click(leader,fixture.open(leader),false);assertTrue(fixture.directory.active().isEmpty());assertTrue(leader.messages.contains("Quest acceptance refused: this site is party-required."));}}
var accepting=personal?member:leader;fixture.click(accepting,fixture.open(accepting),!personal);
ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);var original=fixture.directory.active().getFirst();
assertEquals(mode,original.participationMode().value());assertEquals(personal?Set.of(memberBefore.id()):Set.of(leaderBefore.id(),memberBefore.id()),original.snapshot());
assertEquals(personal?memberBefore.id():party.id(),original.unit());assertEquals(BigInteger.valueOf(150),original.rewardXp());assertEquals(BigInteger.valueOf(40),original.rewardCoins());
String futureMode=personal?"party-required":"individual-only";
f.adminCommand.execute(leader.player,"fstickadmin",new String[]{"adventure-site","mode",site.id().toString(),futureMode});
ReskillEventsNativeTest.await(f,()->fixture.directory.findSite(site.id()).orElseThrow().participationMode().value().equals(futureMode));
var settings=(games.dmg.fantasystick.persistence.AdventureQuestSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestSettings");
f.adminCommand.execute(leader.player,"fstickadmin",new String[]{"config","adventure-champion-reward-xp","175"});
f.adminCommand.execute(leader.player,"fstickadmin",new String[]{"config","adventure-champion-reward-coins","45"});
ReskillEventsNativeTest.await(f,()->settings.value("adventure-champion-reward-xp").equals("175")&&settings.value("adventure-champion-reward-coins").equals("45"));
assertEquals(original,fixture.directory.findQuest(original.id()).orElseThrow());
fixture.click(accepting,fixture.open(accepting),true);ReskillEventsNativeTest.await(f,()->fixture.directory.active().isEmpty());
f.adminCommand.execute(leader.player,"fstickadmin",new String[]{"adventure-site","mode",site.id().toString(),mode});
ReskillEventsNativeTest.await(f,()->fixture.directory.findSite(site.id()).orElseThrow().participationMode().value().equals(mode));
fixture.click(accepting,fixture.open(accepting),!personal);ReskillEventsNativeTest.await(f,()->fixture.directory.active().size()==1);
var successor=fixture.directory.active().getFirst();assertNotEquals(original.id(),successor.id());assertEquals(BigInteger.valueOf(175),successor.rewardXp());assertEquals(BigInteger.valueOf(45),successor.rewardCoins());
if(!personal){
var durable=(games.dmg.fantasystick.party.CharacterPartyDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterParties");
assertEquals(games.dmg.fantasystick.party.CharacterPartyDirectory.Status.APPLIED,durable.transfer(leaderBefore.id(),party.id(),memberBefore.id()).get(5,java.util.concurrent.TimeUnit.SECONDS).status());
var added=new FantasyStickLifecycleNativeTest.Actor(data);added.core.location.setWorld(leader.world);f.players.put(added.core.id,added.player);f.activate(added);
assertTrue(parties.accept(added.core.id,parties.invite(member.core.id,party.id(),added.core.id).orElseThrow().id()));assertFalse(successor.snapshot().contains(added.core.record.id()));
assertEquals(successor,fixture.directory.findQuest(successor.id()).orElseThrow());
}
fixture.resetCatalog();assertTrue(fixture.directory.active().isEmpty());assertEquals(leaderBefore,CombatExperienceNativeTest.record(f,leader));assertEquals(memberBefore,CombatExperienceNativeTest.record(f,member));
ReskillEventsNativeTest.await(f,()->{
try{return rows("adventure.quest.accepted").size()==2&&rows("adventure.quest.abandoned").size()==1&&rows("adventure.quest.expired").size()==1;}
catch(Exception failure){throw new AssertionError(failure);}
});
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");
var accepted=rows("adventure.quest.accepted");var abandoned=rows("adventure.quest.abandoned");var expired=rows("adventure.quest.expired");
assertEquals(4,accepted.size()+abandoned.size()+expired.size(),"Only the actual two saved champion acceptances, acknowledged abandonment and full-reroll expiry may export, not refused choices");
assertEquals(2,accepted.size());assertEquals(1,abandoned.size());assertEquals(1,expired.size());
for(var quest:java.util.List.of(original,successor)){
var row=accepted.stream().filter(value->value.getAsJsonObject("data").get("quest_id").getAsString().equals(quest.id().toString())).findFirst().orElseThrow();var values=row.getAsJsonObject("data");
assertEquals(accepting.core.id.toString(),row.get("playerid").getAsString());assertEquals(quest.catalog().toString(),values.get("catalog_id").getAsString());assertEquals(site.id().toString(),values.get("site_id").getAsString());
assertTrue(values.has("giver_id"),"Acknowledged champion acceptance must retain the actual original Quest Giver target, not infer it during replay");
var giver=fixture.directory.snapshot().givers().getFirst();assertEquals(giver.id().toString(),values.get("giver_id").getAsString());assertEquals(giver.world().toString(),values.getAsJsonObject("giver_location").get("world_id").getAsString());
assertEquals(mode,values.get("captured_mode").getAsString());assertEquals(personal?"solo":"party",values.get("unit_kind").getAsString());assertEquals(quest.unit().toString(),values.get("unit_id").getAsString());
assertEquals(quest.rewardXp().toString(),values.get("reward_xp_pool").getAsString());assertEquals(quest.rewardCoins().toString(),values.get("reward_coin_pool").getAsString());assertEquals(quest.expiresAt().toString(),values.get("deadline").getAsString());
if(!personal)assertEquals(leaderBefore.id().toString(),values.get("accepting_leader_character_id").getAsString());
}
assertEquals(original.id().toString(),abandoned.getFirst().getAsJsonObject("data").get("quest_id").getAsString());
assertEquals("unavailable_not_in_saved_quest",abandoned.getFirst().getAsJsonObject("data").get("accepting_actor_capture_quality").getAsString());
var ended=expired.getFirst().getAsJsonObject("data");assertEquals(successor.id().toString(),ended.get("quest_id").getAsString());assertEquals("full_day_reroll",ended.get("reason").getAsString());
assertEquals(mode,ended.get("captured_mode").getAsString());assertEquals(successor.rewardXp().toString(),ended.get("reward_xp_pool").getAsString());
assertEquals(successor.snapshot().stream().map(Object::toString).collect(java.util.stream.Collectors.toSet()),
java.util.stream.StreamSupport.stream(ended.getAsJsonArray("accepted_roster").spliterator(),false).map(value->value.getAsString()).collect(java.util.stream.Collectors.toSet()));
assertTrue(rows("adventure.trophy_earned").isEmpty());assertTrue(rows("adventure.reward_claimed").isEmpty());
}
}
private java.util.List<com.google.gson.JsonObject> rows(String type)throws Exception{
return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)
&&row.getAsJsonObject("data").has("site_kind")&&row.getAsJsonObject("data").get("site_kind").getAsString().equals("champion")).toList();
}
}
@@ -0,0 +1,96 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static games.dmg.fantasystick.WitchAuditTestSupport.*;
import games.dmg.fantasystick.adventure.AdventureQuestState;
import games.dmg.fantasystick.persistence.YamlAdventureQuestRepository;
import java.nio.file.Path;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ChampionSiteEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void registeredSameNameChampionModeAndRemovalKeepExactRevisionsAndRejectUnchangedDeniedInvalidStaleAndFailedMutations()throws Exception{
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);
for(int index=0;index<2;index++){
int wanted=index+1;assertTrue(f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","place","champion","Same Champion"}));
ReskillEventsNativeTest.await(f,()->fixture.directory.snapshot().sites().size()==wanted);
}
var original=fixture.directory.snapshot().sites().getFirst();var other=fixture.directory.snapshot().sites().getLast();
assertNotEquals(original.id(),other.id());assertNotEquals(original.registrationRevision(),other.registrationRevision());
actor.allowed=false;f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","mode",original.id().toString(),"party-required"});
assertEquals(original,fixture.directory.findSite(original.id()).orElseThrow());actor.allowed=true;
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","mode",original.id().toString(),"bad-mode"});
assertEquals(original,fixture.directory.findSite(original.id()).orElseThrow());
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","mode",original.id().toString(),"party-required"});
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Updated adventure site")));
var changed=fixture.directory.findSite(original.id()).orElseThrow();assertNotNull(changed.registrationRevision());
assertNotEquals(original.registrationRevision(),changed.registrationRevision());assertEquals(changed,new YamlAdventureQuestRepository(data).load().sites().stream().filter(value->value.id().equals(changed.id())).findFirst().orElseThrow());
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","mode",changed.id().toString(),"party-required"});
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.contains("mode is already party-required")));
assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.ModeStatus.STALE,fixture.directory.setSiteMode(changed.id(),
original.participationMode(),AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY).get(5,java.util.concurrent.TimeUnit.SECONDS).status());
var file=data.resolve("adventure-quests.yml");var bytes=java.nio.file.Files.readAllBytes(file);java.nio.file.Files.delete(file);java.nio.file.Files.createDirectory(file);
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","mode",changed.id().toString(),"individual-only"});
ReskillEventsNativeTest.await(f,()->actor.messages.contains("Adventure site mode save failed; the acknowledged mode is unchanged."));
assertEquals(changed,fixture.directory.findSite(changed.id()).orElseThrow());java.nio.file.Files.delete(file);java.nio.file.Files.write(file,bytes);
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"adventure-site","remove",other.id().toString()});
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Removed adventure site "+other.id())));
assertEquals(changed,fixture.directory.findSite(changed.id()).orElseThrow());assertTrue(fixture.directory.findSite(other.id()).isEmpty());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
ReskillEventsNativeTest.await(f,()->{
try{return championRows("adventure.site.registered").size()==2&&championRows("adventure.site.mode_changed").size()==1
&&championRows("adventure.site.removed").size()==1;}catch(Exception failure){throw new AssertionError(failure);}
});
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");
var modes=championRows("adventure.site.mode_changed");var removals=championRows("adventure.site.removed");
assertEquals(2,modes.size()+removals.size(),"Actual acknowledged champion mode/removal must export exactly their own mutations, never refused/failed copies");
assertEquals(1,modes.size());assertEquals(1,removals.size());var mode=modes.getFirst().getAsJsonObject("data");var removed=removals.getFirst().getAsJsonObject("data");
assertEquals(changed.id().toString(),mode.get("site_id").getAsString());assertEquals(changed.registrationRevision().toString(),mode.get("site_revision").getAsString());
assertEquals(original.registrationRevision().toString(),mode.get("requested_site_revision").getAsString());assertEquals("party-available",mode.get("mode_before").getAsString());assertEquals("party-required",mode.get("mode_after").getAsString());
assertEquals("future_acceptances",mode.get("application_scope").getAsString());assertEquals(other.id().toString(),removed.get("site_id").getAsString());assertEquals(other.registrationRevision().toString(),removed.get("site_revision").getAsString());
assertEquals("retained",removed.get("earned_reward_impact").getAsString());assertEquals("Same Champion",removed.get("site_name").getAsString());
}
}
private java.util.List<com.google.gson.JsonObject> championRows(String type)throws Exception{
return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)
&&row.getAsJsonObject("data").get("site_kind").getAsString().equals("champion")).toList();
}
@Test void registeredChampionCreationPersistsIndependentRevisionAndExportsExactOriginalActorDefinitionAfterReload()throws Exception{
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;f.activate(actor);var before=CombatExperienceNativeTest.record(f,actor);
String originalSession=((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions"))
.active(actor.core.id).orElseThrow().session().toString();
assertTrue(f.adminCommand.getExecutor().onCommand(actor.player,f.adminCommand,"fstickadmin",
new String[]{"adventure-site","place","champion","--mode","individual-only","8","16","Same Champion"}));
ReskillEventsNativeTest.await(f,()->actor.messages.stream().anyMatch(value->value.startsWith("Placed champion")));
var saved=fixture.directory.snapshot().sites().getFirst();
assertEquals(AdventureQuestState.SiteType.CHAMPION,saved.type());assertEquals(AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY,saved.participationMode());
assertNotNull(saved.registrationRevision(),"Actual acknowledged champion creation must retain its independent definition revision");
assertNotEquals(saved.id(),saved.registrationRevision());assertEquals(AdventureQuestState.Arena.rectangle(8,16),saved.arena());
assertEquals(saved,new YamlAdventureQuestRepository(data).load().sites().getFirst());assertEquals(before,CombatExperienceNativeTest.record(f,actor));
ReskillEventsNativeTest.await(f,()->{
try{return ReskillEventsNativeTest.records(data).stream().anyMatch(row->row.get("type").getAsString().endsWith("adventure.site.registered")
&&row.getAsJsonObject("data").get("site_kind").getAsString().equals("champion"));}
catch(Exception failure){throw new AssertionError(failure);}
});
f.plugin.setEnabled(false);f.awaitWorkers();drain(f,"championAuditDrain");
var rows=ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("adventure.site.registered")
&&row.getAsJsonObject("data").get("site_kind").getAsString().equals("champion")).toList();
assertEquals(1,rows.size(),"Actual saved registered champion administration must export one owner record, not depend on camp-only filtering");
var row=rows.getFirst();var values=row.getAsJsonObject("data");
assertEquals(actor.core.id.toString(),row.get("playerid").getAsString());assertEquals(before.id().toString(),row.get("characterid").getAsString());assertEquals(originalSession,values.get("session_id").getAsString());
assertEquals(saved.id().toString(),values.get("site_id").getAsString());assertEquals(saved.registrationRevision().toString(),values.get("site_revision").getAsString());
assertEquals("champion",values.get("site_kind").getAsString());assertEquals("persisted",values.get("revision_capture_quality").getAsString());
assertEquals("individual-only",values.get("mode_after").getAsString());assertEquals("Same Champion",values.get("site_name").getAsString());
assertEquals(saved.world().toString(),values.get("world_id").getAsString());assertEquals(12,values.get("x").getAsDouble());assertEquals(70,values.get("y").getAsDouble());assertEquals(-8,values.get("z").getAsDouble());
assertEquals(8,values.getAsJsonObject("arena").get("x_blocks").getAsInt());assertEquals(16,values.getAsJsonObject("arena").get("z_blocks").getAsInt());
}
}
}
@@ -0,0 +1,41 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntityRegainHealthEvent;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
class CharacterBossAbilityEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
static final class NativeHealingGuard implements Listener {
final boolean cancel;final List<EntityRegainHealthEvent> observed=new ArrayList<>();
NativeHealingGuard(boolean cancel){this.cancel=cancel;}
@EventHandler(priority=EventPriority.HIGHEST)public void regain(EntityRegainHealthEvent event){if(event.getEntity().getType()!=org.bukkit.entity.EntityType.VINDICATOR)return;event.setAmount(5);event.setCancelled(cancel);observed.add(event);}
}
@ParameterizedTest @ValueSource(booleans={false,true})
void actualSixReadyNativeSlotsDamageAndAdjustedOrCancelledHealingReachSystemCastOwnersWithoutTemplateMutation(boolean cancelled)throws Exception{
try(var fixture=new CharacterBossAuditNativeFixture(data)){
var b=fixture.base;fixture.authorAndAccept();b.enter(b.boss);var shell=fixture.shell();shell.entity.setHealth(50);b.nativeEffects.get(shell.entity.getUniqueId()).put(PotionEffectType.WEAKNESS,new PotionEffect(PotionEffectType.WEAKNESS,200,0));var nativeDamage=new ArrayList<EntityDamageByEntityEvent>();b.actor.damage=(amount,sourceEntity)->{var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(sourceEntity);when(source.getDirectEntity()).thenReturn(sourceEntity);var event=new EntityDamageByEntityEvent(sourceEntity,b.actor.player,EntityDamageEvent.DamageCause.MAGIC,source,amount);event.setCancelled(cancelled);b.f.dispatcher.accept(event);nativeDamage.add(event);};var healing=new NativeHealingGuard(cancelled);b.f.dispatcher.register(healing,b.f.plugin);fixture.damage(b.actor,shell,3,false);b.shared.pulse();assertEquals(2,nativeDamage.size(),"Both ready offensive slots actually traverse native damage dispatch in the same pulse");assertEquals(1,healing.observed.size());assertEquals(cancelled?50:55,shell.health[0],"Actual native adjusted/cancelled healing governs applied shell health, not requested ability amount");assertFalse(b.nativeEffects.get(shell.entity.getUniqueId()).containsKey(PotionEffectType.WEAKNESS));assertTrue(b.nativeEffects.get(shell.entity.getUniqueId()).containsKey(PotionEffectType.RESISTANCE));assertTrue(b.playerEffects.containsKey(PotionEffectType.WEAKNESS));assertTrue(b.playerEffects.containsKey(PotionEffectType.SLOWNESS));assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());assertEquals(fixture.template,((games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characters")).find(fixture.template.id()).get().orElseThrow());settle(b);
var casts=owned("ability.cast");assertEquals(6,casts.size(),"Six actual ready slot attempts, including native rejected healing, need original independent system cast facts; ally-less Guardian Bond cannot be fabricated");assertEquals(Set.of("fireball","frost_nova","hex","second_wind","purify","stone_skin"),casts.stream().map(row->row.getAsJsonObject("data").get("ability_id").getAsString()).collect(java.util.stream.Collectors.toSet()));var resolved=owned("ability.resolved");assertEquals(6,resolved.size());var damage=owned("combat.damage");assertEquals(2,damage.size());for(int i=0;i<2;i++){var nativeEvent=nativeDamage.get(i);var abilityId=i==0?"fireball":"frost_nova";var matching=damage.stream().filter(row->row.getAsJsonObject("data").get("ability_id").getAsString().equals(abilityId)).findFirst().orElseThrow();var values=matching.getAsJsonObject("data");assertEquals(nativeEvent.isCancelled()?"cancelled":nativeEvent.getFinalDamage()>0?"accepted":"no_op",values.get("outcome").getAsString());assertEquals(nativeEvent.getFinalDamage(),values.get("final_damage").getAsDouble());assertEquals(b.actor.core.id.toString(),values.get("target_player_id").getAsString());assertEquals(b.actor.core.record.id().toString(),values.get("target_character_id").getAsString());}var healed=owned("combat.healed");assertEquals(1,healed.size());assertEquals(cancelled?"cancelled":"accepted",healed.getFirst().getAsJsonObject("data").get("outcome").getAsString());assertEquals(cancelled?0:5,healed.getFirst().getAsJsonObject("data").get("applied_amount").getAsDouble());assertEquals(shell.entity.getUniqueId().toString(),healed.getFirst().getAsJsonObject("data").get("target_entity_id").getAsString());for(var row:casts){var values=row.getAsJsonObject("data");assertEquals("system",values.get("actor_kind").getAsString());assertEquals(fixture.template.id().toString(),values.get("subject_character_id").getAsString());assertEquals(shell.entity.getUniqueId().toString(),values.get("native_entity_id").getAsString());assertEquals(fixture.custody.operation().toString(),values.get("custody_operation_id").getAsString());assertFalse(values.has("session_id"));assertFalse(row.has("playerid"));assertTrue(resolved.stream().anyMatch(result->result.getAsJsonObject("data").get("cast_id").equals(values.get("cast_id"))));}int attacks=nativeDamage.size();b.shared.pulse();assertEquals(attacks,nativeDamage.size(),"Existing native boss-only offensive cooldowns stay authoritative even when damage was cancelled");
}
}
private List<com.google.gson.JsonObject> owned(String type)throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)&&row.getAsJsonObject("data").has("entity_role")&&row.getAsJsonObject("data").get("entity_role").getAsString().equals("character_boss")).toList();}
private static void settle(DungeonAuditNativeFixture b)throws Exception{fence(FantasyStickLifecycleNativeTest.Fixture.field(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characterBossEvents"),"outbox"));fence(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"eventLog"));}
private static void fence(Object owner)throws Exception{((java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(owner,"worker")).submit(()->{}).get(5,java.util.concurrent.TimeUnit.SECONDS);}
}
@@ -0,0 +1,56 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.progression.Stat;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.bukkit.attribute.Attribute;
import org.bukkit.entity.Entity;
import org.bukkit.entity.EntityType;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Mob;
/** Feature-local extension of strict dungeon ports; real template custody, menus and registered commands remain installed. */
final class CharacterBossAuditNativeFixture implements AutoCloseable {
final DungeonAuditNativeFixture base;
final FantasyStickLifecycleNativeTest.Actor templateActor;
final CharacterRecord template;
final Map<UUID,NativeBoss> shells=new HashMap<>();
games.dmg.fantasystick.dungeon.DungeonState.CharacterBoss custody;
static final class NativeBoss {
final Mob entity;final double[] maximum={20},attack={1},health={20},absorption={0};final LivingEntity[] target={null};
NativeBoss(Mob entity){this.entity=entity;}
}
CharacterBossAuditNativeFixture(Path data)throws Exception{this(data,List.of("speed_i","low_light_vision","quicker_regeneration_i"));}
CharacterBossAuditNativeFixture(Path data,List<String> equippedPassives)throws Exception{this(data,equippedPassives,Map.of());}
CharacterBossAuditNativeFixture(Path data,List<String> equippedPassives,Map<Stat,BigInteger> configuredStats)throws Exception{
assertEquals(3,equippedPassives.size(),"Real template has exactly three passive slots");
base=new DungeonAuditNativeFixture(data);templateActor=new FantasyStickLifecycleNativeTest.Actor(data);base.f.players.put(templateActor.core.id,templateActor.player);var old=templateActor.core.record;var stats=new java.util.EnumMap<Stat,BigInteger>(Stat.class);for(var stat:Stat.values())stats.put(stat,configuredStats.getOrDefault(stat,BigInteger.valueOf(30)));var abilities=List.of("fireball","frost_nova","hex","second_wind","purify","stone_skin","guardian_bond");
template=new CharacterRecord(old.id(),"Original Template Boss",old.revision().add(BigInteger.ONE),stats,old.level(),old.experience(),old.unspentPoints(),BigDecimal.valueOf(40),Set.copyOf(abilities),abilities,List.copyOf(equippedPassives),Map.of("fireball",java.time.Instant.parse("2099-01-01T00:00:00Z")),old.restrictedKills(),false,null,null,old.coins(),old.coinsInitialized(),old.rewardReceipts(),old.pvpCoinEscrow(),new games.dmg.fantasystick.persistence.CharacterGear(games.dmg.fantasystick.persistence.GearTier.COPPER,games.dmg.fantasystick.persistence.GearTier.GOLD,games.dmg.fantasystick.persistence.GearTier.IRON),old.sheetGeneration());new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(template,old.revision());templateActor.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(template));var originalPort=base.shared.nativeSpawnSetup;base.shared.nativeSpawnSetup=entity->{originalPort.accept(entity);if(entity.getType()==EntityType.VINDICATOR)configure((Mob)entity);};
}
private void configure(Mob entity){
var state=new NativeBoss(entity);shells.put(entity.getUniqueId(),state);var maximum=mock(org.bukkit.attribute.AttributeInstance.class);var attack=mock(org.bukkit.attribute.AttributeInstance.class);when(entity.getAttribute(Attribute.MAX_HEALTH)).thenReturn(maximum);when(entity.getAttribute(Attribute.ATTACK_DAMAGE)).thenReturn(attack);when(maximum.getValue()).thenAnswer(call->state.maximum[0]);when(attack.getValue()).thenAnswer(call->state.attack[0]);doAnswer(call->{state.maximum[0]=call.getArgument(0);return null;}).when(maximum).setBaseValue(anyDouble());doAnswer(call->{state.attack[0]=call.getArgument(0);return null;}).when(attack).setBaseValue(anyDouble());when(entity.getHealth()).thenAnswer(call->state.health[0]);doAnswer(call->{state.health[0]=call.getArgument(0);return null;}).when(entity).setHealth(anyDouble());when(entity.getEquipment()).thenReturn(mock(org.bukkit.inventory.EntityEquipment.class));when(entity.hasLineOfSight(any(Entity.class))).thenReturn(true);when(entity.getAbsorptionAmount()).thenAnswer(call->state.absorption[0]);doAnswer(call->{state.absorption[0]=call.getArgument(0);return null;}).when(entity).setAbsorptionAmount(anyDouble());doAnswer(call->{state.target[0]=call.getArgument(0);return null;}).when(entity).setTarget(any());when(entity.getTarget()).thenAnswer(call->state.target[0]);
}
void authorAndAccept()throws Exception{
base.authorAndAccept("players",true,()->{var held=templateActor.core.inventory.getItemInOffHand();templateActor.core.inventory.setItemInOffHand(null);base.actor.core.inventory.setItemInMainHand(held);base.command("dungeon","room","boss","assign-sheet","2");custody=base.directory.custody(template.id()).orElseThrow();assertEquals(template,custody.character());assertTrue(base.actor.core.inventory.getItemInMainHand().isEmpty());});
assertNotEquals(template.id(),base.actor.core.record.id());assertEquals(Set.of(base.actor.core.record.id()),base.quest.snapshot());assertTrue(((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(base.f.plugin,"sessions")).active(templateActor.core.id).isEmpty());
}
FantasyStickLifecycleNativeTest.Actor acceptedChallenger(Path data)throws Exception{
var challenger=new FantasyStickLifecycleNativeTest.Actor(data);base.f.players.put(challenger.core.id,challenger.player);challenger.core.location.setWorld(base.actor.world);challenger.core.location.setX(12.75);challenger.core.location.setY(base.actor.core.location.getY());challenger.core.location.setZ(base.actor.core.location.getZ());doReturn(base.actor.world).when(challenger.player).getWorld();doReturn(false).when(challenger.player).isDead();DungeonAuditNativeFixture.effects(challenger.player,new HashMap<>());challenger.damage=(amount,source)->{};base.f.activate(challenger);base.shared.click(challenger,base.shared.open(challenger),false);ReskillEventsNativeTest.await(base.f,()->base.shared.directory.active().stream().anyMatch(quest->quest.snapshot().contains(challenger.core.record.id())));challenger.core.location.setX(17.75);return challenger;
}
void damage(FantasyStickLifecycleNativeTest.Actor actor,NativeBoss shell,double amount,boolean cancelled){
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);var event=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,shell.entity,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,amount);event.setCancelled(cancelled);base.f.dispatcher.accept(event);assertEquals(cancelled,event.isCancelled());
}
NativeBoss shell(){var entity=base.nativeEntity(EntityType.VINDICATOR);return shells.get(entity.getUniqueId());}
@Override public void close()throws java.io.IOException{base.close();try{WitchAuditTestSupport.drain(base.f,"characterBossDrain");}catch(Exception failure){throw new java.io.IOException("Character boss audit fixture did not drain",failure);}}
}
@@ -0,0 +1,69 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.dungeon.DungeonDirectory;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.persistence.CharacterStorage;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class CharacterBossCustodyEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void authenticHeldInactivePartylessCharacterReachesRegisteredAssignmentConsumptionAndExactRecoveryBeforeIndependentOwnerSuccess()throws Exception{
Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;var source=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var saved=storage.find(actor.core.record.id()).get().orElseThrow();var originalLocation=actor.core.location.clone();
command(f,actor,"dungeon","create","--mode","individual-only","Original Character Realm");var dungeon=source.snapshot().dungeons().getFirst();command(f,actor,"dungeon","edit","start",dungeon.id().toString());command(f,actor,"dungeon","room","create","corner","4","4","3","Original Custody Room");var room=source.find(dungeon.id()).orElseThrow().rooms().getFirst();command(f,actor,"dungeon","room","boss","mob","husk","Original Prior Guard");var prior=source.room(dungeon.id(),room.id()).orElseThrow().boss();var sheets=new CharacterSheets();var held=actor.core.inventory.getItemInOffHand();assertTrue(sheets.authorizes(held,saved));actor.core.inventory.setItemInOffHand(null);actor.core.inventory.setItemInMainHand(held);var unrelated=java.util.Arrays.stream(actor.core.inventory.getContents()).map(item->item==null?null:item.clone()).toArray(org.bukkit.inventory.ItemStack[]::new);
command(f,actor,"dungeon","room","boss","assign-sheet","1.125");var custody=source.custody(saved.id()).orElseThrow();assertEquals(saved,custody.character());assertEquals("1.125",custody.multiplier().toPlainString());assertEquals(prior.type(),custody.priorType());assertEquals(prior.name(),custody.priorName());assertTrue(actor.core.inventory.getItemInMainHand().isEmpty());for(int slot=1;slot<unrelated.length;slot++)assertEquals(unrelated[slot],actor.core.inventory.getItem(slot));assertEquals(saved,storage.find(saved.id()).get().orElseThrow());assertTrue(((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(actor.core.id).isEmpty());
command(f,actor,"dungeon","edit","done");actor.core.location.setX(100);command(f,actor,"dungeon","boss","recover-sheet",dungeon.id().toString());assertTrue(source.custody(saved.id()).isEmpty());assertNull(source.room(dungeon.id(),room.id()).orElseThrow().boss());assertFalse(source.find(dungeon.id()).orElseThrow().enabled());var recovered=java.util.Arrays.stream(actor.core.inventory.getContents()).filter(java.util.Objects::nonNull).filter(item->sheets.authorizes(item,saved)).toList();assertEquals(1,recovered.size());assertEquals(saved,storage.find(saved.id()).get().orElseThrow());var recoveryLocation=actor.core.location.clone();ReskillEventsNativeTest.await(f,()->{try{return rows("dungeon.character_boss.assigned").size()==1&&rows("dungeon.character_boss.recovered").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});f.plugin.setEnabled(false);f.awaitWorkers();
var assigned=rows("dungeon.character_boss.assigned");assertEquals(1,assigned.size(),"Actual saved custody plus exact physical consumption needs its independent success owner, not generic admin.action or definition configuration");var values=assigned.getFirst().getAsJsonObject("data");assertEquals(actor.core.id.toString(),assigned.getFirst().get("playerid").getAsString());assertFalse(values.has("session_id"));assertEquals(saved.id().toString(),values.get("subject_character_id").getAsString());assertEquals(saved.name(),values.get("subject_character_name").getAsString());assertEquals(saved.revision().toString(),values.get("subject_character_revision").getAsString());assertEquals(saved.sheetGeneration().toString(),values.get("subject_sheet_generation").getAsString());assertEquals(custody.operation().toString(),values.get("custody_operation_id").getAsString());assertEquals(dungeon.id().toString(),values.get("dungeon_id").getAsString());assertEquals(room.id().toString(),values.get("room_id").getAsString());assertEquals("1.125",values.get("multiplier").getAsString());assertEquals(originalLocation.getX(),values.getAsJsonObject("actor_location").get("x").getAsDouble());assertEquals("HUSK",values.get("prior_entity_type").getAsString());assertEquals("Original Prior Guard",values.get("prior_entity_name").getAsString());assertFalse(values.has("character_snapshot"));assertFalse(values.has("item"));
var returns=rows("dungeon.character_boss.recovered");assertEquals(1,returns.size());var returned=returns.getFirst().getAsJsonObject("data");assertEquals(saved.id().toString(),returned.get("subject_character_id").getAsString());assertEquals(custody.operation().toString(),returned.get("custody_operation_id").getAsString());assertTrue(returned.has("recovery_operation_id"));assertEquals(recoveryLocation.getX(),returned.getAsJsonObject("actor_location").get("x").getAsDouble());assertTrue(rows("combat.reward_resolved").isEmpty());assertTrue(rows("dungeon.completed").isEmpty());assertTrue(rows("adventure.trophy_earned").isEmpty());
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans={false,true})
void acknowledgedOriginalCustodyWithoutDeferredConsumptionRemainsPendingNotSuccessAndExactOriginalSheetRecovers(boolean offline)throws Exception{
Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;var source=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var saved=storage.find(actor.core.record.id()).get().orElseThrow();command(f,actor,"dungeon","create","Original Pending Custody");var dungeon=source.snapshot().dungeons().getFirst();command(f,actor,"dungeon","edit","start",dungeon.id().toString());command(f,actor,"dungeon","room","create","corner","4","4","3","Original Pending Room");var room=source.find(dungeon.id()).orElseThrow().rooms().getFirst();command(f,actor,"dungeon","room","boss","mob","husk","Original Pending Guard");var held=actor.core.inventory.getItemInOffHand();actor.core.inventory.setItemInOffHand(null);actor.core.inventory.setItemInMainHand(held);var originalInventory=java.util.Arrays.stream(actor.core.inventory.getContents()).map(item->item==null?null:item.clone()).toArray(org.bukkit.inventory.ItemStack[]::new);var originalLocation=actor.core.location.clone();
f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"dungeon","room","boss","assign-sheet","1.125"});if(offline)org.mockito.Mockito.doReturn(false).when(actor.player).isOnline();else org.mockito.Mockito.doReturn(false).when(actor.player).hasPermission("fantasystick.admin");actor.core.location.setX(100);ReskillEventsNativeTest.await(f,()->source.custody(saved.id()).isPresent());settleSourcesAndAudit(f,source,storage);var custody=source.custody(saved.id()).orElseThrow();assertEquals(saved,custody.character());assertArrayEquals(originalInventory,actor.core.inventory.getContents());assertEquals(saved,storage.find(saved.id()).get().orElseThrow());assertTrue(rows("dungeon.character_boss.assigned").isEmpty(),"Saved source custody alone cannot prove physical sheet consumption");
var pending=rows("action.resolved").stream().filter(row->row.getAsJsonObject("data").has("custody_operation_id")).toList();assertEquals(1,pending.size(),"An acknowledged source custody phase must remain observable without deferred reply permission/online status, explicitly pending rather than assigned");var values=pending.getFirst().getAsJsonObject("data");assertEquals("pending_native_consumption",values.get("outcome").getAsString());assertEquals("custody_saved",values.get("phase").getAsString());assertEquals(custody.operation().toString(),values.get("custody_operation_id").getAsString());assertEquals(saved.id().toString(),values.get("subject_character_id").getAsString());assertEquals(actor.core.id.toString(),pending.getFirst().get("playerid").getAsString());assertEquals(originalLocation.getX(),values.getAsJsonObject("actor_location").get("x").getAsDouble());assertFalse(values.has("session_id"));
org.mockito.Mockito.doReturn(true).when(actor.player).hasPermission("fantasystick.admin");org.mockito.Mockito.doReturn(true).when(actor.player).isOnline();command(f,actor,"dungeon","edit","done");command(f,actor,"dungeon","boss","recover-sheet",dungeon.id().toString());ReskillEventsNativeTest.await(f,()->{try{return rows("dungeon.character_boss.recovered").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});assertTrue(source.custody(saved.id()).isEmpty());assertEquals(saved,storage.find(saved.id()).get().orElseThrow());var sheets=new CharacterSheets();assertEquals(1,java.util.Arrays.stream(actor.core.inventory.getContents()).filter(java.util.Objects::nonNull).filter(item->sheets.authorizes(item,saved)).count());f.plugin.setEnabled(false);f.awaitWorkers();assertEquals(1,rows("dungeon.character_boss.recovered").size());assertTrue(rows("dungeon.character_boss.assigned").isEmpty());assertTrue(rows("combat.reward_resolved").isEmpty());
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans={false,true})
void deliveredOperationMarkedSheetWithFailedFinalCustodySaveRemainsPendingAndRetryCompletesExactRecoveryOnce(boolean restart)throws Exception{
Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;var source=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var saved=storage.find(actor.core.record.id()).get().orElseThrow();command(f,actor,"dungeon","create","Original Interrupted Recovery");var dungeon=source.snapshot().dungeons().getFirst();command(f,actor,"dungeon","edit","start",dungeon.id().toString());command(f,actor,"dungeon","room","create","corner","4","4","3","Original Recovery Room");var room=source.find(dungeon.id()).orElseThrow().rooms().getFirst();command(f,actor,"dungeon","room","boss","mob","husk","Original Recovery Guard");var held=actor.core.inventory.getItemInOffHand();actor.core.inventory.setItemInOffHand(null);actor.core.inventory.setItemInMainHand(held);command(f,actor,"dungeon","room","boss","assign-sheet","1.125");var custody=source.custody(saved.id()).orElseThrow();assertTrue(actor.core.inventory.getItemInMainHand().isEmpty());command(f,actor,"dungeon","edit","done");actor.core.location.setX(100);var originalLocation=actor.core.location.clone();var inventory=org.mockito.Mockito.spy(actor.core.inventory);org.mockito.Mockito.when(actor.player.getInventory()).thenReturn(inventory);var blocked=new java.util.concurrent.atomic.AtomicBoolean();var target=data.resolve("dungeons.yml");var backup=data.resolve("last-prepared-dungeons.yml");org.mockito.Mockito.doAnswer(call->{call.callRealMethod();if(blocked.compareAndSet(false,true)){Files.move(target,backup);Files.createDirectory(target);Files.writeString(target.resolve("blocked"),"test-only final source save refusal");}return null;}).when(inventory).setItem(org.mockito.ArgumentMatchers.eq(0),org.mockito.ArgumentMatchers.any(org.bukkit.inventory.ItemStack.class));
command(f,actor,"dungeon","boss","recover-sheet",dungeon.id().toString());assertTrue(blocked.get());assertTrue(actor.messages.getLast().contains("durable custody completion must be retried"),actor.messages.toString());var prepared=source.custody(saved.id()).orElseThrow();assertNotNull(prepared.recovery());assertEquals(custody.operation(),prepared.operation());assertEquals(saved,prepared.character());var sheets=new CharacterSheets();var delivered=java.util.Arrays.stream(actor.core.inventory.getContents()).filter(java.util.Objects::nonNull).filter(item->sheets.authorizes(item,saved)).toList();assertEquals(1,delivered.size());assertTrue(sheets.hasRecovery(delivered.getFirst(),prepared.recovery().operation()));assertEquals(saved,storage.find(saved.id()).get().orElseThrow());assertTrue(rows("dungeon.character_boss.recovered").isEmpty(),"Native sheet delivery cannot acknowledge failed durable custody completion");settleSourcesAndAudit(f,source,storage);
var pending=rows("action.resolved").stream().filter(row->"recovery_completion_pending".equals(row.getAsJsonObject("data").has("phase")?row.getAsJsonObject("data").get("phase").getAsString():null)).toList();assertEquals(1,pending.size(),"Exact native recovery delivery with failed final source save must retain original pending evidence, not success or silence");var values=pending.getFirst().getAsJsonObject("data");assertEquals("pending_durable_completion",values.get("outcome").getAsString());assertEquals(prepared.recovery().operation().toString(),values.get("recovery_operation_id").getAsString());assertEquals(custody.operation().toString(),values.get("custody_operation_id").getAsString());assertEquals(saved.id().toString(),values.get("subject_character_id").getAsString());assertEquals("exact_authorized_sheet_delivered",values.get("sheet_status").getAsString());assertEquals(originalLocation.getX(),values.getAsJsonObject("actor_location").get("x").getAsDouble());assertEquals(actor.core.id.toString(),pending.getFirst().get("playerid").getAsString());
Files.delete(target.resolve("blocked"));Files.delete(target);Files.move(backup,target);if(restart){var persisted=Files.readAllBytes(target);var characterFile=data.resolve("characters").resolve(saved.id()+".yml");var characterBytes=Files.readAllBytes(characterFile);f.plugin.setEnabled(false);f.awaitWorkers();WitchAuditTestSupport.drain(f,"characterBossDrain");f.plugin.setEnabled(true);source=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(prepared,source.custody(saved.id()).orElseThrow(),"Restart preserves exact pending custody, original template and recovery operation rather than deriving completion from physical delivery");assertEquals(saved,storage.find(saved.id()).get().orElseThrow());assertArrayEquals(persisted,Files.readAllBytes(target));assertArrayEquals(characterBytes,Files.readAllBytes(characterFile));assertTrue(sheets.hasRecovery(actor.core.inventory.getItem(0),prepared.recovery().operation()));assertTrue(rows("dungeon.character_boss.recovered").isEmpty());}actor.core.location.setX(105);command(f,actor,"dungeon","boss","recover-sheet",dungeon.id().toString());command(f,actor,"dungeon","boss","recover-sheet",dungeon.id().toString());ReskillEventsNativeTest.await(f,()->{try{return rows("dungeon.character_boss.recovered").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});assertTrue(source.custody(saved.id()).isEmpty());assertNull(source.room(dungeon.id(),room.id()).orElseThrow().boss());assertFalse(source.find(dungeon.id()).orElseThrow().enabled());assertEquals(saved,storage.find(saved.id()).get().orElseThrow());var returned=java.util.Arrays.stream(actor.core.inventory.getContents()).filter(java.util.Objects::nonNull).filter(item->sheets.authorizes(item,saved)).toList();assertEquals(1,returned.size());assertFalse(sheets.hasRecovery(returned.getFirst(),prepared.recovery().operation()));f.plugin.setEnabled(false);f.awaitWorkers();var returns=rows("dungeon.character_boss.recovered");assertEquals(1,returns.size());var returnedFacts=returns.getFirst().getAsJsonObject("data");assertEquals(prepared.recovery().operation().toString(),returnedFacts.get("recovery_operation_id").getAsString());assertEquals(105,returnedFacts.getAsJsonObject("actor_location").get("x").getAsDouble());assertTrue(rows("combat.reward_resolved").isEmpty());assertTrue(rows("dungeon.completed").isEmpty());
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans={false,true})
void changedHeldSheetAfterSavedCustodyHasOriginalAcknowledgedRollbackOrPendingFailureEvidenceWithoutSuccess(boolean failedRollback)throws Exception{
Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;var source=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var saved=storage.find(actor.core.record.id()).get().orElseThrow();command(f,actor,"dungeon","create","Original Rollback Custody");var dungeon=source.snapshot().dungeons().getFirst();command(f,actor,"dungeon","edit","start",dungeon.id().toString());command(f,actor,"dungeon","room","create","corner","4","4","3","Original Rollback Room");var room=source.find(dungeon.id()).orElseThrow().rooms().getFirst();command(f,actor,"dungeon","room","boss","mob","husk","Original Rollback Guard");var prior=source.room(dungeon.id(),room.id()).orElseThrow().boss();var held=actor.core.inventory.getItemInOffHand();actor.core.inventory.setItemInOffHand(null);actor.core.inventory.setItemInMainHand(held);var original=actor.core.location.clone();var target=data.resolve("dungeons.yml");var backup=data.resolve("last-assigned-dungeons.yml");var interrupted=new java.util.concurrent.atomic.AtomicBoolean();if(failedRollback){var inventory=org.mockito.Mockito.spy(actor.core.inventory);org.mockito.Mockito.when(actor.player.getInventory()).thenReturn(inventory);org.mockito.Mockito.doAnswer(call->{var item=call.callRealMethod();if(source.custody(saved.id()).isPresent()&&interrupted.compareAndSet(false,true)){Files.move(target,backup);Files.createDirectory(target);Files.writeString(target.resolve("blocked"),"test-only rollback source save refusal");}return item;}).when(inventory).getItemInMainHand();}
var worker=(java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(source,"worker");var entered=new java.util.concurrent.CountDownLatch(1);var release=new java.util.concurrent.CountDownLatch(1);worker.execute(()->{entered.countDown();try{if(!release.await(5,java.util.concurrent.TimeUnit.SECONDS))throw new AssertionError("Unreleased custody authority barrier");}catch(InterruptedException interruptedException){Thread.currentThread().interrupt();throw new AssertionError(interruptedException);}});assertTrue(entered.await(5,java.util.concurrent.TimeUnit.SECONDS));int messages=actor.messages.size();org.bukkit.inventory.ItemStack[] moved;try{f.adminCommand.execute(actor.player,"fstickadmin",new String[]{"dungeon","room","boss","assign-sheet","1.125"});actor.core.inventory.setItemInMainHand(null);actor.core.inventory.setItem(5,held);actor.core.location.setX(100);moved=java.util.Arrays.stream(actor.core.inventory.getContents()).map(item->item==null?null:item.clone()).toArray(org.bukkit.inventory.ItemStack[]::new);}finally{release.countDown();}ReskillEventsNativeTest.await(f,()->actor.messages.size()>messages);assertArrayEquals(moved,actor.core.inventory.getContents());assertEquals(saved,storage.find(saved.id()).get().orElseThrow());var expectedPhase=failedRollback?"assignment_rollback_pending":"assignment_cancelled";if(failedRollback){assertTrue(interrupted.get());assertTrue(actor.messages.getLast().contains("saved but sheet consumption could not be confirmed"),actor.messages.toString());assertEquals(saved,source.custody(saved.id()).orElseThrow().character());}else{assertTrue(actor.messages.getLast().contains("cancelled because the held sheet changed"),actor.messages.toString());assertTrue(source.custody(saved.id()).isEmpty());assertEquals(prior,source.room(dungeon.id(),room.id()).orElseThrow().boss());}settleSourcesAndAudit(f,source,storage);assertTrue(rows("dungeon.character_boss.assigned").isEmpty());var resolved=rows("action.resolved").stream().filter(row->expectedPhase.equals(row.getAsJsonObject("data").has("phase")?row.getAsJsonObject("data").get("phase").getAsString():null)).toList();assertEquals(1,resolved.size(),"Original acknowledged assignment rollback or unacknowledged rollback must reach its pending/cancelled owner, never assigned success or reconstructed current custody");var values=resolved.getFirst().getAsJsonObject("data");assertEquals(failedRollback?"pending_rollback":"cancelled",values.get("outcome").getAsString());assertEquals(saved.id().toString(),values.get("subject_character_id").getAsString());assertEquals(original.getX(),values.getAsJsonObject("actor_location").get("x").getAsDouble());assertEquals(actor.core.id.toString(),resolved.getFirst().get("playerid").getAsString());assertEquals("held_sheet_changed",values.get("reason").getAsString());assertTrue(values.has("custody_operation_id"));
if(failedRollback){Files.delete(target.resolve("blocked"));Files.delete(target);Files.move(backup,target);}command(f,actor,"dungeon","edit","done");if(failedRollback){command(f,actor,"dungeon","boss","recover-sheet",dungeon.id().toString());ReskillEventsNativeTest.await(f,()->{try{return rows("dungeon.character_boss.recovered").size()==1;}catch(Exception failure){throw new AssertionError(failure);}});assertTrue(source.custody(saved.id()).isEmpty());}var sheets=new CharacterSheets();assertEquals(1,java.util.Arrays.stream(actor.core.inventory.getContents()).filter(java.util.Objects::nonNull).filter(item->sheets.authorizes(item,saved)).count());assertEquals(saved,storage.find(saved.id()).get().orElseThrow());f.plugin.setEnabled(false);f.awaitWorkers();assertTrue(rows("dungeon.character_boss.assigned").isEmpty());assertTrue(rows("combat.reward_resolved").isEmpty());assertTrue(rows("dungeon.completed").isEmpty());
}
}
/** Test-local serialized fences; no source mutation, synthetic event or production lifecycle change. */
private static void settleSourcesAndAudit(FantasyStickLifecycleNativeTest.Fixture f,DungeonDirectory source,CharacterStorage storage)throws Exception{
fence(source,"worker");fence(storage,"worker");Runnable task;while((task=f.tasks.poll())!=null)task.run();
var audit=FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterBossEvents");fence(FantasyStickLifecycleNativeTest.Fixture.field(audit,"outbox"),"worker");fence(FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"eventLog"),"worker");
}
private static void fence(Object owner,String field)throws Exception{((java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(owner,field)).submit(()->{}).get(5,java.util.concurrent.TimeUnit.SECONDS);}
private static void command(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor,String...args)throws Exception{DungeonAuthoringEventsNativeTest.command(f,actor,args);}
private List<com.google.gson.JsonObject> rows(String type)throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)).toList();}
}
@@ -0,0 +1,30 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.dungeon.DungeonDirectory;
import games.dmg.fantasystick.events.CharacterBossEventBridge;
import games.dmg.fantasystick.events.LocalEventLog;
import games.dmg.fantasystick.persistence.CharacterStorage;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.Properties;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class CharacterBossCustodyReplayNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void rejectedWriterCapturesExactRegisteredCustodyPhasesThenReplaysTwiceOfflineWithoutTemplateInventoryActorOrSourceRecreation()throws Exception{
Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");var folder=data.resolve("character-boss-event-intents");java.util.List<Properties> originals;byte[] definitions,character;Path characterFile;games.dmg.fantasystick.dungeon.DungeonState originalState;games.dmg.fantasystick.persistence.CharacterRecord originalCharacter;
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;var source=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");originalCharacter=storage.find(actor.core.record.id()).get().orElseThrow();characterFile=data.resolve("characters").resolve(originalCharacter.id()+".yml");command(f,actor,"dungeon","create","Original Offline Custody");var dungeon=source.snapshot().dungeons().getFirst();command(f,actor,"dungeon","edit","start",dungeon.id().toString());command(f,actor,"dungeon","room","create","corner","4","4","3","Original Offline Room");command(f,actor,"dungeon","room","boss","mob","husk","Original Offline Guard");var held=actor.core.inventory.getItemInOffHand();actor.core.inventory.setItemInOffHand(null);actor.core.inventory.setItemInMainHand(held);((LocalEventLog)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"eventLog")).close();command(f,actor,"dungeon","room","boss","assign-sheet","1.125");assertEquals(originalCharacter,source.custody(originalCharacter.id()).orElseThrow().character());assertTrue(actor.core.inventory.getItemInMainHand().isEmpty());command(f,actor,"dungeon","edit","done");actor.core.location.setX(100);command(f,actor,"dungeon","boss","recover-sheet",dungeon.id().toString());assertTrue(source.custody(originalCharacter.id()).isEmpty());assertEquals(originalCharacter,storage.find(originalCharacter.id()).get().orElseThrow());f.plugin.setEnabled(false);f.awaitWorkers();WitchAuditTestSupport.drain(f,"characterBossDrain");originals=WitchAuditTestSupport.intents(folder);assertEquals(Map.of("action.resolved",1L,"dungeon.character_boss.assigned",1L,"dungeon.character_boss.recovered",1L),originals.stream().collect(java.util.stream.Collectors.groupingBy(value->value.getProperty("type"),java.util.stream.Collectors.counting())),"Rejected writer retains each original independent custody acknowledgement, not inferred success or gameplay recovery");originalState=new games.dmg.fantasystick.persistence.YamlDungeonRepository(data).load();definitions=Files.readAllBytes(data.resolve("dungeons.yml"));character=Files.readAllBytes(characterFile);
}
try(var reopened=new CampAuditNativeFixture(data)){
var f=reopened.f;var actor=reopened.actor;var source=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertTrue(((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(actor.core.id).isEmpty());var inventory=java.util.Arrays.stream(actor.core.inventory.getContents()).map(item->item==null?null:item.clone()).toArray(org.bukkit.inventory.ItemStack[]::new);int messages=actor.messages.size();ReskillEventsNativeTest.await(f,()->{try{var rows=ReskillEventsNativeTest.records(data);return originals.stream().allMatch(intent->rows.stream().anyMatch(row->row.get("id").getAsString().equals(intent.getProperty("id"))));}catch(Exception failure){throw new AssertionError(failure);}});var audit=(CharacterBossEventBridge)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterBossEvents");audit.replay();audit.replay();assertEquals(originalCharacter,storage.find(originalCharacter.id()).get().orElseThrow());f.plugin.setEnabled(false);f.awaitWorkers();WitchAuditTestSupport.drain(f,"characterBossDrain");var rows=ReskillEventsNativeTest.records(data);for(var intent:originals){var matching=rows.stream().filter(row->row.get("id").getAsString().equals(intent.getProperty("id"))).toList();assertEquals(1,matching.size());WitchAuditTestSupport.assertOriginal(intent,matching.getFirst());assertFalse(matching.getFirst().getAsJsonObject("data").has("session_id"));}assertEquals(originalState,source.snapshot());assertArrayEquals(definitions,Files.readAllBytes(data.resolve("dungeons.yml")));assertArrayEquals(character,Files.readAllBytes(characterFile));assertArrayEquals(inventory,actor.core.inventory.getContents());assertEquals(messages,actor.messages.size());assertTrue(reopened.spawned.isEmpty());assertTrue(source.custody(originalCharacter.id()).isEmpty());assertTrue(rows.stream().noneMatch(row->row.get("type").getAsString().endsWith("combat.reward_resolved")||row.get("type").getAsString().endsWith("dungeon.completed")||row.get("type").getAsString().endsWith("adventure.trophy_earned")));
}
}
private static void command(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor,String...args)throws Exception{DungeonAuthoringEventsNativeTest.command(f,actor,args);}
}
@@ -0,0 +1,44 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.dungeon.DungeonDirectory;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
class CharacterBossCustodySafetyNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@ParameterizedTest @ValueSource(strings={"permission","multiplier","duplicate","party","active","dead","stale","source_save","audit_storage"})
void actualRegisteredRefusalOrUnavailableObservationCannotManufactureCustodySuccessOrChangeGameplay(String mode)throws Exception{
Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
try(var fixture=new CampAuditNativeFixture(data)){
var f=fixture.f;var actor=fixture.actor;var source=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var saved=storage.find(actor.core.record.id()).get().orElseThrow();command(f,actor,"dungeon","create","Original Safety Custody");var dungeon=source.snapshot().dungeons().getFirst();command(f,actor,"dungeon","edit","start",dungeon.id().toString());command(f,actor,"dungeon","room","create","corner","4","4","3","Original Safety Room");command(f,actor,"dungeon","room","boss","mob","husk","Original Safety Guard");var held=actor.core.inventory.getItemInOffHand();actor.core.inventory.setItemInOffHand(null);actor.core.inventory.setItemInMainHand(held);var target=data.resolve("dungeons.yml");var backup=data.resolve("before-refused-dungeons.yml");var definitions=Files.readAllBytes(target);
switch(mode){
case "permission"->org.mockito.Mockito.doReturn(false).when(actor.player).hasPermission("fantasystick.admin");
case "duplicate"->actor.core.inventory.setItem(5,held.clone());
case "party"->((games.dmg.fantasystick.party.CharacterPartyDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterParties")).create(saved.id(),"Original Refused Party").get();
case "active"->{actor.core.inventory.setItemInMainHand(null);actor.core.inventory.setItemInOffHand(held);f.activate(actor);assertTrue(((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(actor.core.id).isPresent());actor.core.inventory.setItemInMainHand(held);}
case "dead","stale"->{var changed=changed(saved,mode.equals("dead"));new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(changed,saved.revision());}
case "source_save"->{Files.move(target,backup);Files.createDirectory(target);Files.writeString(target.resolve("blocked"),"test-only initial source save refusal");}
case "audit_storage"->Files.writeString(data.resolve("character-boss-event-intents"),"test-only unavailable audit storage");
default->{ }
}
var before=storage.find(saved.id()).get().orElseThrow();var snapshot=source.snapshot();var inventory=java.util.Arrays.stream(actor.core.inventory.getContents()).map(item->item==null?null:item.clone()).toArray(org.bukkit.inventory.ItemStack[]::new);command(f,actor,"dungeon","room","boss","assign-sheet",mode.equals("multiplier")?"bogus":"1.125");
if(mode.equals("audit_storage")){
assertEquals(before,source.custody(saved.id()).orElseThrow().character());assertTrue(actor.core.inventory.getItemInMainHand().isEmpty());assertEquals(before,storage.find(saved.id()).get().orElseThrow());command(f,actor,"dungeon","edit","done");actor.core.location.setX(100);command(f,actor,"dungeon","boss","recover-sheet",dungeon.id().toString());assertTrue(source.custody(saved.id()).isEmpty());var sheets=new games.dmg.fantasystick.items.CharacterSheets();assertEquals(1,java.util.Arrays.stream(actor.core.inventory.getContents()).filter(java.util.Objects::nonNull).filter(item->sheets.authorizes(item,before)).count());assertEquals("test-only unavailable audit storage",Files.readString(data.resolve("character-boss-event-intents")));
}else{
assertTrue(source.custody(saved.id()).isEmpty());assertEquals(snapshot,source.snapshot());assertArrayEquals(inventory,actor.core.inventory.getContents());assertEquals(before,storage.find(saved.id()).get().orElseThrow());if(mode.equals("source_save")){Files.delete(target.resolve("blocked"));Files.delete(target);Files.move(backup,target);}assertArrayEquals(definitions,Files.readAllBytes(target));
}
f.plugin.setEnabled(false);f.awaitWorkers();WitchAuditTestSupport.drain(f,"characterBossDrain");var rows=ReskillEventsNativeTest.records(data);assertTrue(rows.stream().noneMatch(row->row.get("type").getAsString().endsWith("dungeon.character_boss.assigned")||row.get("type").getAsString().endsWith("dungeon.character_boss.recovered")||row.getAsJsonObject("data").has("custody_operation_id")),"Refused/unobserved transitions cannot create an owner success or reconstruct unavailable metadata");assertTrue(rows.stream().noneMatch(row->row.get("type").getAsString().endsWith("combat.reward_resolved")||row.get("type").getAsString().endsWith("dungeon.completed")));
}
}
private static CharacterRecord changed(CharacterRecord old,boolean dead){return new CharacterRecord(old.id(),old.name(),old.revision().add(java.math.BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),dead?java.math.BigDecimal.ZERO:old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),dead,dead?java.util.UUID.randomUUID():old.deathId(),dead?java.time.Instant.now():old.diedAt(),old.coins(),old.coinsInitialized(),old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),dead?old.sheetGeneration():old.sheetGeneration().add(java.math.BigInteger.ONE));}
private static void command(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor,String...args)throws Exception{DungeonAuthoringEventsNativeTest.command(f,actor,args);}
}
@@ -0,0 +1,80 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
import org.bukkit.event.entity.EntityPotionEffectEvent;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
class CharacterBossEffectEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@ParameterizedTest @ValueSource(booleans={false,true})
void confirmedEquippedPassivesAndRealNativePotionAcceptanceCancellationExpirationAndShellCleanupKeepIndependentOriginalApplications(boolean cancelled)throws Exception{
try(var fixture=new CharacterBossAuditNativeFixture(data)){
var b=fixture.base;fixture.authorAndAccept();b.enter(b.boss);var shell=fixture.shell();var shellEffects=b.nativeEffects.get(shell.entity.getUniqueId());assertTrue(shellEffects.containsKey(PotionEffectType.SPEED));assertTrue(shellEffects.containsKey(PotionEffectType.NIGHT_VISION));var unrelated=new PotionEffect(PotionEffectType.SATURATION,400,2);b.playerEffects.put(PotionEffectType.SATURATION,unrelated);var nativeEvents=new ArrayList<EntityPotionEffectEvent>();nativePotionPort(b,shell.entity,shellEffects,shell.entity,cancelled,nativeEvents,Set.of(PotionEffectType.RESISTANCE));nativePotionPort(b,b.actor.player,b.playerEffects,shell.entity,cancelled,nativeEvents,Set.of(PotionEffectType.WEAKNESS,PotionEffectType.SLOWNESS));b.actor.damage=(amount,source)->{};fixture.damage(b.actor,shell,3,false);b.shared.pulse();assertEquals(3,nativeEvents.size());assertTrue(nativeEvents.stream().allMatch(event->event.isCancelled()==cancelled));assertEquals(!cancelled,shellEffects.containsKey(PotionEffectType.RESISTANCE));assertEquals(!cancelled,b.playerEffects.containsKey(PotionEffectType.WEAKNESS));assertEquals(!cancelled,b.playerEffects.containsKey(PotionEffectType.SLOWNESS));settle(b);var applied=owned("effect.applied");assertEquals(cancelled?2:5,applied.size(),"Only real confirmed equipped passive and accepted native AI layers need original application owners; cancelled calls are not application success");assertEquals(Set.of("speed_i","low_light_vision"),applied.stream().filter(row->row.getAsJsonObject("data").has("passive_id")).map(row->row.getAsJsonObject("data").get("passive_id").getAsString()).collect(java.util.stream.Collectors.toSet()));for(var row:applied){var values=row.getAsJsonObject("data");assertEquals("system",values.get("actor_kind").getAsString());assertEquals(shell.entity.getUniqueId().toString(),values.get("native_entity_id").getAsString());assertEquals(fixture.template.id().toString(),values.get("subject_character_id").getAsString());assertEquals(fixture.custody.operation().toString(),values.get("custody_operation_id").getAsString());assertTrue(values.has("application_id"));assertTrue(values.has("source_location"));assertTrue(values.has("target_location"));assertFalse(values.has("session_id"));assertFalse(row.has("playerid"));}
assertEquals(cancelled?Set.of():Set.of("hex","stone_skin","frost_nova"),applied.stream().filter(row->row.getAsJsonObject("data").has("ability_id")).map(row->row.getAsJsonObject("data").get("ability_id").getAsString()).collect(java.util.stream.Collectors.toSet()));
for(var id:List.of("hex","stone_skin","frost_nova")){var result=owned("ability.resolved").stream().filter(row->row.getAsJsonObject("data").get("ability_id").getAsString().equals(id)).findFirst().orElseThrow();assertEquals(cancelled?"cancelled":"accepted",result.getAsJsonObject("data").get("native_effect_outcome").getAsString());}
if(cancelled){for(var id:List.of("hex","stone_skin","frost_nova")){var result=owned("ability.resolved").stream().filter(row->row.getAsJsonObject("data").get("ability_id").getAsString().equals(id)).findFirst().orElseThrow();assertEquals("cancelled",result.getAsJsonObject("data").get("native_effect_outcome").getAsString());assertTrue(result.getAsJsonObject("data").get("cooldown_committed").getAsBoolean(),"Existing native call-based cooldown decisions are not changed by observation");}}
else{for(var type:List.of(PotionEffectType.WEAKNESS,PotionEffectType.SLOWNESS)){var old=b.playerEffects.get(type);var event=new EntityPotionEffectEvent(b.actor.player,old,null,shell.entity,EntityPotionEffectEvent.Cause.EXPIRATION,EntityPotionEffectEvent.Action.REMOVED,false);b.f.dispatcher.accept(event);assertFalse(event.isCancelled());b.playerEffects.remove(type);}settle(b);assertEquals(2,owned("effect.ended").size(),"Accepted real expiration releases each original AI target layer once");}
b.actor.core.location.setX(100);b.shared.pulse();assertFalse(shell.entity.isValid());settle(b);assertEquals(unrelated,b.playerEffects.get(PotionEffectType.SATURATION));var ended=owned("effect.ended");assertEquals(applied.size(),ended.size(),"Actual expiration plus native shell lifetime end account for original owned layers without pulse duplicates");for(var row:applied)assertEquals(1,ended.stream().filter(end->end.getAsJsonObject("data").get("application_id").equals(row.getAsJsonObject("data").get("application_id"))).count());assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());assertTrue(ReskillEventsNativeTest.records(data).stream().noneMatch(row->row.get("type").getAsString().endsWith("combat.reward_resolved")||row.get("type").getAsString().endsWith("dungeon.completed")));
}
}
@org.junit.jupiter.api.Test
void actualUnrelatedStrongerNativeReplacementEndsOnlyTheOriginalPassiveAndPreservesTheNewLayer()throws Exception{
try(var fixture=new CharacterBossAuditNativeFixture(data)){
var b=fixture.base;fixture.authorAndAccept();b.enter(b.boss);var shell=fixture.shell();var layers=b.nativeEffects.get(shell.entity.getUniqueId());settle(b);
var original=layers.get(PotionEffectType.SPEED);assertNotNull(original);assertEquals(2,owned("effect.applied").size());
var stronger=new PotionEffect(PotionEffectType.SPEED,200,2,false,false);
var replacement=new EntityPotionEffectEvent(shell.entity,original,stronger,shell.entity,EntityPotionEffectEvent.Cause.PLUGIN,EntityPotionEffectEvent.Action.CHANGED,true);
b.f.dispatcher.accept(replacement);assertFalse(replacement.isCancelled());layers.put(PotionEffectType.SPEED,stronger);settle(b);
var applied=owned("effect.applied");var ended=owned("effect.ended");assertEquals(2,applied.size());assertEquals(1,ended.size());assertEquals(applied.stream().filter(row->"speed_i".equals(row.getAsJsonObject("data").get("passive_id").getAsString())).findFirst().orElseThrow().getAsJsonObject("data").get("application_id"),ended.getFirst().getAsJsonObject("data").get("application_id"));
assertEquals(stronger,layers.get(PotionEffectType.SPEED));b.actor.core.location.setX(100);b.shared.pulse();settle(b);assertEquals(stronger,layers.get(PotionEffectType.SPEED));assertEquals(2,owned("effect.ended").size());assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());
}
}
@org.junit.jupiter.api.Test
void originalNativeExpirationCapturesItsOwnTimeAndTargetLocationBeforeDeferredConfirmationNotApplicationOrLaterMovement()throws Exception{
try(var fixture=new CharacterBossAuditNativeFixture(data)){
var b=fixture.base;fixture.authorAndAccept();b.enter(b.boss);var shell=fixture.shell();var observed=new ArrayList<EntityPotionEffectEvent>();nativePotionPort(b,b.actor.player,b.playerEffects,shell.entity,false,observed,Set.of(PotionEffectType.WEAKNESS,PotionEffectType.SLOWNESS));b.actor.damage=(amount,source)->{};fixture.damage(b.actor,shell,3,false);b.shared.pulse();settle(b);var application=owned("effect.applied").stream().filter(row->row.getAsJsonObject("data").has("ability_id")&&row.getAsJsonObject("data").get("ability_id").getAsString().equals("hex")).findFirst().orElseThrow();assertEquals(17.5,application.getAsJsonObject("data").getAsJsonObject("target_location").get("x").getAsDouble());b.actor.core.location.setX(17.8);var original=b.playerEffects.get(PotionEffectType.WEAKNESS);var before=java.time.Instant.now();var event=new EntityPotionEffectEvent(b.actor.player,original,null,shell.entity,EntityPotionEffectEvent.Cause.EXPIRATION,EntityPotionEffectEvent.Action.REMOVED,false);b.f.dispatcher.accept(event);assertFalse(event.isCancelled());b.playerEffects.remove(PotionEffectType.WEAKNESS);var after=java.time.Instant.now();b.actor.core.location.setX(100);settle(b);var ended=owned("effect.ended");assertEquals(1,ended.size());var values=ended.getFirst().getAsJsonObject("data");assertEquals(application.getAsJsonObject("data").get("application_id"),values.get("application_id"));assertEquals(17.8,values.getAsJsonObject("target_location").get("x").getAsDouble(),"Original native end location is neither application location nor deferred/current player location");var time=java.time.Instant.parse(ended.getFirst().get("time").getAsString());assertFalse(time.isBefore(before),"End timestamp belongs to original native expiration, not original application");assertFalse(time.isAfter(after),"Deferred native confirmation cannot replace original end time");assertEquals(17.5,values.getAsJsonObject("application_target_location").get("x").getAsDouble());assertEquals("system",values.get("actor_kind").getAsString());assertFalse(values.has("session_id"));assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());
}
}
@org.junit.jupiter.api.Test
void priorCancelledNativeRemovalCannotEndOrReplaceTheOriginalPassiveApplication()throws Exception{
try(var fixture=new CharacterBossAuditNativeFixture(data)){
var b=fixture.base;fixture.authorAndAccept();b.enter(b.boss);var shell=fixture.shell();var layers=b.nativeEffects.get(shell.entity.getUniqueId());var original=layers.get(PotionEffectType.SPEED);settle(b);var event=new EntityPotionEffectEvent(shell.entity,original,null,shell.entity,EntityPotionEffectEvent.Cause.PLUGIN,EntityPotionEffectEvent.Action.REMOVED,false);event.setCancelled(true);b.f.dispatcher.accept(event);settle(b);assertTrue(event.isCancelled());assertEquals(original,layers.get(PotionEffectType.SPEED));assertEquals(2,owned("effect.applied").size());assertTrue(owned("effect.ended").isEmpty());assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());
}
}
@ParameterizedTest @ValueSource(booleans={false,true})
void optionalPostRemovalValidityFailureKeepsOriginalPendingLayerFactsAndOnlyActualLaterConfirmationEndsThem(boolean staysUnavailable)throws Exception{
try(var fixture=new CharacterBossAuditNativeFixture(data)){
var b=fixture.base;fixture.authorAndAccept();b.enter(b.boss);var shell=fixture.shell();settle(b);var applications=owned("effect.applied");assertEquals(2,applications.size());var removed=new java.util.concurrent.atomic.AtomicBoolean();doAnswer(call->{removed.set(true);return null;}).when(shell.entity).remove();doAnswer(call->{if(removed.get())throw new IllegalStateException("test-only optional post-removal validity unavailable");return true;}).when(shell.entity).isValid();b.actor.core.location.setX(100);var before=java.time.Instant.now();assertDoesNotThrow(b.shared::pulse,"An added optional native-effect end confirmation must not interrupt original native room cleanup after remove returned");var after=java.time.Instant.now();assertTrue(removed.get());assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());assertFalse(b.directory.runView(b.definition.id(),b.quest.catalog()).orElseThrow().defeated());if(!staysUnavailable)doReturn(false).when(shell.entity).isValid();settle(b);
var pending=owned("action.resolved").stream().filter(row->row.getAsJsonObject("data").has("phase")&&row.getAsJsonObject("data").get("phase").getAsString().equals("native_effect_end_confirmation_pending")).toList();assertEquals(2,pending.size(),"Actual unavailable lifetime confirmation needs qualified original application evidence, never orphaned or successful end");for(var row:pending){var facts=row.getAsJsonObject("data");assertEquals("unconfirmed",facts.get("outcome").getAsString());assertEquals("remove_returned_validity_unavailable",facts.get("end_status").getAsString());assertTrue(applications.stream().anyMatch(application->application.getAsJsonObject("data").get("application_id").equals(facts.get("application_id"))));var time=java.time.Instant.parse(row.get("time").getAsString());assertFalse(time.isBefore(before));assertFalse(time.isAfter(after));assertEquals(17.25,facts.getAsJsonObject("target_location").get("x").getAsDouble());}
var ended=owned("effect.ended");assertEquals(staysUnavailable?0:2,ended.size(),"Only actual later native invalidity can confirm the original pending shell lifetime");for(var row:ended){var time=java.time.Instant.parse(row.get("time").getAsString());assertFalse(time.isBefore(before));assertFalse(time.isAfter(after));assertEquals("native_invalid_confirmed_after_original_remove",row.getAsJsonObject("data").get("reason").getAsString());}settle(b);assertEquals(staysUnavailable?0:2,owned("effect.ended").size());assertTrue(ReskillEventsNativeTest.records(data).stream().noneMatch(row->row.get("type").getAsString().endsWith("combat.reward_resolved")||row.get("type").getAsString().endsWith("dungeon.completed")));doReturn(false).when(shell.entity).isValid();
}
}
@ParameterizedTest @ValueSource(strings={"unrelated_nested","unchanged_existing"})
void actualNullSourcePotionCallsCannotAttributeAnUnrelatedDamageCallbackOrAnUnchangedPriorLayer(String mode)throws Exception{
try(var fixture=new CharacterBossAuditNativeFixture(data)){
var b=fixture.base;fixture.authorAndAccept();b.enter(b.boss);var shell=fixture.shell();var observed=new ArrayList<EntityPotionEffectEvent>();nativePotionPort(b,shell.entity,b.nativeEffects.get(shell.entity.getUniqueId()),shell.entity,false,observed,Set.of(PotionEffectType.RESISTANCE));nativePotionPort(b,b.actor.player,b.playerEffects,shell.entity,false,observed,Set.of(PotionEffectType.WEAKNESS,PotionEffectType.SLOWNESS,PotionEffectType.LUCK));if(mode.equals("unchanged_existing"))b.playerEffects.put(PotionEffectType.SLOWNESS,new PotionEffect(PotionEffectType.SLOWNESS,40,0,false,true));b.actor.damage=(amount,source)->{if(mode.equals("unrelated_nested"))b.actor.player.addPotionEffect(new PotionEffect(PotionEffectType.LUCK,100,1,false,false),true);};fixture.damage(b.actor,shell,3,false);b.shared.pulse();settle(b);assertTrue(observed.stream().allMatch(event->event.getSource()==null),"Pinned Bukkit addPotionEffect dispatches null native sources");assertTrue(b.playerEffects.containsKey(PotionEffectType.WEAKNESS));assertTrue(b.playerEffects.containsKey(PotionEffectType.SLOWNESS));assertTrue(b.nativeEffects.get(shell.entity.getUniqueId()).containsKey(PotionEffectType.RESISTANCE));assertEquals(mode.equals("unchanged_existing")?4:5,owned("effect.applied").size(),"Only actual changed intended native layers may be attributed to their exact cast");assertTrue(owned("effect.applied").stream().noneMatch(row->{var values=row.getAsJsonObject("data");return values.has("ability_id")&&values.get("ability_id").getAsString().equals("fireball");}),"An unrelated reentrant damage callback cannot become Fireball's potion application");if(mode.equals("unchanged_existing"))assertTrue(owned("effect.applied").stream().noneMatch(row->{var values=row.getAsJsonObject("data");return values.has("ability_id")&&values.get("ability_id").getAsString().equals("frost_nova");}));assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());
}
}
static void nativePotionPort(DungeonAuditNativeFixture b,LivingEntity target,Map<PotionEffectType,PotionEffect> layers,Entity source,boolean cancelled,List<EntityPotionEffectEvent> observed,Set<PotionEffectType> types){
doAnswer(call->{PotionEffect effect=call.getArgument(0);if(!types.contains(effect.getType())){layers.put(effect.getType(),effect);return true;}var old=layers.get(effect.getType());var event=new EntityPotionEffectEvent(target,old,effect,null,EntityPotionEffectEvent.Cause.PLUGIN,old==null?EntityPotionEffectEvent.Action.ADDED:EntityPotionEffectEvent.Action.CHANGED,true);event.setCancelled(cancelled);b.f.dispatcher.accept(event);observed.add(event);if(!event.isCancelled())layers.put(effect.getType(),effect);return !event.isCancelled();}).when(target).addPotionEffect(any(PotionEffect.class),anyBoolean());
}
private List<com.google.gson.JsonObject> owned(String type)throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)&&row.getAsJsonObject("data").has("entity_role")&&row.getAsJsonObject("data").get("entity_role").getAsString().equals("character_boss")).toList();}
private static void settle(DungeonAuditNativeFixture b)throws Exception{Runnable reply;while((reply=b.f.tasks.poll())!=null)reply.run();fence(FantasyStickLifecycleNativeTest.Fixture.field(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characterBossEvents"),"outbox"));fence(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"eventLog"));}
private static void fence(Object owner)throws Exception{((java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(owner,"worker")).submit(()->{}).get(5,java.util.concurrent.TimeUnit.SECONDS);}
}
@@ -0,0 +1,68 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.bukkit.damage.DamageType;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntityKnockbackEvent;
import org.bukkit.event.entity.EntityRegainHealthEvent;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.util.Vector;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
class CharacterBossMeleePassiveNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@ParameterizedTest @org.junit.jupiter.params.provider.CsvSource({"accepted,ENTITY_ATTACK","cancelled_knockback,ENTITY_ATTACK","unchanged_velocity,ENTITY_ATTACK","native_event_unavailable,ENTITY_ATTACK","accepted,MAGIC","cancelled_knockback,MAGIC","unchanged_velocity,MAGIC","native_event_unavailable,MAGIC","late_cancelled_damage,ENTITY_ATTACK","late_cancelled_damage,MAGIC"})
void actualEligibleNativeDamageKnockbackPreservesOriginalCausePolicyAndConfirmsOnlyFinalNativeDecisionAndVelocity(String mode,String cause) throws Exception {
try (var fixture=new CharacterBossAuditNativeFixture(data,List.of("stick_knockback","speed_i","low_light_vision"))) {
var b=fixture.base; fixture.authorAndAccept(); b.enter(b.boss); var shell=fixture.shell();
var velocity=new Vector[]{new Vector()}; var nativeCalls=new ArrayList<EntityKnockbackEvent>();
doAnswer(call->{if(mode.equals("native_event_unavailable"))return null;var event=new EntityKnockbackEvent(b.actor.player,EntityKnockbackEvent.KnockbackCause.ENTITY_ATTACK,1,velocity[0].clone(),new Vector(0,0,1)); b.f.dispatcher.accept(event);nativeCalls.add(event);if(!event.isCancelled()&&!mode.equals("unchanged_velocity"))velocity[0]=event.getFinalKnockback().clone();return null;}).when(b.actor.player).knockback(anyDouble(),anyDouble(),anyDouble());
doAnswer(call->velocity[0].clone()).when(b.actor.player).getVelocity();
b.f.dispatcher.register(new Listener(){@EventHandler(priority=EventPriority.HIGHEST)public void veto(EntityKnockbackEvent event){if(mode.equals("cancelled_knockback"))event.setCancelled(true);}@EventHandler(priority=EventPriority.MONITOR)public void finalDamageDecision(EntityDamageByEntityEvent event){if(mode.equals("late_cancelled_damage")&&event.getDamager()==shell.entity)event.setCancelled(true);}},b.f.plugin);
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(shell.entity);when(source.getDirectEntity()).thenReturn(shell.entity);
var refused=new EntityDamageByEntityEvent(shell.entity,b.actor.player,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3);refused.setCancelled(true);b.f.dispatcher.accept(refused);
b.actor.core.location.setX(100);var ineligible=new EntityDamageByEntityEvent(shell.entity,b.actor.player,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3);b.f.dispatcher.accept(ineligible);assertTrue(ineligible.isCancelled());b.actor.core.location.setX(17.5);
assertTrue(nativeCalls.isEmpty(),"Prior cancellation and room refusal never invoke native passive knockback");
var accepted=new EntityDamageByEntityEvent(shell.entity,b.actor.player,EntityDamageEvent.DamageCause.valueOf(cause),source,3);b.f.dispatcher.accept(accepted);assertEquals(mode.equals("late_cancelled_damage"),accepted.isCancelled());assertEquals(mode.equals("native_event_unavailable")?0:1,nativeCalls.size());verify(b.actor.player,times(1)).knockback(eq(1d),anyDouble(),anyDouble());
assertEquals((mode.equals("accepted")||mode.equals("late_cancelled_damage"))?1:0,velocity[0].getZ());assertEquals(fixture.template,b.directory.custody(fixture.template.id()).orElseThrow().character());settle(b);
var damage=owned("combat.damage");assertEquals(1,damage.size(),"Actual accepted native melee needs a distinct system-shell damage observation, not an ability/basic-stick copy");
var facts=damage.getFirst().getAsJsonObject("data");assertEquals(cause.equals("ENTITY_ATTACK")?"native_melee":"native_entity_damage",facts.get("damage_source").getAsString());assertEquals(cause,facts.get("damage_cause").getAsString());assertEquals(3,facts.get("final_damage").getAsDouble());assertEquals(mode.equals("late_cancelled_damage")?"cancelled":"accepted",facts.get("outcome").getAsString());assertEquals("system",facts.get("actor_kind").getAsString());assertEquals(b.actor.core.record.id().toString(),facts.get("target_character_id").getAsString());assertEquals(fixture.template.id().toString(),facts.get("subject_character_id").getAsString());assertFalse(damage.getFirst().has("playerid"));assertFalse(facts.has("session_id"));
var applied=owned("effect.applied").stream().filter(row->row.getAsJsonObject("data").has("passive_id")&&"stick_knockback".equals(row.getAsJsonObject("data").get("passive_id").getAsString())).toList();
assertEquals((mode.equals("accepted")||mode.equals("late_cancelled_damage"))?1:0,applied.size(),"A returned native knockback call is not confirmed application after cancellation/no velocity change");
if(!applied.isEmpty()){var effect=applied.getFirst().getAsJsonObject("data");assertEquals("accepted",effect.get("outcome").getAsString());assertEquals(facts.get("operation_id").getAsString(),effect.get("melee_damage_operation_id").getAsString());assertEquals(1,effect.getAsJsonObject("velocity_after").get("z").getAsDouble());assertEquals(shell.entity.getUniqueId().toString(),effect.get("native_entity_id").getAsString());assertEquals(b.actor.core.id.toString(),effect.get("target_player_id").getAsString());assertFalse(applied.getFirst().has("playerid"));}
}
}
@ParameterizedTest @ValueSource(booleans={false,true})
void registeredDowningPreservesOriginalHealthStealEligibilityRefusalWithoutInventingHeal(boolean cancelled) throws Exception {
try(var fixture=new CharacterBossAuditNativeFixture(data,List.of("health_steal","speed_i","low_light_vision"))){
var b=fixture.base;fixture.authorAndAccept();b.enter(b.boss);var shell=fixture.shell();shell.entity.setHealth(50);
var calls=new ArrayList<EntityRegainHealthEvent>();b.f.dispatcher.register(new Listener(){@EventHandler(priority=EventPriority.HIGHEST)public void adjust(EntityRegainHealthEvent event){if(event.getEntity()!=shell.entity)return;calls.add(event);event.setAmount(4);event.setCancelled(cancelled);}},b.f.plugin);
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(shell.entity);when(source.getDirectEntity()).thenReturn(shell.entity);
var hit=new EntityDamageByEntityEvent(shell.entity,b.actor.player,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,5);b.f.dispatcher.accept(hit);assertFalse(hit.isCancelled());doReturn(hit).when(b.actor.player).getLastDamageCause();
var deathSource=new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(DamageType.GENERIC)));
var death=new PlayerDeathEvent(b.actor.player,deathSource,new ArrayList<>(),0,net.kyori.adventure.text.Component.empty(),true);b.f.dispatcher.accept(death);
assertTrue(death.isCancelled(),"Existing registered fantasy downing contains the original native death; never suppress this guard for an audit test");
assertTrue(calls.isEmpty(),"Registered downing removes original eligibility before Health Steal; audit cannot bypass it or invent a callback");assertEquals(50,shell.health[0]);assertEquals(fixture.template,b.directory.custody(fixture.template.id()).orElseThrow().character());settle(b);
assertTrue(owned("combat.healed").isEmpty(),"No native regain or health change may become Health Steal success");assertTrue(owned("ability.cast").isEmpty());
}
}
private List<com.google.gson.JsonObject> owned(String type)throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)&&row.getAsJsonObject("data").has("entity_role")&&row.getAsJsonObject("data").get("entity_role").getAsString().equals("character_boss")).toList();}
private static void settle(DungeonAuditNativeFixture b)throws Exception{Runnable task;while((task=b.f.tasks.poll())!=null)task.run();fence(FantasyStickLifecycleNativeTest.Fixture.field(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characterBossEvents"),"outbox"));fence(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"eventLog"));}
private static void fence(Object owner)throws Exception{((java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(owner,"worker")).submit(()->{}).get(5,java.util.concurrent.TimeUnit.SECONDS);}
}
@@ -0,0 +1,85 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.events.CharacterBossEventBridge;
import games.dmg.fantasystick.events.LocalEventLog;
import games.dmg.fantasystick.progression.Stat;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.bukkit.entity.EntityType;
import org.bukkit.potion.PotionEffectType;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class CharacterBossMetadataSafetyNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void optionalActorCaptureFailureCannotEraseAuthenticatedAdministratorOrReclassifyRegisteredCustodyAsSystem() throws Exception {
try(var fixture=new CampAuditNativeFixture(data)) {
var f=fixture.f;var actor=fixture.actor;
var source=(games.dmg.fantasystick.dungeon.DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");
var owner=(CharacterBossEventBridge)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterBossEvents");
var capture=CharacterBossEventBridge.class.getDeclaredField("capture");capture.setAccessible(true);
capture.set(owner,(java.util.function.Function<org.bukkit.command.CommandSender,games.dmg.fantasystick.events.CampEventBridge.Actor>)sender->{throw new IllegalStateException("test-only optional capture unavailable");});
command(f,actor,"dungeon","create","Original Capture Failure");var dungeon=source.snapshot().dungeons().getFirst();
command(f,actor,"dungeon","edit","start",dungeon.id().toString());command(f,actor,"dungeon","room","create","corner","4","4","3","Original Room");command(f,actor,"dungeon","room","boss","mob","husk","Original Guard");
var held=actor.core.inventory.getItemInOffHand();actor.core.inventory.setItemInOffHand(null);actor.core.inventory.setItemInMainHand(held);
command(f,actor,"dungeon","room","boss","assign-sheet","1.125");assertTrue(actor.core.inventory.getItemInMainHand().isEmpty());assertEquals(actor.core.record.id(),source.custody(actor.core.record.id()).orElseThrow().character().id());
command(f,actor,"dungeon","edit","done");actor.core.location.setX(100);command(f,actor,"dungeon","boss","recover-sheet",dungeon.id().toString());assertTrue(source.custody(actor.core.record.id()).isEmpty());
fence(FantasyStickLifecycleNativeTest.Fixture.field(owner,"outbox"));fence(FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"eventLog"));
for(var type:List.of("dungeon.character_boss.assigned","dungeon.character_boss.recovered")) {
var rows=ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)).toList();assertEquals(1,rows.size());
var row=rows.getFirst();assertEquals("player",row.getAsJsonObject("data").get("actor_kind").getAsString(),"Optional metadata failure cannot turn the authenticated administrator into a system actor");assertTrue(row.has("playerid"));assertEquals(actor.core.id.toString(),row.get("playerid").getAsString());assertFalse(row.getAsJsonObject("data").has("session_id"));assertFalse(row.getAsJsonObject("data").has("actor_location"));
}
}
}
@Test void oneUnavailablePassiveLayerReadCannotHideAnotherConfirmedEquippedNativeLayer() throws Exception {
try(var fixture=new CharacterBossAuditNativeFixture(data)) {
var b=fixture.base;fixture.authorAndAccept();var reads=new java.util.concurrent.atomic.AtomicInteger();
b.shared.nativeSpawnOutcome=entity->{if(entity.getType()==EntityType.VINDICATOR)doAnswer(call->{if(reads.incrementAndGet()==1)throw new IllegalStateException("test-only first passive observation unavailable");return new ArrayList<>(b.nativeEffects.get(entity.getUniqueId()).values());}).when(entity).getActivePotionEffects();};
assertDoesNotThrow(()->b.enter(b.boss));var shell=fixture.shell();assertTrue(shell.entity.isValid());assertTrue(b.nativeEffects.get(shell.entity.getUniqueId()).containsKey(PotionEffectType.SPEED));assertTrue(b.nativeEffects.get(shell.entity.getUniqueId()).containsKey(PotionEffectType.NIGHT_VISION));
fence(FantasyStickLifecycleNativeTest.Fixture.field(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characterBossEvents"),"outbox"));fence(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"eventLog"));
var rows=ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("effect.applied")&&row.getAsJsonObject("data").has("passive_id")).toList();assertEquals(1,rows.size(),"A failed optional Speed confirmation cannot hide independently confirmed Night Vision");assertEquals("low_light_vision",rows.getFirst().getAsJsonObject("data").get("passive_id").getAsString());assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());
}
}
@Test void authenticVeryLargePersistedStatReachesBoundedOriginalCustodyAndNativeCastProofThenExactOfflineReplay() throws Exception {
var huge=BigInteger.ONE.shiftLeft(60_000);List<java.util.Properties> originals;byte[] definitions,character;Path subjectFile;String expected;
try(var fixture=new CharacterBossAuditNativeFixture(data,List.of("speed_i","low_light_vision","quicker_regeneration_i"),Map.of(Stat.INT,huge))) {
var b=fixture.base;((LocalEventLog)FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"eventLog")).close();fixture.authorAndAccept();subjectFile=data.resolve("characters").resolve(fixture.template.id()+".yml");character=Files.readAllBytes(subjectFile);b.enter(b.boss);var shell=fixture.shell();assertTrue(shell.entity.isValid());assertEquals(70,shell.health[0]);b.actor.damage=(amount,source)->{};fixture.damage(b.actor,shell,3,false);b.shared.pulse();assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());expected=huge.multiply(BigInteger.TWO).toString();
b.f.plugin.setEnabled(false);b.f.awaitWorkers();WitchAuditTestSupport.drain(b.f,"characterBossDrain");originals=WitchAuditTestSupport.intents(data.resolve("character-boss-event-intents"));
for(var type:List.of("dungeon.character_boss.assigned","dungeon.entity.spawned","ability.cast","ability.resolved"))assertTrue(originals.stream().anyMatch(p->p.getProperty("type").equals(type)),"Supported exact large stat must not drop every independent owner: "+type);
try(var paths=Files.list(data.resolve("character-boss-event-intents"))) {for(var path:paths.filter(p->p.toString().endsWith(".pending")).toList())assertTrue(Files.size(path)<=16_384,"Shared durable intent bound stays unchanged");}
definitions=Files.readAllBytes(data.resolve("dungeons.yml"));assertArrayEquals(character,Files.readAllBytes(subjectFile));
}
var failures=new ArrayList<Throwable>();
try(var writer=LocalEventLog.open(data,java.time.Clock.systemUTC(),new LocalEventLog.RuntimeInfo("offline metadata","pinned-native","26.2","test"),Math.max(128,originals.size()+32),1_048_576,message->{throw new AssertionError(message);})) {
writer.ready().toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);var audit=new CharacterBossEventBridge(input->writer.emit(input).thenApply(delivery->{assertNotEquals(LocalEventLog.Delivery.REJECTED,delivery,"Offline exact-replay control must accept original "+input.id());return delivery;}),data,sender->{throw new AssertionError("No current actor reconstruction");},failures::add);try(audit){audit.replay();audit.replay();}audit.drained().toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
((java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(writer,"worker")).submit(()->{}).get(120,java.util.concurrent.TimeUnit.SECONDS);
}
assertTrue(failures.isEmpty(),failures.toString());var rows=ReskillEventsNativeTest.records(data);
for(var intent:originals){var matching=rows.stream().filter(row->row.get("id").getAsString().equals(intent.getProperty("id"))).toList();assertEquals(1,matching.size());WitchAuditTestSupport.assertOriginal(intent,matching.getFirst());}
for(var type:List.of("dungeon.character_boss.assigned","dungeon.entity.spawned","ability.cast","ability.resolved")) {
var matching=rows.stream().filter(row->row.get("type").getAsString().endsWith(type)).toList();assertFalse(matching.isEmpty());
var logical=matching.getFirst().getAsJsonObject("data").get("operation_id");var group=matching.stream().filter(row->row.getAsJsonObject("data").get("operation_id").equals(logical)).toList();
var main=group.stream().filter(row->row.getAsJsonObject("data").get("event_part_index").getAsInt()==0).toList();assertEquals(1,main.size(),"Only part zero is the logical outcome");var head=main.getFirst();var headData=head.getAsJsonObject("data");assertEquals(group.size(),headData.get("event_part_count").getAsInt());assertEquals(6,headData.get("multiplied_stats_count").getAsInt());assertEquals(head.get("id").getAsString(),headData.get("logical_event_id").getAsString());assertEquals(java.util.stream.IntStream.range(0,group.size()).boxed().toList(),group.stream().map(row->row.getAsJsonObject("data").get("event_part_index").getAsInt()).sorted().toList());for(var row:group){assertEquals(head.get("time"),row.get("time"));assertEquals(headData.get("logical_event_id"),row.getAsJsonObject("data").get("logical_event_id"));assertEquals(headData.get("subject_character_id"),row.getAsJsonObject("data").get("subject_character_id"));if(row.getAsJsonObject("data").get("event_part_index").getAsInt()>0){for(var outcomeField:List.of("outcome","custody_status","sheet_status","cooldown_committed","application_id"))assertFalse(row.getAsJsonObject("data").has(outcomeField),"Decimal-only continuation is not another gameplay acknowledgement: "+outcomeField);}}
for(var stat:List.of("STR","DEX","CON","WIS","CHR")){var values=group.stream().map(row->row.getAsJsonObject("data")).filter(value->value.has("multiplied_stats_fragment")&&stat.equals(value.getAsJsonObject("multiplied_stats_fragment").get("stat").getAsString())).toList();assertEquals(1,values.size());assertEquals("60",values.getFirst().getAsJsonObject("multiplied_stats_fragment").get("digits").getAsString());}
var exact=new StringBuilder();
var fragments=group.stream().map(row->row.getAsJsonObject("data")).filter(value->value.has("multiplied_stats_fragment")&&"INT".equals(value.getAsJsonObject("multiplied_stats_fragment").get("stat").getAsString())).sorted(java.util.Comparator.comparingInt(value->value.getAsJsonObject("multiplied_stats_fragment").get("offset").getAsInt())).toList();
for(var value:fragments){var fragment=value.getAsJsonObject("multiplied_stats_fragment");assertEquals(exact.length(),fragment.get("offset").getAsInt());exact.append(fragment.get("digits").getAsString());assertEquals(expected.length(),fragment.get("total_digits").getAsInt());}
assertEquals(expected,exact.toString(),"Linked bounded parts must retain every original multiplied digit, not truncation/current-template reconstruction");
}
assertArrayEquals(definitions,Files.readAllBytes(data.resolve("dungeons.yml")));assertArrayEquals(character,Files.readAllBytes(subjectFile));
}
private static void command(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor,String... args) throws Exception {DungeonAuthoringEventsNativeTest.command(f,actor,args);}
private static void fence(Object owner) throws Exception {((java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(owner,"worker")).submit(()->{}).get(5,java.util.concurrent.TimeUnit.SECONDS);}
}
@@ -0,0 +1,60 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityRegainHealthEvent;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.junit.jupiter.api.Test;
class CharacterBossPassiveHealingNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
static final class RegainDecision implements Listener {
final boolean cancel;final List<EntityRegainHealthEvent> calls=new ArrayList<>();final List<Double> requests=new ArrayList<>();
RegainDecision(boolean cancel){this.cancel=cancel;}
@EventHandler(priority=EventPriority.HIGHEST)public void regain(EntityRegainHealthEvent event){
if(event.getEntity().getType()!=org.bukkit.entity.EntityType.VINDICATOR)return;
calls.add(event);requests.add(event.getAmount());event.setAmount(4);event.setCancelled(cancel);
}
}
private void settings()throws Exception{Files.writeString(data.resolve("config.yml"),"out-of-combat-healing-delay-seconds: 0\nout-of-combat-healing-interval-seconds: 120\nout-of-combat-healing-amount: 2\nquicker-regeneration-i-bonus-healing: 3\n");}
@ParameterizedTest @ValueSource(booleans={false,true})
void nativeOutOfCombatRegainWithEquippedBonusCapturesOriginalDecisionAndAppliedDelta(boolean cancelled)throws Exception{
settings();try(var fixture=new CharacterBossAuditNativeFixture(data)){
var b=fixture.base;fixture.authorAndAccept();var decision=new RegainDecision(cancelled);b.f.dispatcher.register(decision,b.f.plugin);b.shared.nativeSpawnOutcome=entity->{if(entity.getType()==org.bukkit.entity.EntityType.VINDICATOR)entity.setHealth(50);};b.enter(b.boss);var shell=fixture.shell();
assertEquals(1,decision.calls.size(),"Actual native OOC callback, not a cast or synthetic event");assertEquals(cancelled?50:54,shell.health[0]);
assertEquals(fixture.template,b.directory.custody(fixture.template.id()).orElseThrow().character());
settle(b);var healed=owned("combat.healed");assertEquals(1,healed.size(),"Native OOC regain has its independent character-boss system owner");var row=healed.getFirst();var facts=row.getAsJsonObject("data");
assertEquals("system",facts.get("actor_kind").getAsString());assertFalse(row.has("playerid"));assertFalse(facts.has("session_id"));assertEquals(fixture.template.id().toString(),facts.get("subject_character_id").getAsString());assertEquals(fixture.custody.operation().toString(),facts.get("custody_operation_id").getAsString());assertEquals(shell.entity.getUniqueId().toString(),facts.get("native_entity_id").getAsString());assertEquals(shell.entity.getUniqueId().toString(),facts.get("target_entity_id").getAsString());assertEquals("quicker_regeneration_i",facts.get("passive_id").getAsString());assertEquals("out_of_combat_recovery",facts.get("healing_source").getAsString());assertEquals(5,facts.get("requested_amount").getAsDouble());assertEquals(4,facts.get("native_event_amount").getAsDouble());assertEquals(cancelled?0:4,facts.get("applied_amount").getAsDouble());assertEquals(cancelled?"cancelled":"accepted",facts.get("outcome").getAsString());assertEquals(50,facts.get("health_before").getAsDouble());assertEquals(cancelled?50:54,facts.get("health_after").getAsDouble());assertTrue(facts.has("source_location"));assertTrue(facts.has("target_location"));assertTrue(owned("ability.cast").isEmpty());
b.shared.pulse();settle(b);assertEquals(1,decision.calls.size(),"Interval does not dispatch another regain callback");assertEquals(1,owned("combat.healed").size(),"An unchanged callback interval is not a second heal");
}
}
@ParameterizedTest @ValueSource(booleans={false,true})
void bothEquippedRegenerationBonusesKeepOriginalCombinedNativeRequestAndQualifiedComponentProvenance(boolean cancelled)throws Exception{
settings();Files.writeString(data.resolve("config.yml"),"quicker-regeneration-ii-bonus-healing: 4\n",java.nio.file.StandardOpenOption.APPEND);
try(var fixture=new CharacterBossAuditNativeFixture(data,List.of("quicker_regeneration_i","quicker_regeneration_ii","low_light_vision"))){var b=fixture.base;fixture.authorAndAccept();var decision=new RegainDecision(cancelled);b.f.dispatcher.register(decision,b.f.plugin);b.shared.nativeSpawnOutcome=entity->{if(entity.getType()==org.bukkit.entity.EntityType.VINDICATOR)entity.setHealth(50);};b.enter(b.boss);var shell=fixture.shell();assertEquals(List.of(9d),decision.requests);assertEquals(cancelled?50:54,shell.health[0]);assertEquals(fixture.template,b.directory.custody(fixture.template.id()).orElseThrow().character());settle(b);var healed=owned("combat.healed");assertEquals(1,healed.size());var facts=healed.getFirst().getAsJsonObject("data");assertEquals(9,facts.get("requested_amount").getAsDouble());assertEquals(cancelled?0:4,facts.get("applied_amount").getAsDouble());assertTrue(facts.has("recovery_base_amount"),"Actual I+II recovery needs captured original base/combined bonus facts, not I-only provenance");assertEquals("2",facts.get("recovery_base_amount").getAsString());assertEquals("7",facts.get("recovery_bonus_amount").getAsString());assertEquals(List.of("quicker_regeneration_i","quicker_regeneration_ii"),java.util.stream.StreamSupport.stream(facts.getAsJsonArray("recovery_bonus_passive_ids").spliterator(),false).map(com.google.gson.JsonElement::getAsString).toList());assertEquals("unavailable_from_combined_native_bonus_port",facts.get("bonus_component_amounts_status").getAsString());assertFalse(facts.has("quicker_regeneration_i_amount"));assertFalse(facts.has("quicker_regeneration_ii_amount"));b.shared.pulse();settle(b);assertEquals(1,decision.requests.size());assertEquals(1,owned("combat.healed").size());}
}
@Test void fullHealthAndUnharmedNativeAiDoesNotInventHealingCleansingOrBond()throws Exception{
settings();try(var fixture=new CharacterBossAuditNativeFixture(data)){
var b=fixture.base;fixture.authorAndAccept();b.enter(b.boss);var shell=fixture.shell();var decision=new RegainDecision(false);b.f.dispatcher.register(decision,b.f.plugin);b.actor.damage=(amount,source)->{};
fixture.damage(b.actor,shell,2,false);b.shared.pulse();settle(b);
assertEquals(70,shell.health[0]);assertTrue(decision.calls.isEmpty());assertTrue(owned("combat.healed").isEmpty());assertTrue(owned("combat.damage").isEmpty(),"No dispatched native damage can become a successful combat row");assertTrue(owned("effect.applied").stream().noneMatch(row->"purify".equals(row.getAsJsonObject("data").has("ability_id")?row.getAsJsonObject("data").get("ability_id").getAsString():null)));
var resolved=owned("ability.resolved");for(var id:List.of("second_wind","purify")){var matches=resolved.stream().filter(row->id.equals(row.getAsJsonObject("data").get("ability_id").getAsString())).toList();assertEquals(1,matches.size());assertEquals("no_op",matches.getFirst().getAsJsonObject("data").get("outcome").getAsString());assertFalse(matches.getFirst().getAsJsonObject("data").get("cooldown_committed").getAsBoolean());}
assertTrue(owned("ability.cast").stream().noneMatch(row->"guardian_bond".equals(row.getAsJsonObject("data").get("ability_id").getAsString())));
b.shared.pulse();settle(b);assertTrue(decision.calls.isEmpty());assertTrue(owned("combat.healed").isEmpty());for(var id:List.of("second_wind","purify")){var retries=owned("ability.resolved").stream().filter(row->id.equals(row.getAsJsonObject("data").get("ability_id").getAsString())).toList();assertEquals(2,retries.size(),"Refused full-health/no-harmful-effect attempt does not commit a cooldown");assertTrue(retries.stream().noneMatch(row->row.getAsJsonObject("data").get("cooldown_committed").getAsBoolean()));}assertEquals(fixture.template,b.directory.custody(fixture.template.id()).orElseThrow().character());int casts=owned("ability.cast").size();org.mockito.Mockito.doReturn(false).when(shell.entity).hasLineOfSight(b.actor.player);b.shared.pulse();settle(b);assertEquals(casts,owned("ability.cast").size(),"Unreachable target cannot manufacture another native AI cast");assertTrue(owned("combat.damage").isEmpty());assertTrue(owned("combat.healed").isEmpty());
}
}
private List<com.google.gson.JsonObject> owned(String type)throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)&&row.getAsJsonObject("data").has("entity_role")&&row.getAsJsonObject("data").get("entity_role").getAsString().equals("character_boss")).toList();}
private static void settle(DungeonAuditNativeFixture b)throws Exception{fence(FantasyStickLifecycleNativeTest.Fixture.field(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characterBossEvents"),"outbox"));fence(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"eventLog"));}
private static void fence(Object owner)throws Exception{((java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(owner,"worker")).submit(()->{}).get(5,java.util.concurrent.TimeUnit.SECONDS);}
}
@@ -0,0 +1,37 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class CharacterBossRuntimeEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void registeredDistinctTemplateAssignmentAndActualGiverAcceptanceMaterializeMultipliedShellAsSystemActorNotFantasyPlayer()throws Exception{
Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
try(var fixture=new CharacterBossAuditNativeFixture(data)){
var b=fixture.base;fixture.authorAndAccept();var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characters");var before=storage.find(fixture.template.id()).get().orElseThrow();b.enter(b.boss);var shell=fixture.shell();assertTrue(shell.entity.isValid());assertEquals(70,shell.maximum[0]);assertEquals(26,shell.attack[0]);assertEquals(70,shell.health[0]);assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());assertEquals(before,storage.find(fixture.template.id()).get().orElseThrow());assertTrue(b.directory.runView(b.definition.id(),b.quest.catalog()).orElseThrow().defeatedSlots().isEmpty());fence(FantasyStickLifecycleNativeTest.Fixture.field(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characterBossEvents"),"outbox"));fence(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"eventLog"));
var rows=ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("dungeon.entity.spawned")&&"character_boss".equals(row.getAsJsonObject("data").has("entity_role")?row.getAsJsonObject("data").get("entity_role").getAsString():null)).toList();assertEquals(1,rows.size(),"Actual loaded-valid multiplied character shell needs its independent system-owned variant, not generic run/native-boss/player evidence");var row=rows.getFirst();var values=row.getAsJsonObject("data");assertEquals("system",values.get("actor_kind").getAsString());assertFalse(row.has("playerid"));assertFalse(row.has("characterid"));assertFalse(values.has("session_id"));assertEquals(fixture.template.id().toString(),values.get("subject_character_id").getAsString());assertEquals(fixture.template.name(),values.get("subject_character_name").getAsString());assertEquals(fixture.custody.operation().toString(),values.get("custody_operation_id").getAsString());assertEquals(shell.entity.getUniqueId().toString(),values.get("native_entity_id").getAsString());assertEquals("VINDICATOR",values.get("entity_type").getAsString());assertEquals(b.definition.id().toString(),values.get("dungeon_id").getAsString());assertEquals(b.boss.id().toString(),values.get("room_id").getAsString());assertEquals(b.quest.catalog().toString(),values.get("generation_id").getAsString());assertEquals("2",values.get("multiplier").getAsString());assertEquals("60",values.getAsJsonObject("multiplied_stats").get("CON").getAsString());assertEquals(17.25,values.getAsJsonObject("spawn_location").get("x").getAsDouble());assertEquals("loaded_valid_registered_entity",values.get("materialization_status").getAsString());assertFalse(values.has("character_snapshot"));assertFalse(values.has("item"));assertTrue(ReskillEventsNativeTest.records(data).stream().noneMatch(record->record.get("type").getAsString().endsWith("combat.reward_resolved")||record.get("type").getAsString().endsWith("dungeon.completed")));b.f.plugin.setEnabled(false);b.f.awaitWorkers();WitchAuditTestSupport.drain(b.f,"characterBossDrain");
}
}
@Test void realEmptyRoomRemovalAndReentryUseOriginalShellFactsWithoutRewardOrCustodyMutation()throws Exception{
try(var fixture=new CharacterBossAuditNativeFixture(data)){
var b=fixture.base;fixture.authorAndAccept();b.enter(b.boss);var original=fixture.shell();b.actor.core.location.setX(100);b.shared.pulse();assertFalse(original.entity.isValid(),"The actual native empty-room cleanup removed the original shell");assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());assertFalse(b.directory.runView(b.definition.id(),b.quest.catalog()).orElseThrow().defeated());settle(b);
var removed=owned("dungeon.entity.removed");assertEquals(1,removed.size(),"Real character-shell cleanup needs its own nonreward system observation, not ordinary-boss cleanup or silence");var values=removed.getFirst().getAsJsonObject("data");assertEquals("system",values.get("actor_kind").getAsString());assertEquals(original.entity.getUniqueId().toString(),values.get("native_entity_id").getAsString());assertEquals(fixture.template.id().toString(),values.get("subject_character_id").getAsString());assertEquals(fixture.custody.operation().toString(),values.get("custody_operation_id").getAsString());assertEquals("no_eligible_player_in_room",values.get("reason").getAsString());assertEquals(17.25,values.getAsJsonObject("removal_location").get("x").getAsDouble());assertFalse(removed.getFirst().has("playerid"));assertFalse(values.has("session_id"));
b.enter(b.boss);var fresh=fixture.shell();assertNotEquals(original.entity.getUniqueId(),fresh.entity.getUniqueId());assertEquals(70,fresh.health[0]);assertNull(fresh.target[0]);settle(b);assertEquals(2,owned("dungeon.entity.spawned").size());assertEquals(1,owned("dungeon.entity.removed").size());assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());assertTrue(ReskillEventsNativeTest.records(data).stream().noneMatch(row->row.get("type").getAsString().endsWith("combat.reward_resolved")||row.get("type").getAsString().endsWith("dungeon.completed")));
}
}
@Test void genuineRepresentedNativeShellDeathObservesAcknowledgedDefeatWithoutCopyingSoleKillCompletionOrTrophyOwner()throws Exception{
try(var fixture=new CharacterBossAuditNativeFixture(data)){
var b=fixture.base;fixture.authorAndAccept();b.enter(b.boss);var shell=fixture.shell();var source=org.mockito.Mockito.mock(org.bukkit.damage.DamageSource.class);org.mockito.Mockito.when(source.getCausingEntity()).thenReturn(b.actor.player);org.mockito.Mockito.when(source.getDirectEntity()).thenReturn(b.actor.player);b.f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(b.actor.player,shell.entity,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3));b.f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(shell.entity,source,new java.util.ArrayList<>(),0));ReskillEventsNativeTest.await(b.f,()->b.directory.runView(b.definition.id(),b.quest.catalog()).map(games.dmg.fantasystick.dungeon.DungeonState.Run::defeated).orElse(false));ReskillEventsNativeTest.await(b.f,()->{try{var rows=ReskillEventsNativeTest.records(data);return rows.stream().anyMatch(row->row.get("type").getAsString().endsWith("dungeon.completed"))&&rows.stream().anyMatch(row->row.get("type").getAsString().endsWith("combat.reward_resolved"));}catch(Exception pending){return false;}});assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());assertFalse(b.directory.runView(b.definition.id(),b.quest.catalog()).orElseThrow().clearedRooms().contains(b.entry.id()),"Actual boss death completes without fabricated ordinary-room kills");settle(b);
var defeated=owned("dungeon.entity.defeated");assertEquals(1,defeated.size(),"Actual acknowledged native character-shell defeat needs its own original system observation, not copied reward/completion evidence");var values=defeated.getFirst().getAsJsonObject("data");assertEquals("system",values.get("actor_kind").getAsString());assertEquals(shell.entity.getUniqueId().toString(),values.get("native_entity_id").getAsString());assertEquals(fixture.template.id().toString(),values.get("subject_character_id").getAsString());assertEquals(b.quest.catalog().toString(),values.get("generation_id").getAsString());assertEquals("acknowledged_boss_completion",values.get("settlement_status").getAsString());assertEquals(b.actor.core.id.toString(),values.get("triggering_player_id").getAsString());assertEquals(17.25,values.getAsJsonObject("death_location").get("x").getAsDouble());assertFalse(defeated.getFirst().has("characterid"));assertFalse(values.has("session_id"));assertFalse(values.has("xp"));assertFalse(values.has("coins"));assertTrue(owned("dungeon.entity.removed").isEmpty(),"The genuinely defeated shell is not also an empty-room survivor");var rows=ReskillEventsNativeTest.records(data);assertEquals(1,rows.stream().filter(row->row.get("type").getAsString().endsWith("combat.reward_resolved")&&row.getAsJsonObject("data").get("target_id").getAsString().equals(shell.entity.getUniqueId().toString())).count());assertEquals(1,rows.stream().filter(row->row.get("type").getAsString().endsWith("dungeon.completed")).count());assertEquals(1,rows.stream().filter(row->row.get("type").getAsString().endsWith("adventure.trophy_earned")&&"dungeon_trophy".equals(row.getAsJsonObject("data").get("trophy_type").getAsString())).count());assertEquals(fixture.template,((games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characters")).find(fixture.template.id()).get().orElseThrow());
}
}
private java.util.List<com.google.gson.JsonObject> owned(String type)throws Exception{return ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith(type)&&row.getAsJsonObject("data").has("entity_role")&&row.getAsJsonObject("data").get("entity_role").getAsString().equals("character_boss")).toList();}
private static void settle(DungeonAuditNativeFixture b)throws Exception{Runnable reply;while((reply=b.f.tasks.poll())!=null)reply.run();fence(FantasyStickLifecycleNativeTest.Fixture.field(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characterBossEvents"),"outbox"));fence(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"eventLog"));}
private static void fence(Object owner)throws Exception{((java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(owner,"worker")).submit(()->{}).get(5,java.util.concurrent.TimeUnit.SECONDS);}
}
@@ -0,0 +1,123 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.events.CharacterBossEventBridge;
import games.dmg.fantasystick.events.LocalEventLog;
import games.dmg.fantasystick.persistence.CharacterStorage;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.UUID;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntityDeathEvent;
import org.bukkit.event.entity.EntityPotionEffectEvent;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
class CharacterBossRuntimeReplayNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@ParameterizedTest @ValueSource(booleans={false,true})
void rejectedWriterOriginalRuntimeAiNativeOutcomesAndLifecycleReplayTwiceOfflineWithoutRecreatingAuthority(boolean cancelledAndDefeated) throws Exception {
List<Properties> originals;
UUID subject;
games.dmg.fantasystick.persistence.CharacterRecord template;
games.dmg.fantasystick.dungeon.DungeonState originalState;
var bytes=new LinkedHashMap<Path,byte[]>();
FantasyStickLifecycleNativeTest.Actor originalActor;
org.bukkit.inventory.ItemStack[] originalInventory;
int originalMessages;
java.util.List<org.bukkit.entity.LivingEntity> originalSpawns;
int spawnCount;
var folder=data.resolve("character-boss-event-intents");
try(var fixture=new CharacterBossAuditNativeFixture(data)) {
var b=fixture.base;
fixture.authorAndAccept();
ReskillEventsNativeTest.await(b.f,()->{try{return ReskillEventsNativeTest.records(data).stream().anyMatch(row->row.get("type").getAsString().endsWith("dungeon.character_boss.assigned")&&row.has("playerid")&&row.get("playerid").getAsString().equals(b.actor.core.id.toString()));}catch(Exception failure){throw new AssertionError(failure);}});
fence(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"eventLog"));fence(FantasyStickLifecycleNativeTest.Fixture.field(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characterBossEvents"),"outbox"));
subject=fixture.template.id();template=fixture.template;
((LocalEventLog)FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"eventLog")).close();
b.enter(b.boss);var shell=fixture.shell();shell.entity.setHealth(50);
var nativeDamage=new ArrayList<EntityDamageByEntityEvent>();
b.actor.damage=(amount,causing)->{
var source=mock(org.bukkit.damage.DamageSource.class);
when(source.getCausingEntity()).thenReturn(causing);when(source.getDirectEntity()).thenReturn(causing);
var event=new EntityDamageByEntityEvent(causing,b.actor.player,EntityDamageEvent.DamageCause.MAGIC,source,amount);
event.setCancelled(cancelledAndDefeated);b.f.dispatcher.accept(event);nativeDamage.add(event);
};
var nativePotions=new ArrayList<EntityPotionEffectEvent>();
CharacterBossEffectEventsNativeTest.nativePotionPort(b,b.actor.player,b.playerEffects,shell.entity,cancelledAndDefeated,nativePotions,Set.of(PotionEffectType.WEAKNESS,PotionEffectType.SLOWNESS));
CharacterBossEffectEventsNativeTest.nativePotionPort(b,shell.entity,b.nativeEffects.get(shell.entity.getUniqueId()),shell.entity,cancelledAndDefeated,nativePotions,Set.of(PotionEffectType.RESISTANCE));
b.nativeEffects.get(shell.entity.getUniqueId()).put(PotionEffectType.WEAKNESS,new PotionEffect(PotionEffectType.WEAKNESS,200,0));
var healing=new CharacterBossAbilityEventsNativeTest.NativeHealingGuard(cancelledAndDefeated);
b.f.dispatcher.register(healing,b.f.plugin);
fixture.damage(b.actor,shell,3,false);b.shared.pulse();
assertEquals(2,nativeDamage.size());assertEquals(3,nativePotions.size());assertEquals(1,healing.observed.size());assertEquals(cancelledAndDefeated?50:55,shell.health[0]);
if(!cancelledAndDefeated) {
for(var type:List.of(PotionEffectType.WEAKNESS,PotionEffectType.SLOWNESS)) {
var old=b.playerEffects.get(type);
b.f.dispatcher.accept(new EntityPotionEffectEvent(b.actor.player,old,null,shell.entity,EntityPotionEffectEvent.Cause.EXPIRATION,EntityPotionEffectEvent.Action.REMOVED,false));
b.playerEffects.remove(type);
}
}
settle(b);
b.actor.core.location.setX(100);b.shared.pulse();assertFalse(shell.entity.isValid());settle(b);
b.enter(b.boss);var fresh=fixture.shell();assertNotEquals(shell.entity.getUniqueId(),fresh.entity.getUniqueId());assertEquals(70,fresh.health[0]);assertNull(fresh.target[0]);
if(cancelledAndDefeated) {
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(b.actor.player);when(source.getDirectEntity()).thenReturn(b.actor.player);
b.f.dispatcher.accept(new EntityDamageByEntityEvent(b.actor.player,fresh.entity,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3));
b.f.dispatcher.accept(new EntityDeathEvent(fresh.entity,source,new ArrayList<>(),0));
ReskillEventsNativeTest.await(b.f,()->b.directory.runView(b.definition.id(),b.quest.catalog()).map(games.dmg.fantasystick.dungeon.DungeonState.Run::defeated).orElse(false));
}
assertEquals(fixture.custody,b.directory.custody(subject).orElseThrow());
assertEquals(template,((CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characters")).find(subject).get().orElseThrow());
b.f.plugin.setEnabled(false);b.f.awaitWorkers();WitchAuditTestSupport.drain(b.f,"characterBossDrain");
originals=WitchAuditTestSupport.intents(folder);
var counts=originals.stream().collect(java.util.stream.Collectors.groupingBy(p->p.getProperty("type"),java.util.stream.Collectors.counting()));
for(var type:List.of("dungeon.entity.spawned","dungeon.entity.removed","dungeon.character_boss.target_changed","ability.cast","ability.resolved","combat.damage","combat.healed","effect.applied","effect.ended")) assertTrue(counts.containsKey(type),"Actual original runtime intent missing: "+type);
assertEquals(cancelledAndDefeated?1L:0L,counts.getOrDefault("dungeon.entity.defeated",0L));
assertEquals(2L,counts.get("combat.damage"));assertEquals(1L,counts.get("combat.healed"));
for(var intent:originals){assertEquals("ssystem",intent.getProperty("data.actor_kind"));assertNull(intent.getProperty("player"));assertNull(intent.getProperty("character"));assertNull(intent.getProperty("session"));assertNull(intent.getProperty("data.session_id"));}
originalState=new games.dmg.fantasystick.persistence.YamlDungeonRepository(data).load();
originalActor=b.actor;originalInventory=java.util.Arrays.stream(b.actor.core.inventory.getContents()).map(item->item==null?null:item.clone()).toArray(org.bukkit.inventory.ItemStack[]::new);originalMessages=b.actor.messages.size();originalSpawns=b.shared.spawned;spawnCount=originalSpawns.size();
try(var paths=Files.walk(data)) {
for(var path:paths.filter(p->Files.isRegularFile(p)&&p.toString().endsWith(".yml")&&!p.getFileName().toString().equals("config.yml")).toList())bytes.put(data.relativize(path),Files.readAllBytes(path));
}
}
// Reopen only the independent owner/writer: a full plugin restart separately resumes US-085's
// legitimate pending completion publication, and cannot isolate this owner's byte conservation.
var failures=new ArrayList<Throwable>();
try(var writer=LocalEventLog.open(data,java.time.Clock.systemUTC(),new LocalEventLog.RuntimeInfo("offline metadata","pinned-native","26.2","test"),128,1_048_576,message->{throw new AssertionError(message);})) {
writer.ready().toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
var audit=new CharacterBossEventBridge(writer::emit,data,sender->{throw new AssertionError("Offline replay must not capture current actor/native authority");},failures::add);
try(audit) {audit.replay();audit.replay();}
audit.drained().toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
}
assertTrue(failures.isEmpty(),failures.toString());
var rows=ReskillEventsNativeTest.records(data);
for(var intent:originals){var matching=rows.stream().filter(row->row.get("id").getAsString().equals(intent.getProperty("id"))).toList();assertEquals(1,matching.size());WitchAuditTestSupport.assertOriginal(intent,matching.getFirst());assertEquals("system",matching.getFirst().getAsJsonObject("data").get("actor_kind").getAsString());assertFalse(matching.getFirst().has("playerid"));assertFalse(matching.getFirst().has("characterid"));assertFalse(matching.getFirst().getAsJsonObject("data").has("session_id"));}
assertEquals(originalState,new games.dmg.fantasystick.persistence.YamlDungeonRepository(data).load());
assertEquals(template,originalState.dungeons().getFirst().rooms().stream().filter(room->room.boss()!=null&&room.boss().character()!=null).findFirst().orElseThrow().boss().character().character());
for(var entry:bytes.entrySet())assertArrayEquals(entry.getValue(),Files.readAllBytes(data.resolve(entry.getKey())),"Replay cannot rewrite original authority: "+entry.getKey());
assertArrayEquals(originalInventory,originalActor.core.inventory.getContents());assertEquals(originalMessages,originalActor.messages.size());assertEquals(spawnCount,originalSpawns.size(),"Independent offline audit replay cannot materialize any encounter");
assertTrue(rows.stream().noneMatch(row->row.get("type").getAsString().endsWith("combat.reward_resolved")||row.get("type").getAsString().endsWith("dungeon.completed")),"Independent character owner cannot export another owner's original receipts or re-earn rewards");
}
private static void settle(DungeonAuditNativeFixture b) throws Exception {
Runnable reply;while((reply=b.f.tasks.poll())!=null)reply.run();
fence(FantasyStickLifecycleNativeTest.Fixture.field(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characterBossEvents"),"outbox"));
}
private static void fence(Object owner) throws Exception {((java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(owner,"worker")).submit(()->{}).get(5,java.util.concurrent.TimeUnit.SECONDS);}
}
@@ -0,0 +1,20 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Path;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class CharacterBossTargetEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void realAcceptedThreatFixatesDespiteLaterHigherThreatThenRetargetsWithOriginalNativeSystemFactsAndNoPulseSpam()throws Exception{
try(var fixture=new CharacterBossAuditNativeFixture(data)){
var b=fixture.base;fixture.authorAndAccept();var challenger=fixture.acceptedChallenger(data);b.actor.damage=(amount,source)->{};b.enter(b.boss);var shell=fixture.shell();fixture.damage(b.actor,shell,100,true);b.shared.pulse();assertNull(shell.target[0],"Prior native cancellation cannot establish accepted threat or a fantasy target");fixture.damage(b.actor,shell,3,false);b.shared.pulse();assertSame(b.actor.player,shell.target[0]);fixture.damage(challenger,shell,10,false);b.shared.pulse();assertSame(b.actor.player,shell.target[0],"Later higher accepted threat cannot steal actual fixation");b.actor.core.location.setX(100);b.shared.pulse();assertSame(challenger.player,shell.target[0],"Actual ineligible fixation retargets highest remaining eligible threat");for(int i=0;i<3;i++)b.shared.pulse();assertEquals(fixture.custody,b.directory.custody(fixture.template.id()).orElseThrow());fence(FantasyStickLifecycleNativeTest.Fixture.field(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"characterBossEvents"),"outbox"));fence(FantasyStickLifecycleNativeTest.Fixture.field(b.f.plugin,"eventLog"));var rows=ReskillEventsNativeTest.records(data).stream().filter(row->row.get("type").getAsString().endsWith("dungeon.character_boss.target_changed")).toList();assertEquals(2,rows.size(),"Actual initial threat fixation and ineligible retarget need original system-owned transitions, not pulse/refusal spam or later actor reconstruction");
var initial=rows.stream().filter(row->row.getAsJsonObject("data").get("target_player_id").getAsString().equals(b.actor.core.id.toString())).findFirst().orElseThrow();var retarget=rows.stream().filter(row->row.getAsJsonObject("data").get("target_player_id").getAsString().equals(challenger.core.id.toString())).findFirst().orElseThrow();assertEquals("highest_accepted_threat",initial.getAsJsonObject("data").get("reason").getAsString());assertEquals("fixation_invalidated_retarget",retarget.getAsJsonObject("data").get("reason").getAsString());assertEquals(b.actor.core.record.id().toString(),initial.getAsJsonObject("data").get("target_character_id").getAsString());assertEquals(challenger.core.record.id().toString(),retarget.getAsJsonObject("data").get("target_character_id").getAsString());assertEquals(17.5,initial.getAsJsonObject("data").getAsJsonObject("target_location").get("x").getAsDouble());assertEquals(17.75,retarget.getAsJsonObject("data").getAsJsonObject("target_location").get("x").getAsDouble());assertEquals(3,initial.getAsJsonObject("data").get("accepted_threat").getAsDouble());assertEquals(10,retarget.getAsJsonObject("data").get("accepted_threat").getAsDouble());assertEquals(b.actor.core.id.toString(),retarget.getAsJsonObject("data").get("previous_target_player_id").getAsString());for(var row:rows){var values=row.getAsJsonObject("data");assertEquals("system",values.get("actor_kind").getAsString());assertEquals(fixture.template.id().toString(),values.get("subject_character_id").getAsString());assertEquals(shell.entity.getUniqueId().toString(),values.get("native_entity_id").getAsString());assertEquals(fixture.custody.operation().toString(),values.get("custody_operation_id").getAsString());assertEquals(b.quest.catalog().toString(),values.get("generation_id").getAsString());assertEquals(17.25,values.getAsJsonObject("source_location").get("x").getAsDouble());assertFalse(values.has("session_id"));assertFalse(row.has("playerid"));assertFalse(row.has("characterid"));}
}
}
private static void fence(Object owner)throws Exception{((java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(owner,"worker")).submit(()->{}).get(5,java.util.concurrent.TimeUnit.SECONDS);}
}
Loaded 100 of 164 files, more files were not shown because too many files have changed in this diff. Show more