Compare commits

...
13 Commits
Author SHA1 Message Date
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
dmg a79814118e feat(adventure): audit persistent camp trophies
Release / release (push) Failing after 19m8s
2026-09-26 20:18:09 -04:00
dmg 5923fcaf75 feat(adventure): audit champion trophies and passive spacing
Release / release (push) Failing after 19m36s
2026-09-26 19:47:38 -04:00
dmg ec46e667f1 feat(fishing): audit native quests in local JSONL
Release / release (push) Failing after 10m6s
2026-09-26 18:44:16 -04:00
dmg 04101d4565 feat(fishing): retain native loot on ordinary catches
Release / release (push) Failing after 9m56s
2026-09-26 17:13:16 -04:00
dmg 71839364a2 feat(recovery): export acknowledged pulses and config events
Release / release (push) Failing after 9m29s
2026-09-26 15:35:56 -04:00
dmg 9425103745 feat(experience): export acknowledged admin grants
Release / release (push) Failing after 17m43s
2026-09-26 15:07:03 -04:00
dmg 8f2501f1c0 feat(pack): export native delivery and client statuses
Release / release (push) Failing after 18m13s
2026-09-26 14:48:15 -04:00
dmg 2f7a72355d feat(party): export acknowledged capacity changes
Release / release (push) Successful in 17m51s
2026-09-26 14:18:28 -04:00
dmg 01fd2f10ff feat(roleplay): export truthful availability changes
Release / release (push) Failing after 10m2s
2026-09-26 13:59:29 -04:00
47 changed files with 6190 additions and 205 deletions

No files matched your search

@@ -164,6 +164,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.passive.PassiveRegistry passiveRegistry;
private games.dmg.fantasystick.passive.PassiveEngine passiveEngine;
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 games.dmg.fantasystick.events.PreferenceEventBridge preferenceEvents;
private games.dmg.fantasystick.events.EventCaptureListener eventCapture;
private games.dmg.fantasystick.events.DownedEventBridge downedEvents;
@@ -173,6 +175,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.events.AbilityEventBridge abilityEvents;
private games.dmg.fantasystick.events.PassiveEventBridge passiveEvents;
private games.dmg.fantasystick.events.RecoveryEventBridge recoveryEvents;
private java.util.concurrent.CompletionStage<Void> recoveryAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.ReviveEventBridge reviveEvents;
private games.dmg.fantasystick.events.AdminRevivalEventBridge adminRevivalEvents;
private java.util.concurrent.CompletionStage<Void> adminRevivalAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
@@ -186,6 +189,14 @@ public final class FantasyStickPlugin extends JavaPlugin {
private java.util.concurrent.CompletionStage<Void> switchAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.SwitchConfigEventBridge switchConfigEvents;
private java.util.concurrent.CompletionStage<Void> switchConfigAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.RoleplayAvailabilityEventBridge roleplayAvailabilityEvents;
private java.util.concurrent.CompletionStage<Void> roleplayAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.PartyCapacityEventBridge partyCapacityEvents;
private java.util.concurrent.CompletionStage<Void> partyCapacityAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.SheetResourcePackEventBridge sheetResourcePackEvents;
private java.util.concurrent.CompletionStage<Void> sheetResourcePackAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.AdminExperienceEventBridge adminExperienceEvents;
private java.util.concurrent.CompletionStage<Void> adminExperienceAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityAuditEvents;
private java.util.concurrent.CompletionStage<Void> identityAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.EquipmentEventBridge equipmentAuditEvents;
@@ -665,8 +676,9 @@ 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(adventureQuestController::managed);
combatExperience.attachExcluded(entity->adventureQuestController.encounterOrigin(entity).isPresent());
sessionEvents.attachManagedInteractionTarget(entity -> partyRegistrarController.tagged(entity)
|| armorerController.tagged(entity) || rareMerchantController.tagged(entity) || adventureQuestController.giver(entity));
admin = new EquipmentAdminCommand(receipts, sessions, getServer(), nextTick,
@@ -688,10 +700,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
admin.attachWarBanner(warBannerSettings, warBanner);
admin.attachMeteor(meteorSettings, meteor);
admin.attachAdminRevival(adminRevival, adminReviveSettings);
admin.attachExperienceGrant(new games.dmg.fantasystick.session.AdminExperienceGrant(
var grantService = new games.dmg.fantasystick.session.AdminExperienceGrant(
characters, rules, sessions, nextTick, getServer()::getPlayer,
problem -> getLogger().log(Level.WARNING,
"Admin experience grant for character " + problem.character(), problem.cause()), particleDelivery));
"Admin experience grant for character " + problem.character(), problem.cause()), particleDelivery);
admin.attachExperienceGrant(grantService);
admin.attachSwitchDelay(switchDelaySettings);
admin.attachRoleplaySettings(roleplaySettings);
admin.attachPartySettings(partySettings);
@@ -739,6 +752,62 @@ 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(
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(
eventLog::emit, Clock.systemUTC(), sessions, feedbackPreferences,
failure -> getLogger().log(Level.WARNING, "Preference event publication proof failed", failure));
@@ -783,7 +852,16 @@ public final class FantasyStickPlugin extends JavaPlugin {
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);
recoveryEvents = new games.dmg.fantasystick.events.RecoveryEventBridge(eventLog::emit, Clock.systemUTC());outOfCombatRecovery.attachEvents(recoveryEvents,record->record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationI.ID)?quickerRegeneration.bonus():java.math.BigInteger.ZERO);
recoveryEvents = new games.dmg.fantasystick.events.RecoveryEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
failure -> getLogger().log(Level.WARNING, "Recovery event intent failed", failure));
outOfCombatRecovery.attachEvents(recoveryEvents,
record -> record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationI.ID)
? quickerRegeneration.bonus() : java.math.BigInteger.ZERO,
record -> record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationI.ID)
&& record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationII.ID)
? quickerRegenerationII.bonus() : java.math.BigInteger.ZERO);
admin.attachRecoveryEvents(recoveryEvents);
reviveEvents = new games.dmg.fantasystick.events.ReviveEventBridge(eventLog::emit, Clock.systemUTC());revive.attachEvents(reviveEvents);
adminRevivalEvents = new games.dmg.fantasystick.events.AdminRevivalEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
@@ -809,6 +887,23 @@ public final class FantasyStickPlugin extends JavaPlugin {
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
failure -> getLogger().log(Level.WARNING, "Switch configuration event intent failed", failure));
admin.attachSwitchConfigEvents(switchConfigEvents);
roleplayAvailabilityEvents = new games.dmg.fantasystick.events.RoleplayAvailabilityEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
failure -> getLogger().log(Level.WARNING, "Roleplay availability event intent failed", failure));
admin.attachRoleplayAvailabilityEvents(roleplayAvailabilityEvents);
partyCapacityEvents = new games.dmg.fantasystick.events.PartyCapacityEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
failure -> getLogger().log(Level.WARNING, "Party capacity event intent failed", failure));
admin.attachPartyCapacityEvents(partyCapacityEvents);
sheetResourcePackEvents = new games.dmg.fantasystick.events.SheetResourcePackEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
failure -> getLogger().log(Level.WARNING, "Sheet resource-pack event intent failed", failure));
admin.attachSheetResourcePackEvents(sheetResourcePackEvents);
sheetResourcePack.attachEvents(sheetResourcePackEvents);
adminExperienceEvents = new games.dmg.fantasystick.events.AdminExperienceEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING, "Admin experience event intent failed", failure));
grantService.attachEvents(adminExperienceEvents);
var identitySessions = sessions;
identityAuditEvents = new games.dmg.fantasystick.events.CharacterIdentityEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
@@ -836,7 +931,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
id -> presentation.snapshot().get(id),sessionAuditEvents,eventLog::emit,Clock.systemUTC());
getServer().getPluginManager().registerEvents(messageAuditEvents,this);
} catch (java.io.IOException failure) {
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; reviveEvents = null; adminRevivalEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null; nutritionAuditEvents = null; switchAuditEvents = null; switchConfigEvents = null; identityAuditEvents = null; equipmentAuditEvents = null; sessionRecoveryAuditEvents = null; messageAuditEvents = null;
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; reviveEvents = null; adminRevivalEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null; nutritionAuditEvents = null; switchAuditEvents = null; switchConfigEvents = null; roleplayAvailabilityEvents = null; partyCapacityEvents = null; sheetResourcePackEvents = null; adminExperienceEvents = null; identityAuditEvents = null; equipmentAuditEvents = null; sessionRecoveryAuditEvents = null; fishingAuditEvents = null; messageAuditEvents = null;
getLogger().log(Level.WARNING,"Fantasy Stick local events are unavailable; gameplay remains enabled",failure);
}
getServer().getPluginManager().registerEvents(crafting, this);
@@ -922,6 +1017,31 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (failure != null) getLogger().log(Level.WARNING, "Previous nutrition event intent drain failed", failure);
if (replayNutritionEvents != null) replayNutritionEvents.replay();
});
var replayRoleplayEvents = roleplayAvailabilityEvents;
roleplayAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous roleplay event intent drain failed", failure);
if (replayRoleplayEvents != null) replayRoleplayEvents.replay();
});
var replayPartyCapacityEvents = partyCapacityEvents;
partyCapacityAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous party capacity event intent drain failed", failure);
if (replayPartyCapacityEvents != null) replayPartyCapacityEvents.replay();
});
var replaySheetPackEvents = sheetResourcePackEvents;
sheetResourcePackAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous sheet pack event intent drain failed", failure);
if (replaySheetPackEvents != null) replaySheetPackEvents.replay();
});
var replayGrantEvents = adminExperienceEvents;
adminExperienceAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous admin experience intent drain failed", failure);
if (replayGrantEvents != null) replayGrantEvents.replay();
});
var replayRecoveryEvents = recoveryEvents;
recoveryAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous recovery event intent drain failed", failure);
if (replayRecoveryEvents != null) replayRecoveryEvents.replay();
});
var replaySwitchConfigEvents = switchConfigEvents;
switchConfigAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous switch configuration intent drain failed", failure);
@@ -952,6 +1072,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (failure != null) getLogger().log(Level.WARNING, "Previous recovery event intent drain failed", failure);
if (replaySessionRecoveryEvents != null) replaySessionRecoveryEvents.replay();
});
var replayFishingEvents = fishingAuditEvents;
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.
}
getServer().getPluginManager().registerEvents(recipeDiscovery, this);
@@ -990,14 +1116,20 @@ public final class FantasyStickPlugin extends JavaPlugin {
combatFeedback = null;
var closingEvents = sessionEvents; sessionEvents = null;
var closingEventLog = eventLog; eventLog = null; preferenceEvents = null;
var closingFishingAuditEvents = fishingAuditEvents; fishingAuditEvents = null;
var closingNutritionAuditEvents = nutritionAuditEvents; nutritionAuditEvents = null;
var closingSwitchAuditEvents = switchAuditEvents; switchAuditEvents = null;
var closingSwitchConfigEvents = switchConfigEvents; switchConfigEvents = null;
var closingRoleplayEvents = roleplayAvailabilityEvents; roleplayAvailabilityEvents = null;
var closingPartyCapacityEvents = partyCapacityEvents; partyCapacityEvents = null;
var closingSheetPackEvents = sheetResourcePackEvents; sheetResourcePackEvents = null;
var closingGrantEvents = adminExperienceEvents; adminExperienceEvents = 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; recoveryEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = 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 closingBooks = books; books = null;
var closingPartyEvents = partyEvents; partyEvents = null;
var closingPartyCombat = partyCombat; partyCombat = null;
@@ -1314,6 +1446,56 @@ public final class FantasyStickPlugin extends JavaPlugin {
return null;
}).thenCompose(ignored -> closingNutritionAuditEvents.drained());
});
if (closingRoleplayEvents != null) cleanup("roleplay availability event intent drain", () -> {
var settingsDrained = closingRoleplaySettings == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
: closingRoleplaySettings.drained();
roleplayAuditDrain = settingsDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Roleplay setting drain failed", failure);
closingRoleplayEvents.close();
return null;
}).thenCompose(ignored -> closingRoleplayEvents.drained());
});
if (closingPartyCapacityEvents != null) cleanup("party capacity event intent drain", () -> {
var settingsDrained = closingPartySettings == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
: closingPartySettings.drained();
partyCapacityAuditDrain = settingsDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Party setting drain failed", failure);
closingPartyCapacityEvents.close();
return null;
}).thenCompose(ignored -> closingPartyCapacityEvents.drained());
});
if (closingSheetPackEvents != null) cleanup("sheet resource-pack event intent drain", () -> {
var settingsDrained = closingSheetResourcePackSettings == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
: closingSheetResourcePackSettings.drained();
sheetResourcePackAuditDrain = settingsDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Sheet pack setting drain failed", failure);
closingSheetPackEvents.close();
return null;
}).thenCompose(ignored -> closingSheetPackEvents.drained());
});
if (closingGrantEvents != null) cleanup("admin experience event intent drain", () -> {
var storageDrained = closingCharacters == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
: closingCharacters.drained();
adminExperienceAuditDrain = storageDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Character storage drain failed", failure);
closingGrantEvents.close();
return null;
}).thenCompose(ignored -> closingGrantEvents.drained());
});
if (closingRecoveryEvents != null) cleanup("recovery event intent drain", () -> {
var settingsDrained = closingOutOfCombatRecoverySettings == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
: closingOutOfCombatRecoverySettings.drained();
recoveryAuditDrain = settingsDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Recovery setting drain failed", failure);
closingRecoveryEvents.close();
return null;
}).thenCompose(ignored -> closingRecoveryEvents.drained());
});
if (closingSwitchConfigEvents != null) cleanup("switch configuration event intent drain", () -> {
var settingsDrained = closingSwitchDelaySettings == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
@@ -1353,6 +1535,21 @@ public final class FantasyStickPlugin extends JavaPlugin {
return null;
}).thenCompose(ignored -> closingSessionRecoveryAuditEvents.drained());
});
if (closingFishingAuditEvents != null) cleanup("fishing event intent drain", () -> {
var characterDrained = closingCharacters == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
var fishingDrained = closingFishing == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingFishing.drained();
var adventureDrained = closingAdventureQuests == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingAdventureQuests.drained();
fishingAuditDrain = java.util.concurrent.CompletableFuture.allOf(characterDrained.toCompletableFuture(),
fishingDrained.toCompletableFuture(), adventureDrained.toCompletableFuture(), configurationDrain.toCompletableFuture())
.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Fishing owner drain failed", failure);
closingFishingAuditEvents.close();
return null;
}).thenCompose(ignored -> closingFishingAuditEvents.drained());
});
if (closingEventLog != null) cleanup("local events", closingEventLog::close);
if (closingVitals != null) cleanup("native health hooks", closingVitals::close);
unregister(closingModes);
@@ -12,12 +12,14 @@ public final class AdventureQuestCommands {
private static final String PERMISSION="fantasystick.admin";
private final Plugin plugin;private final AdventureQuestDirectory directory;private final AdventureQuestController controller;private final Consumer<Runnable> nextTick;private final AdventureLocationPresentation locations;private final AdventureAdminTeleport teleports;
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;}directory.setSiteMode(id,current.participationMode(),requested).whenComplete((result,failure)->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 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);}}
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();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)->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();directory.updateFishingSite(id,current,options.getOrDefault("name",current.name()),definition,mode).whenComplete((result,failure)->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);}}
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);}}
private static String siteDetails(AdventureQuestState.Site site){return site.fishing()==null?"":"; "+fishingDetails(site);}
private static String fishingDetails(AdventureQuestState.Site site){var value=site.fishing();return "item=\""+value.itemName()+"\", required="+value.required()+", chance="+value.chancePercent().toPlainString()+"%, reward="+value.rewardXp()+" XP/"+value.rewardCoins()+" coins, radius="+value.radius()+", mode="+site.participationMode().value();}
private static Map<String,String> options(String[] args,int start,Set<String> allowed){var result=new LinkedHashMap<String,String>();for(int index=start;index<args.length;index++){String raw=args[index];if(!raw.startsWith("--")||!allowed.contains(raw.substring(2))||result.containsKey(raw.substring(2))||index+1>=args.length)return null;String key=raw.substring(2),value=args[++index];if(value.startsWith("\"")){var joined=new StringBuilder(value);while(!joined.toString().endsWith("\"")&&index+1<args.length)joined.append(' ').append(args[++index]);value=joined.toString();if(value.length()<2||!value.endsWith("\""))return null;value=value.substring(1,value.length()-1);}if(value.isBlank()||value.startsWith("--"))return null;result.put(key,value);}return result;}
@@ -38,37 +38,63 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
private FishingController fishing;
private final Set<Entity> relocating=Collections.newSetFromMap(new IdentityHashMap<>());
private final Map<UUID,Attempt> attempts=new HashMap<>();private final Map<UUID,Instant> readyAt=new HashMap<>();private final Set<UUID> starting=new HashSet<>();
/** A bounded same-enable receipt for genuine deaths after their attempt has been cleaned up. */
private final LinkedHashMap<UUID,games.dmg.fantasystick.progression.ManagedEncounterOrigin> dyingOrigins=new LinkedHashMap<>();
private final List<AdventureResetParticipant> resetParticipants=new ArrayList<>();private CompletableFuture<Boolean> resetWork;
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;MenuHolder(UUID catalog,int page){this.catalog=catalog;this.page=page;}@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;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;}}
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;
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());}
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,games.dmg.fantasystick.presentation.ParticleDelivery particles){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.settings=Objects.requireNonNull(settings);this.particles=Objects.requireNonNull(particles);navigation=new AdventureQuestNavigation(plugin.getServer());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.clock=Objects.requireNonNull(clock);this.random=Objects.requireNonNull(random);typeKey=new NamespacedKey(plugin,"managed_npc_type");idKey=new NamespacedKey(plugin,"quest_giver_id");siteKey=new NamespacedKey(plugin,"adventure_site_id");catalogKey=new NamespacedKey(plugin,"adventure_catalog_id");attemptKey=new NamespacedKey(plugin,"adventure_attempt_id");encounterKindKey=new NamespacedKey(plugin,"adventure_encounter_kind");menuSiteKey=new NamespacedKey(plugin,"quest_offer_site");menuCatalogKey=new NamespacedKey(plugin,"quest_offer_catalog");menuPageKey=new NamespacedKey(plugin,"quest_offer_page");menuFishingKey=new NamespacedKey(plugin,"quest_fishing_turn_in");}
public void install(){for(var world:plugin.getServer().getWorlds()){var chunks=world.getLoadedChunks();if(chunks!=null)for(var chunk:chunks)reconcile(chunk);}pulseTask=plugin.getServer().getScheduler().runTaskTimer(plugin,this::pulse,20,20);publishPendingReset();}
public boolean giver(Entity entity){var data=entity.getPersistentDataContainer();return data!=null&&GIVER.equals(data.get(typeKey,PersistentDataType.STRING));}
public boolean enemy(Entity entity){var data=entity.getPersistentDataContainer();return data!=null&&ENEMY.equals(data.get(typeKey,PersistentDataType.STRING));}
public boolean managed(Entity entity){var data=entity.getPersistentDataContainer();return enemy(entity)||data!=null&&PROJECTILE.equals(data.get(typeKey,PersistentDataType.STRING));}
public Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin> encounterOrigin(Entity entity){if(!enemy(entity))return Optional.empty();var data=entity.getPersistentDataContainer();UUID site=site(entity),catalog=parse(data.get(catalogKey,PersistentDataType.STRING)),source=parse(data.get(attemptKey,PersistentDataType.STRING));String encoded=data.get(encounterKindKey,PersistentDataType.STRING);if(site==null||catalog==null||source==null||encoded==null)return Optional.empty();games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind kind;try{kind=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.valueOf(encoded);}catch(IllegalArgumentException invalid){return Optional.empty();}if(kind!=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CHAMPION&&kind!=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CAMP)return Optional.empty();var active=attempts.get(site);if(active!=null&&(!active.catalog.equals(catalog)||!active.id.equals(source)))return Optional.empty();return Optional.of(new games.dmg.fantasystick.progression.ManagedEncounterOrigin(kind,source));}
public Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin> encounterOrigin(Entity entity){
if(!enemy(entity)||entity.getUniqueId()==null)return Optional.empty();
var data=entity.getPersistentDataContainer();UUID site=site(entity);
UUID catalog=parse(data.get(catalogKey,PersistentDataType.STRING));
UUID source=parse(data.get(attemptKey,PersistentDataType.STRING));
String encoded=data.get(encounterKindKey,PersistentDataType.STRING);
if(site==null||catalog==null||source==null||encoded==null)return Optional.empty();
games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind kind;
try{kind=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.valueOf(encoded);}
catch(IllegalArgumentException invalid){return Optional.empty();}
if(kind!=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CHAMPION
&&kind!=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CAMP)return Optional.empty();
var origin=new games.dmg.fantasystick.progression.ManagedEncounterOrigin(kind,source);
var remembered=dyingOrigins.get(entity.getUniqueId());
if(origin.equals(remembered))return Optional.of(origin);
var active=attempts.get(site);
return active!=null&&active.catalog.equals(catalog)&&active.id.equals(source)
&&active.entities.contains(entity.getUniqueId())&&active.type.name().equals(kind.name())
?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 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 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);}
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;}
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){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),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 ItemStack(Material.ENDER_CHEST);var turLine truncated
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 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).isPresent())open(event.getPlayer());}
@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);return;}if(data.has(menuFishingKey,PersistentDataType.INTEGER)&&fishing!=null){fishing.open(player);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();refreshTracking(player);player.sendMessage("Accepted "+location.displayName()+"; expires "+quest.expiresAt()+". Follow the Quest Compass.");}));}
@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.");}));}
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();}
@@ -86,13 +112,72 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
private void startChampion(AdventureQuestState.Site site,UUID catalog){if(closed||attempts.containsKey(site.id())||starting.contains(site.id()))return;var world=plugin.getServer().getWorld(site.world());if(world==null||!world.isChunkLoaded(section(site.x()),section(site.z())))return;starting.add(site.id());try{var rules=settings.rules();var attempt=new Attempt(site.id(),catalog,site.type(),arena(site),rules.resetSeconds());attempt.bossBar=plugin.getServer().createBossBar("Daybreak Champion",BarColor.RED,BarStyle.SOLID);attempts.put(site.id(),attempt);world.spawn(new Location(world,site.x(),site.y(),site.z()),Vindicator.class,champion->{configureEnemy(champion,attempt);var maximum=Objects.requireNonNull(champion.getAttribute(Attribute.MAX_HEALTH));maximum.setBaseValue(rules.championHealth());champion.setHealth(rules.championHealth());Objects.requireNonNull(champion.getAttribute(Attribute.ATTACK_DAMAGE)).setBaseValue(rules.championDamage());champion.customName(net.kyori.adventure.text.Component.text("Daybreak Champion"));champion.setCustomNameVisible(true);attempt.champion=champion;});syncBossBar(attempt);}catch(RuntimeException failure){var attempt=attempts.remove(site.id());if(attempt!=null)cleanup(attempt);plugin.getLogger().log(java.util.logging.Level.WARNING,"Champion attempt could not start at site "+site.id(),failure);}finally{starting.remove(site.id());}}
private void spawnEnemy(World world,AdventureQuestState.Site site,org.bukkit.entity.EntityType type,Attempt attempt,int index){var location=new Location(world,site.x()+(index%3)-1,site.y(),site.z()+(index/3));switch(type){case HUSK->world.spawn(location,Husk.class,mob->configureEnemy(mob,attempt));case PILLAGER->world.spawn(location,Pillager.class,mob->configureEnemy(mob,attempt));case VINDICATOR->world.spawn(location,Vindicator.class,mob->configureEnemy(mob,attempt));case WITCH->world.spawn(location,Witch.class,mob->configureEnemy(mob,attempt));default->throw new IllegalArgumentException("Unsupported camp entity");}}
private void configureEnemy(Mob mob,Attempt attempt){mob.getPersistentDataContainer().set(typeKey,PersistentDataType.STRING,ENEMY);mob.getPersistentDataContainer().set(siteKey,PersistentDataType.STRING,attempt.site.toString());mob.getPersistentDataContainer().set(catalogKey,PersistentDataType.STRING,attempt.catalog.toString());mob.getPersistentDataContainer().set(attemptKey,PersistentDataType.STRING,attempt.id.toString());var kind=attempt.type==AdventureQuestState.SiteType.CHAMPION?games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CHAMPION:games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CAMP;mob.getPersistentDataContainer().set(encounterKindKey,PersistentDataType.STRING,kind.name());mob.setCanPickupItems(false);mob.setRemoveWhenFarAway(false);mob.setPersistent(true);mob.setFireTicks(0);if(mob instanceof Husk husk){husk.setShouldBurnInDay(false);husk.setCanBreakDoors(false);}if(mob instanceof Raider raider){raider.setPatrolLeader(false);raider.setCanJoinRaid(false);}attempt.entities.add(mob.getUniqueId());}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void damage(EntityDamageEvent event){if(enemy(event.getEntity())){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.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){if(!enemy(event.getEntity()))return;event.getDrops().clear();event.setDroppedExp(0);UUID site=site(event.getEntity());var attempt=site==null?null:attempts.get(site);if(attempt==null)return;attempt.entities.remove(event.getEntity().getUniqueId());if(attempt.entities.isEmpty())clear(attempt);}
@EventHandler(priority=EventPriority.HIGHEST)public void death(EntityDeathEvent event){
if(!enemy(event.getEntity()))return;
UUID site=site(event.getEntity());var attempt=site==null?null:attempts.get(site);
if(attempt==null||!attempt.entities.remove(event.getEntity().getUniqueId()))return;
event.getDrops().clear();event.setDroppedExp(0);
if(attempt.type==AdventureQuestState.SiteType.CHAMPION||attempt.type==AdventureQuestState.SiteType.CAMP){
var kind=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.valueOf(attempt.type.name());
dyingOrigins.put(event.getEntity().getUniqueId(),
new games.dmg.fantasystick.progression.ManagedEncounterOrigin(kind,attempt.id));
if(dyingOrigins.size()>512)dyingOrigins.remove(dyingOrigins.keySet().iterator().next());
}
if(fishingEvents!=null&&(attempt.type==AdventureQuestState.SiteType.CHAMPION
||attempt.type==AdventureQuestState.SiteType.CAMP)){
var target=event.getEntity();var data=new LinkedHashMap<String,Object>();
var kind=attempt.type.name().toLowerCase(Locale.ROOT);
data.put("resolution","encounter_suppressed");data.put("managed_origin_kind",kind);
data.put("managed_origin_source",attempt.id.toString());data.put("target_id",target.getUniqueId().toString());
data.put("target_type",target.getType()==null?"unavailable":target.getType().name().toLowerCase(Locale.ROOT));
data.put("site_id",attempt.site.toString());data.put("catalog_id",attempt.catalog.toString());
data.put("defeat_location",trophyLocation(target.getLocation()));
data.put("spacing_status","managed_origin_exempt");
data.put("ordinary_history","not_consulted_or_updated");
data.put("experience_pool","0");data.put("coin_pool","0");
data.put("allocated_experience","0");data.put("allocated_coins","0");
data.put("applied_experience","0");data.put("applied_coins","0");
data.put("suppressed_experience","0");data.put("suppressed_coins","0");
data.put("suppression_reason","champion_and_camp_per_enemy_payout_disabled");
var operation=UUID.nameUUIDFromBytes(("managed-suppressed:"+attempt.id+":"+target.getUniqueId())
.getBytes(java.nio.charset.StandardCharsets.UTF_8));
fishingEvents.durable("combat.reward_resolved",operation,
new games.dmg.fantasystick.events.FishingEventBridge.Actor("system",null),data);
}
if(attempt.entities.isEmpty()){attempt.defeatedEntity=event.getEntity().getUniqueId();
attempt.defeatLocation=trophyLocation(event.getEntity().getLocation());attempt.defeatedAt=clock.instant();
clear(attempt);}}
private static Map<String,Object> trophyLocation(Location location){
if(location==null||location.getWorld()==null)return Map.of("status","unavailable");
try{var world=location.getWorld();if(world.getUID()==null)return Map.of("status","unavailable");
var value=new LinkedHashMap<String,Object>();value.put("world_id",world.getUID().toString());
value.put("world_name",world.getName()==null?"unavailable":world.getName());
value.put("x",location.getX());value.put("y",location.getY());value.put("z",location.getZ());
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 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 plan=new FishingState.Trophy(trophy,trophyType,carrier,quest.catalog(),quest.site(),quest.id(),attempt.id,trophyName,site.displayName(),rewards);fishing.earnTrophy(plan).whenComplete((earned,failure)->schedule(()->{if(failure!=null||!earned){plugin.getLogger().log(java.util.logging.Level.WARNING,"Champion trophy persistence failed for quest "+quest.id(),failure);return;}for(UUID character:recipients){var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);var player=active==null?null:plugin.getServer().getPlayer(active.player());if(player!=null)player.sendMessage("Adventure complete: its trophy is stored with the captured carrier.");}refreshTrackingPlayers();}));return;}var rewards=new LinkedHashMap<UUID,AdventureQuestDirectory.Reward>();for(UUID character:recipients)rewards.put(character,new AdventureQuestDirectory.Reward(xp.get(character),coins.get(character)));directory.bankAndComplete(quest.id(),quest.id(),rewards).whenComplete((banked,failure)->schedule(()->{if(failure!=null||!banked){plugin.getLogger().log(java.util.logging.Level.WARNING,"Adventure completion reward banking failed for quest "+quest.id(),failure);return;}for(UUID character:recipients){var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);var player=active==null?null:plugin.getServer().getPlayer(active.player());if(player!=null)player.sendMessage("Adventure complete: rewards are ready at any Quest Giver.");}refreshTrackingPlayers();}));}
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>();
audit.put(trophyType==FishingState.TrophyType.CHAMPION?"champion_id":"final_enemy_id",
attempt.defeatedEntity==null?"unavailable":attempt.defeatedEntity.toString());
audit.put("defeated_at",attempt.defeatedAt==null?"unavailable":attempt.defeatedAt.truncatedTo(java.time.temporal.ChronoUnit.SECONDS).toString());
if(trophyType==FishingState.TrophyType.CAMP){
audit.put("arena_shape",attempt.arena.shape().name().toLowerCase(Locale.ROOT));
audit.put("arena_x_blocks",Integer.toString(attempt.arena.xBlocks()));
audit.put("arena_z_blocks",Integer.toString(attempt.arena.zBlocks()));
}
for(var entry:attempt.defeatLocation.entrySet())
audit.put("defeat_"+entry.getKey(),String.valueOf(entry.getValue()));
audit.put("unit_kind",quest.unitType().name().toLowerCase(Locale.ROOT));
audit.put("unit_id",quest.unit().toString());
audit.put("accepted_roster",String.join(",",quest.snapshot().stream().sorted().map(UUID::toString).toList()));
var plan=new FishingState.Trophy(trophy,trophyType,carrier,quest.catalog(),quest.site(),quest.id(),
attempt.id,trophyName,site.displayName(),rewards,FishingState.TrophyPhase.PREPARED,audit);
fishing.earnTrophyWithEvidence(plan).whenComplete((earned,failure)->schedule(()->{if(failure!=null||earned==null||!earned.acknowledged()){plugin.getLogger().log(java.util.logging.Level.WARNING,"Champion trophy persistence failed for quest "+quest.id(),failure);return;}for(UUID character:recipients){var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);var player=active==null?null:plugin.getServer().getPlayer(active.player());if(player!=null)player.sendMessage("Adventure complete: its trophy is stored with the captured carrier.");}refreshTrackingPlayers();}));return;}var rewards=new LinkedHashMap<UUID,AdventureQuestDirectory.Reward>();for(UUID character:recipients)rewards.put(character,new AdventureQuestDirectory.Reward(xp.get(character),coins.get(character)));directory.bankAndComplete(quest.id(),quest.id(),rewards).whenComplete((banked,failure)->schedule(()->{if(failure!=null||!banked){plugin.getLogger().log(java.util.logging.Level.WARNING,"Adventure completion reward banking failed for quest "+quest.id(),failure);return;}for(UUID character:recipients){var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);var player=active==null?null:plugin.getServer().getPlayer(active.player());if(player!=null)player.sendMessage("Adventure complete: rewards are ready at any Quest Giver.");}refreshTrackingPlayers();}));}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void splash(org.bukkit.event.entity.PotionSplashEvent event){if(!managed(event.getPotion()))return;UUID site=site(event.getPotion());for(var affected:List.copyOf(event.getAffectedEntities()))if(!(affected instanceof Player player)||site==null||eligible(player,site)==null)event.setIntensity(affected,0);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void target(EntityTargetLivingEntityEvent event){if(enemy(event.getEntity())&&event.getTarget()!=null){UUID site=site(event.getEntity());if(!(event.getTarget() instanceof Player player)||site==null||eligible(player,site)==null)event.setCancelled(true);}}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void projectile(org.bukkit.event.entity.ProjectileLaunchEvent event){if(event.getEntity().getShooter() instanceof Entity shooter&&enemy(shooter)){event.getEntity().getPersistentDataContainer().set(typeKey,PersistentDataType.STRING,PROJECTILE);event.getEntity().getPersistentDataContainer().set(siteKey,PersistentDataType.STRING,Objects.requireNonNull(site(shooter)).toString());}}
@@ -123,5 +208,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();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);attempts.clear();dyingOrigins.clear();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(giver(entity)||enemy(entity))entity.remove();}
}
@@ -24,19 +24,58 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
private record HookClaim(UUID player,UUID session,UUID character,UUID quest,UUID hook,BigInteger sampledXp){}
private record Holder(UUID player,UUID session,UUID character,UUID nonce,int page) implements InventoryHolder{public Inventory getInventory(){return null;}}
private final Plugin plugin;private final AdventureQuestDirectory adventures;private final FishingDirectory fishing;private final AdventureQuestSettings settings;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 Consumer<Failure> failures;
private final NamespacedKey actionKey;private final Map<UUID,HookClaim> hooks=new HashMap<>();private final Map<UUID,UUID> menus=new HashMap<>();private org.bukkit.scheduler.BukkitTask pulseTask;private boolean closed;
private final NamespacedKey actionKey;private final Map<UUID,HookClaim> hooks=new HashMap<>();private final Map<UUID,UUID> menus=new HashMap<>();
private final Map<UUID,AdventureQuestState.Giver> menuGivers=new HashMap<>();
/** Only recently retained native catches may reappear in stale callbacks; never delete their already-owned items. */
private final Map<UUID,ArrayDeque<UUID>> retainedHooks=new HashMap<>();
private org.bukkit.scheduler.BukkitTask pulseTask;private boolean closed;
private games.dmg.fantasystick.events.FishingEventBridge fishingEvents;
public void attachFishingEvents(games.dmg.fantasystick.events.FishingEventBridge value){fishingEvents=Objects.requireNonNull(value);}
public FishingController(Plugin plugin,AdventureQuestDirectory adventures,FishingDirectory fishing,AdventureQuestSettings settings,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random,Consumer<Failure> failures){this.plugin=Objects.requireNonNull(plugin);this.adventures=Objects.requireNonNull(adventures);this.fishing=Objects.requireNonNull(fishing);this.settings=Objects.requireNonNull(settings);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.failures=Objects.requireNonNull(failures);actionKey=new NamespacedKey(plugin,"fishing_menu_action");}
public void install(){for(var pending:fishing.pendingCatches())recoverCatch(pending);for(var trophy:fishing.snapshot().trophies())reconcileTrophy(trophy);for(var submission:fishing.pendingSubmissions())recoverSubmission(submission);for(var claim:fishing.snapshot().claims())recoverClaim(claim);pulseTask=plugin.getServer().getScheduler().runTaskTimer(plugin,this::pulse,20,20);}
private void pulse(){if(closed)return;for(var player:plugin.getServer().getOnlinePlayers())if(hooks.containsKey(player.getUniqueId())&&!nativeUse(player,player.getInventory().getItemInMainHand()))cleanup(player);}
@Override public java.util.concurrent.CompletionStage<Void> reset(AdventureQuestState.Reset reset){for(var player:plugin.getServer().getOnlinePlayers())if(hooks.containsKey(player.getUniqueId()))cleanup(player);hooks.clear();menus.clear();return java.util.concurrent.CompletableFuture.completedFuture(null);}
@Override public java.util.concurrent.CompletionStage<Void> reset(AdventureQuestState.Reset reset){for(var player:plugin.getServer().getOnlinePlayers())if(hooks.containsKey(player.getUniqueId()))cleanup(player);hooks.clear();menus.clear();menuGivers.clear();return java.util.concurrent.CompletableFuture.completedFuture(null);}
public boolean nativeUse(Player player,ItemStack item){if(closed||adventures.snapshot().reset()!=null||AdventureQuestNavigation.fishingQuest(item)==null)return false;var selected=sessions.selectedControl(player,item).orElse(null);if(selected==null||selected.slot()!=0)return false;var context=context(player);var quest=context==null?null:adventures.findQuest(AdventureQuestNavigation.fishingQuest(item)).orElse(null);return quest!=null&&eligible(context,quest)&&quest.fishing()!=null&&quest.site().equals(site(item));}
public BigInteger quantity(UUID character,UUID item){return fishing.quantity(character,item);}
public java.util.concurrent.CompletionStage<Boolean> earnTrophy(FishingState.Trophy trophy){return fishing.prepareTrophy(trophy).thenCompose(status->{if(status==FishingDirectory.TrophyPrepareStatus.REDEEMED)return java.util.concurrent.CompletableFuture.completedFuture(true);var retained=fishing.trophy(trophy.id()).orElseThrow();return adventures.complete(retained.quest(),retained.rewards().keySet()).thenCompose(ignored->fishing.commitTrophy(retained.id())).thenApply(committed->committed!=FishingDirectory.TrophyCommitStatus.NOT_FOUND);});}
private void reconcileTrophy(FishingState.Trophy trophy){if(trophy.phase()==FishingState.TrophyPhase.EARNED)return;adventures.complete(trophy.quest(),trophy.rewards().keySet()).thenCompose(ignored->fishing.commitTrophy(trophy.id())).whenComplete((ignored,failure)->{if(failure!=null)report(trophy.id(),trophy.carrier(),"trophy-reconcile",failure);});}
public void open(Player player){open(player,0);}
private void open(Player player,int requestedPage){var context=context(player);if(context==null){player.sendMessage("Quest item turn-in requires an active living character.");return;}for(var pending:fishing.pendingCatches())if(pending.character().equals(context.record().id()))recoverCatch(pending);var entries=new ArrayList<ItemStack>();var quest=fishingQuest(context);if(quest!=null){var terms=quest.fishing();entries.add(item(Material.EMERALD,"Submit "+terms.itemName(),"submit",List.of("Held: "+fishing.quantity(context.record().id(),terms.itemId())+" / "+terms.required(),"Reward: "+terms.rewardXp()+" XP, "+terms.rewardCoins()+" coins",quest.unitType()==AdventureQuestState.UnitType.PARTY?"One accepted member supplies all items":"Individual submission","Beneficiaries: "+beneficiaries(context,quest).size()+" accepted character(s)")));}for(var trophy:fishing.trophies(context.record().id())){var total=trophy.total();entries.add(item(Material.PLAYER_HEAD,"Redeem "+trophy.name(),"trophy:"+trophy.id(),List.of("Origin: "+trophy.origin(),"Reward pool: "+total.xp()+" XP, "+total.coins()+" coins","Beneficiaries: "+trophy.rewards().size()+" frozen character(s)")));}var legacy=adventures.unclaimedAll(context.record().id());if(legacy.xp().signum()>0||legacy.coins().signum()>0)entries.add(item(Material.NETHER_STAR,"Claim retained adventure rewards","legacy-claim",List.of(legacy.xp()+" XP, "+legacy.coins()+" coins","Previously earned shares do not expire")));var reward=fishing.unclaimed(context.record().id());if(reward.xp().signum()>0||reward.coins().signum()>0)entries.add(item(Material.GOLD_INGOT,"Claim earned quest rewards","claim",List.of(reward.xp()+" XP, "+reward.coins()+" coins","Earned shares do not expire")));int pages=Math.max(1,(entries.size()+44)/45),page=Math.max(0,Math.min(requestedPage,pages-1));UUID nonce=UUID.randomUUID();menus.put(player.getUniqueId(),nonce);var inventory=plugin.getServer().createInventory(new Holder(player.getUniqueId(),context.active().session(),context.record().id(),nonce,page),54,net.kyori.adventure.text.Component.text("Turn in quest items "+(page+1)+"/"+pages));int start=page*45;for(int index=start;index<Math.min(entries.size(),start+45);index++)inventory.setItem(index-start,entries.get(index));if(entries.isEmpty())inventory.setItem(22,item(Material.BARRIER,"No eligible fishing items or rewards",null,List.of()));if(page>0)inventory.setItem(45,item(Material.ARROW,"Previous","page:"+(page-1),List.of()));if(page+1<pages)inventory.setItem(53,item(Material.ARROW,"Next","page:"+(page+1),List.of()));player.openInventory(inventory);}
@EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof Holder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null||closed)return;String action=event.getCurrentItem().getItemMeta().getPersistentDataContainer().get(actionKey,PersistentDataType.STRING);if(action==null)return;var context=context(player);if(context==null||!holder.player().equals(player.getUniqueId())||!holder.session().equals(context.active().session())||!holder.character().equals(context.record().id())||!menus.remove(player.getUniqueId(),holder.nonce())){player.sendMessage("Quest item action refused: the menu, character or session changed.");return;}if(action.startsWith("page:")){try{open(player,Integer.parseInt(action.substring(5)));}catch(NumberFormatException invalid){player.sendMessage("Quest item page refused.");}return;}if(action.startsWith("trophy:")){redeemTrophy(player,context,action.substring(7));return;}if(action.equals("legacy-claim")){claimLegacy(player,context);return;}if(action.equals("claim")){claim(player,context);return;}if(action.equals("submit"))submit(player,context);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)
public record TrophyEarned(boolean acknowledged,boolean newlyEarned,FishingState.Trophy receipt) {}
public java.util.concurrent.CompletionStage<Boolean> earnTrophy(FishingState.Trophy trophy){
return earnTrophyWithEvidence(trophy).thenApply(TrophyEarned::acknowledged);
}
public java.util.concurrent.CompletionStage<TrophyEarned> earnTrophyWithEvidence(FishingState.Trophy trophy){
return fishing.prepareTrophy(trophy).thenCompose(status->{
if(status==FishingDirectory.TrophyPrepareStatus.REDEEMED)
return java.util.concurrent.CompletableFuture.completedFuture(new TrophyEarned(true,false,null));
var retained=fishing.trophy(trophy.id()).orElseThrow();
return adventures.complete(retained.quest(),retained.rewards().keySet())
.thenCompose(ignored->fishing.commitTrophy(retained.id()))
.thenApply(committed->{
var earned=committed==FishingDirectory.TrophyCommitStatus.NOT_FOUND?retained:retained.earned();
if(committed==FishingDirectory.TrophyCommitStatus.APPLIED&&fishingEvents!=null)
fishingEvents.trophyEarned(earned);
return new TrophyEarned(committed!=FishingDirectory.TrophyCommitStatus.NOT_FOUND,
committed==FishingDirectory.TrophyCommitStatus.APPLIED,earned);
});
});
}
private void reconcileTrophy(FishingState.Trophy trophy){
if(trophy.phase()==FishingState.TrophyPhase.EARNED)return;
adventures.complete(trophy.quest(),trophy.rewards().keySet())
.thenCompose(ignored->fishing.commitTrophy(trophy.id()))
.whenComplete((committed,failure)->{
if(failure!=null)report(trophy.id(),trophy.carrier(),"trophy-reconcile",failure);
else if(committed==FishingDirectory.TrophyCommitStatus.APPLIED&&fishingEvents!=null)
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){
if(giver==null)menuGivers.remove(player.getUniqueId());else menuGivers.put(player.getUniqueId(),giver);var context=context(player);if(context==null){player.sendMessage("Quest item turn-in requires an active living character.");return;}for(var pending:fishing.pendingCatches())if(pending.character().equals(context.record().id()))recoverCatch(pending);var entries=new ArrayList<ItemStack>();var quest=fishingQuest(context);if(quest!=null){var terms=quest.fishing();entries.add(item(Material.EMERALD,"Submit "+terms.itemName(),"submit",List.of("Held: "+fishing.quantity(context.record().id(),terms.itemId())+" / "+terms.required(),"Reward: "+terms.rewardXp()+" XP, "+terms.rewardCoins()+" coins",quest.unitType()==AdventureQuestState.UnitType.PARTY?"One accepted member supplies all items":"Individual submission","Beneficiaries: "+beneficiaries(context,quest).size()+" accepted character(s)")));}for(var trophy:fishing.trophies(context.record().id())){var total=trophy.total();entries.add(item(Material.PLAYER_HEAD,"Redeem "+trophy.name(),"trophy:"+trophy.id(),List.of("Origin: "+trophy.origin(),"Reward pool: "+total.xp()+" XP, "+total.coins()+" coins","Beneficiaries: "+trophy.rewards().size()+" frozen character(s)")));}var legacy=adventures.unclaimedAll(context.record().id());if(legacy.xp().signum()>0||legacy.coins().signum()>0)entries.add(item(Material.NETHER_STAR,"Claim retained adventure rewards","legacy-claim",List.of(legacy.xp()+" XP, "+legacy.coins()+" coins","Previously earned shares do not expire")));var reward=fishing.unclaimed(context.record().id());if(reward.xp().signum()>0||reward.coins().signum()>0)entries.add(item(Material.GOLD_INGOT,"Claim earned quest rewards","claim",List.of(reward.xp()+" XP, "+reward.coins()+" coins","Earned shares do not expire")));int pages=Math.max(1,(entries.size()+44)/45),page=Math.max(0,Math.min(requestedPage,pages-1));UUID nonce=UUID.randomUUID();menus.put(player.getUniqueId(),nonce);var inventory=plugin.getServer().createInventory(new Holder(player.getUniqueId(),context.active().session(),context.record().id(),nonce,page),54,net.kyori.adventure.text.Component.text("Turn in quest items "+(page+1)+"/"+pages));int start=page*45;for(int index=start;index<Math.min(entries.size(),start+45);index++)inventory.setItem(index-start,entries.get(index));if(entries.isEmpty())inventory.setItem(22,item(Material.BARRIER,"No eligible fishing items or rewards",null,List.of()));if(page>0)inventory.setItem(45,item(Material.ARROW,"Previous","page:"+(page-1),List.of()));if(page+1<pages)inventory.setItem(53,item(Material.ARROW,"Next","page:"+(page+1),List.of()));player.openInventory(inventory);}
@EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof Holder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null||closed)return;String action=event.getCurrentItem().getItemMeta().getPersistentDataContainer().get(actionKey,PersistentDataType.STRING);if(action==null)return;var context=context(player);if(context==null||!holder.player().equals(player.getUniqueId())||!holder.session().equals(context.active().session())||!holder.character().equals(context.record().id())||!menus.remove(player.getUniqueId(),holder.nonce())){player.sendMessage("Quest item action refused: the menu, character or session changed.");return;}if(action.startsWith("page:")){try{open(player,Integer.parseInt(action.substring(5)),menuGivers.get(player.getUniqueId()));}catch(NumberFormatException invalid){player.sendMessage("Quest item page refused.");}return;}if(action.startsWith("trophy:")){redeemTrophy(player,context,action.substring(7));return;}if(action.equals("legacy-claim")){claimLegacy(player,context);return;}if(action.equals("claim")){claim(player,context);return;}if(action.equals("submit"))submit(player,context);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=false)
public void fish(PlayerFishEvent event){
if(closed)return;
var player=event.getPlayer();
@@ -45,49 +84,283 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
var context=context(player);
var quest=context==null?null:adventures.findQuest(AdventureQuestNavigation.fishingQuest(item)).orElse(null);
if(context==null||quest==null||!eligible(context,quest))return;
var auditBridge=fishingEvents;var auditActor=auditBridge==null?null:auditBridge.actor(player);
UUID action=UUID.randomUUID();boolean priorCancelled=event.isCancelled();
var observation=new LinkedHashMap<String,Object>();
observation.put("action_kind","fishing");observation.put("state",event.getState().name());
observation.put("hook_id",event.getHook().getUniqueId().toString());
observation.put("quest_id",quest.id().toString());observation.put("site_id",quest.site().toString());
observation.put("candidate_catch_id",UUID.nameUUIDFromBytes((quest.id()+":"+event.getHook().getUniqueId())
.getBytes(StandardCharsets.UTF_8)).toString());
observation.put("fisher_location",locationMap(capture(player.getLocation())));
observation.put("bobber_location",locationMap(capture(event.getHook().getLocation())));
observation.put("prior_cancelled",priorCancelled);
if(auditBridge!=null)auditBridge.transientEvent("action.observed",action,auditActor,observation);
var outcome=new String[]{"native_continuation"};
try {
if(priorCancelled){outcome[0]="prior_cancelled";return;}
switch(event.getState()){
case FISHING->{var hook=event.getHook();hooks.put(player.getUniqueId(),new HookClaim(player.getUniqueId(),context.active().session(),context.record().id(),quest.id(),hook.getUniqueId(),sampleXp()));}
case LURED,BITE->{} // Approach and bite are not reels; retain the original owned hook.
case CAUGHT_ENTITY->{event.setCancelled(true);hooks.remove(player.getUniqueId());event.getHook().remove();player.sendMessage("Quest Fishing Rods cannot pull entities.");}
case CAUGHT_ENTITY->{outcome[0]="entity_pull_refused";event.setCancelled(true);hooks.remove(player.getUniqueId());event.getHook().remove();player.sendMessage("Quest Fishing Rods cannot pull entities.");}
case CAUGHT_FISH->{
var claim=hooks.get(player.getUniqueId());
if(claim==null||!claim.session().equals(context.active().session())||!claim.character().equals(context.record().id())||!claim.quest().equals(quest.id())||!claim.hook().equals(event.getHook().getUniqueId())){
refuseCatch(event,"Quest catch refused: the cast is no longer valid; cast again.");return;
outcome[0]="stale_or_foreign_hook";refuseCatch(event,"Quest catch refused: the cast is no longer valid; cast again.");return;
}
hooks.remove(player.getUniqueId(),claim); // A late callback for another hook must not consume the current cast.
if(!(event.getCaught() instanceof Item caught)){
refuseCatch(event,"Quest catch refused: no native fish item was caught.");return;
outcome[0]="no_native_item";refuseCatch(event,"Quest catch refused: no native fish item was caught.");return;
}
if(!inside(player.getLocation(),quest)||!inside(event.getHook().getLocation(),quest)){
refuseCatch(event,"Quest catch refused: keep yourself and the bobber inside the fishing area.");return;
var caughtSite=adventures.findSite(quest.site()).orElse(null);
if(caughtSite==null||!inside(player.getLocation(),quest)||!inside(event.getHook().getLocation(),quest)){
outcome[0]="outside_captured_area";refuseCatch(event,"Quest catch refused: keep yourself and the bobber inside the fishing area.");return;
}
event.setCancelled(true);event.setExpToDrop(0);caught.remove();event.getHook().remove();
BigInteger xp=claim.sampledXp();boolean awarded=random.nextDouble(100d)<quest.fishing().chancePercent().doubleValue();
ItemStack nativeItem;
try{nativeItem=caught.getItemStack();}catch(RuntimeException unavailable){nativeItem=null;}
event.setExpToDrop(0);event.getHook().remove();
BigInteger xp=claim.sampledXp();double sample=random.nextDouble(100d);boolean awarded=sample<quest.fishing().chancePercent().doubleValue();
if(awarded){outcome[0]="quest_item_pending_acknowledgment";event.setCancelled(true);caught.remove();}
else {outcome[0]="ordinary_native_retained_pending_acknowledgment";retain(player.getUniqueId(),claim.hook());} // Let the original native catch follow ordinary collection.
UUID operation=UUID.nameUUIDFromBytes((quest.id()+":"+claim.hook()).getBytes(StandardCharsets.UTF_8));
var plan=new FishingState.Catch(operation,context.record().id(),quest.id(),quest.site(),quest.fishing().itemId(),quest.fishing().itemName(),xp,awarded);
var audit=new LinkedHashMap<String,String>();
audit.put("player_id",player.getUniqueId().toString());audit.put("player_name",player.getName());
audit.put("character_name",context.record().name());audit.put("session_id",context.active().session().toString());
audit.put("catalog_id",quest.catalog().toString());audit.put("hook_id",claim.hook().toString());
audit.put("radius",Integer.toString(quest.fishing().radius()));
audit.put("chance_percent",quest.fishing().chancePercent().toPlainString());
audit.put("sample_percent",Double.toString(sample));
audit.put("native_item_disposition",awarded?"suppressed":"retained");
audit.put("native_xp_disposition","suppressed");
audit.put("native_item_type",nativeItem==null?"unavailable":nativeItem.getType().name());
audit.put("native_item_amount",nativeItem==null?"unavailable":Integer.toString(nativeItem.getAmount()));
audit.put("fisher_location",capture(player.getLocation()));
audit.put("bobber_location",capture(event.getHook().getLocation()));
audit.put("site_world_id",caughtSite.world().toString());
audit.put("site_center_x",Double.toString(caughtSite.x()));
audit.put("site_center_z",Double.toString(caughtSite.z()));
audit.put("geometry_decision","both_inside_same_world_circle");
var plan=new FishingState.Catch(operation,context.record().id(),quest.id(),quest.site(),quest.fishing().itemId(),quest.fishing().itemName(),xp,awarded,audit);
fishing.prepareCatch(plan).whenComplete((status,failure)->{if(failure!=null){report(plan,"prepare",failure);return;}if(status==FishingDirectory.PrepareStatus.APPLIED||status==FishingDirectory.PrepareStatus.EXISTING)recoverCatch(plan);});
}
case IN_GROUND,FAILED_ATTEMPT,REEL_IN->hooks.remove(player.getUniqueId());
case IN_GROUND,FAILED_ATTEMPT,REEL_IN->{hooks.remove(player.getUniqueId());outcome[0]="no_successful_item_reel";}
}
} finally {
if(auditBridge!=null){var resolved=new LinkedHashMap<String,Object>(observation);
resolved.put("outcome",outcome[0]);resolved.put("final_cancelled",event.isCancelled());
resolved.put("native_xp",event.getExpToDrop());
auditBridge.transientEvent("action.resolved",action,auditActor,resolved);
}
}
}
private void refuseCatch(PlayerFishEvent event,String reason){event.setCancelled(true);event.setExpToDrop(0);if(event.getCaught()!=null)event.getCaught().remove();event.getPlayer().sendMessage(reason);}
@EventHandler(priority=EventPriority.MONITOR)public void quit(org.bukkit.event.player.PlayerQuitEvent event){cleanup(event.getPlayer());}
private static String capture(Location value){
if(value==null||value.getWorld()==null)return "unavailable";
return value.getWorld().getUID()+","+value.getX()+","+value.getY()+","+value.getZ();
}
private static Map<String,Object> locationMap(String encoded){
if(encoded==null||encoded.equals("unavailable"))return Map.of("status","unavailable");
try{var parts=encoded.split(",",-1);if(parts.length!=4)throw new IllegalArgumentException();
return Map.of("world_id",UUID.fromString(parts[0]).toString(),
"x",Double.parseDouble(parts[1]),"y",Double.parseDouble(parts[2]),"z",Double.parseDouble(parts[3]));
}catch(RuntimeException invalid){return Map.of("status","unavailable");}
}
private static Map<String,Object> giverLocation(AdventureQuestState.Giver giver){
return Map.of("world_id",giver.world().toString(),"x",giver.x(),"y",giver.y(),"z",giver.z());
}
private void retain(UUID player,UUID hook){var recent=retainedHooks.computeIfAbsent(player,ignored->new ArrayDeque<>());recent.addLast(hook);while(recent.size()>16)recent.removeFirst();}
private void refuseCatch(PlayerFishEvent event,String reason){event.setCancelled(true);event.setExpToDrop(0);var recent=retainedHooks.get(event.getPlayer().getUniqueId());if(event.getCaught()!=null&&(recent==null||!recent.contains(event.getHook().getUniqueId())))event.getCaught().remove();event.getPlayer().sendMessage(reason);}
@EventHandler(priority=EventPriority.MONITOR)public void quit(org.bukkit.event.player.PlayerQuitEvent event){cleanup(event.getPlayer());retainedHooks.remove(event.getPlayer().getUniqueId());menuGivers.remove(event.getPlayer().getUniqueId());}
@EventHandler(priority=EventPriority.MONITOR)public void changed(org.bukkit.event.player.PlayerItemHeldEvent event){cleanup(event.getPlayer());}
@EventHandler(priority=EventPriority.MONITOR)public void opened(org.bukkit.event.inventory.InventoryOpenEvent event){if(event.getPlayer() instanceof Player player&&hooks.containsKey(player.getUniqueId()))cleanup(player);}
@EventHandler(priority=EventPriority.MONITOR)public void sneakBook(org.bukkit.event.player.PlayerInteractEvent event){if(event.getPlayer().isSneaking()&&AdventureQuestNavigation.fishingQuest(event.getItem())!=null)cleanup(event.getPlayer());}
private void cleanup(Player player){hooks.remove(player.getUniqueId());try{var hook=player.getFishHook();if(hook!=null)hook.remove();}catch(RuntimeException ignored){}}
private void recoverCatch(FishingState.Catch plan){characters.find(plan.character()).whenComplete((record,failure)->{if(failure!=null||record.isEmpty()){if(failure!=null)report(plan,"load",failure);return;}awardCatch(plan,record.orElseThrow());});}
private void awardCatch(FishingState.Catch plan,CharacterRecord record){characters.awardCombatReward(record.id(),record.revision(),plan.id(),plan.xp(),BigInteger.ZERO,progression,CombatRewardRules.defaults(),null).whenComplete((award,failure)->{if(failure!=null){report(plan,"award",failure);return;}if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){awardCatch(plan,award.record().orElseThrow());return;}if(award.record().isEmpty()){return;}var saved=award.record().orElseThrow();fishing.completeCatch(plan.id()).whenComplete((status,completeFailure)->{if(completeFailure!=null){report(plan,"complete",completeFailure);return;}schedule(()->{var active=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(active!=null&&award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE)sessions.acknowledgeProgress(active,saved.revision().subtract(BigInteger.ONE),saved);var player=active==null?null:plugin.getServer().getPlayer(active.player());if(player!=null&&status==FishingDirectory.CompleteStatus.APPLIED){String result=plan.itemAwarded()?"Caught "+plan.itemName()+".":"The catch yielded no quest item.";String xp=award.status()==CharacterStorage.CombatAwardStatus.AT_CAP?" Fantasy XP remained at the level cap.":award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE?" Its Fantasy XP was already recorded.":" Earned "+saved.experience().subtract(record.experience()).max(BigInteger.ZERO)+" Fantasy XP.";player.sendMessage(result+xp);}});});});}
private void submit(Player player,Context context){var quest=fishingQuest(context);if(quest==null){player.sendMessage("Fishing submission refused: no eligible accepted fishing quest.");return;}var beneficiaries=beneficiaries(context,quest);if(beneficiaries.isEmpty()||!beneficiaries.contains(context.record().id())){player.sendMessage("Fishing submission refused: party eligibility changed.");return;}var unit=new RewardAllocator.Unit<UUID>(quest.unit(),beneficiaries);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:beneficiaries)rewards.put(character,new FishingState.Reward(xp.get(character),coins.get(character)));UUID operation=UUID.nameUUIDFromBytes((quest.catalog()+":"+quest.site()+":"+quest.id()+":"+context.record().id()).getBytes(StandardCharsets.UTF_8));var plan=new FishingState.Submission(operation,quest.id(),quest.catalog(),quest.site(),context.record().id(),quest.fishing().itemId(),quest.fishing().required(),rewards,FishingState.SubmissionPhase.PREPARED);fishing.prepareSubmission(plan).whenComplete((status,failure)->{if(failure!=null){report(operation,context.record().id(),"submit-prepare",failure);return;}if(status==FishingDirectory.PrepareStatus.INSUFFICIENT||status==FishingDirectory.PrepareStatus.COMPLETED){schedule(()->player.sendMessage(status==FishingDirectory.PrepareStatus.INSUFFICIENT?"Fishing submission refused: the full required quantity is not held.":"Fishing submission refused: this character already completed the offered generation."));return;}commitSubmission(plan,player);});}
private void awardCatch(FishingState.Catch plan,CharacterRecord record){characters.awardCombatReward(record.id(),record.revision(),plan.id(),plan.xp(),BigInteger.ZERO,progression,CombatRewardRules.defaults(),null).whenComplete((award,failure)->{if(failure!=null){report(plan,"award",failure);return;}if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){awardCatch(plan,award.record().orElseThrow());return;}if(award.record().isEmpty()){return;}var saved=award.record().orElseThrow();fishing.completeCatchWithEvidence(plan.id()).whenComplete((receipt,completeFailure)->{if(completeFailure!=null){report(plan,"complete",completeFailure);return;}if(receipt.status()==FishingDirectory.CompleteStatus.APPLIED&&fishingEvents!=null){
var data=new LinkedHashMap<String,Object>(plan.audit());
data.put("fisher_location",locationMap(plan.audit().get("fisher_location")));
data.put("bobber_location",locationMap(plan.audit().get("bobber_location")));
if(plan.audit().containsKey("radius"))data.put("radius",plan.audit().get("radius"));
data.put("catch_id",plan.id().toString());data.put("quest_id",plan.quest().toString());
data.put("site_id",plan.site().toString());data.put("item_id",plan.item().toString());
data.put("item_name",plan.itemName());data.put("item_awarded",plan.itemAwarded());
data.put("sampled_xp",plan.xp().toString());
data.put("allocated_xp",plan.xp().toString()); // Personal catch XP, never a party split.
data.put("stock_before",receipt.stockBefore().toString());
data.put("stock_after",receipt.stockAfter().toString());
data.put("award_status",award.status().name().toLowerCase(java.util.Locale.ROOT));
if(award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE)data.put("applied_xp_status","previously_applied_receipt");
else {var applied=saved.experience().subtract(record.experience()).max(BigInteger.ZERO);
data.put("applied_xp",applied.toString());data.put("lost_xp",plan.xp().subtract(applied).max(BigInteger.ZERO).toString());}
fishingEvents.durable("fishing.catch_resolved",plan.id(),
fishingEvents.actorFromCatch(plan),data);
}schedule(()->{var active=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(active!=null&&award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE)sessions.acknowledgeProgress(active,saved.revision().subtract(BigInteger.ONE),saved);var player=active==null?null:plugin.getServer().getPlayer(active.player());if(player!=null&&receipt.status()==FishingDirectory.CompleteStatus.APPLIED){String result=plan.itemAwarded()?"Caught "+plan.itemName()+".":"The catch yielded no quest item.";String xp=award.status()==CharacterStorage.CombatAwardStatus.AT_CAP?" Fantasy XP remained at the level cap.":award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE?" Its Fantasy XP was already recorded.":" Earned "+saved.experience().subtract(record.experience()).max(BigInteger.ZERO)+" Fantasy XP.";player.sendMessage(result+xp);}});});});}
private void emitSubmission(FishingState.Submission plan,games.dmg.fantasystick.events.FishingEventBridge.Actor actor,
FishingDirectory.SubmissionReceipt receipt){
if(fishingEvents==null)return;
var data=new LinkedHashMap<String,Object>();
data.put("submission_id",plan.id().toString());data.put("quest_id",plan.quest().toString());
data.put("catalog_id",plan.catalog().toString());data.put("site_id",plan.site().toString());
data.put("item_id",plan.item().toString());data.put("submitter_id",plan.submitter().toString());
data.put("required",plan.quantity().toString());data.put("consumed",plan.quantity().toString());
data.put("stock_before",receipt.stockBefore().toString());
data.put("stock_after",receipt.stockAfter().toString());
data.putAll(plan.audit());
data.remove("online_roster");
data.put("roster",plan.rewards().keySet().stream().sorted().map(UUID::toString).toList());
if(plan.audit().containsKey("submitter_location"))
data.put("submitter_location",locationMap(plan.audit().get("submitter_location")));
if(plan.audit().containsKey("giver_location"))
data.put("giver_location",locationMap(plan.audit().get("giver_location")));
if(!plan.audit().containsKey("unit_kind"))data.put("unit_kind","unavailable_legacy_submission");
var beneficiaries=new ArrayList<Map<String,String>>();
var online=plan.audit().containsKey("online_roster")?Set.of(plan.audit().get("online_roster").split(",")):Set.<String>of();
plan.rewards().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry->{
var part=new LinkedHashMap<String,String>();
part.put("character_id",entry.getKey().toString());
part.put("online_at_submission",plan.audit().containsKey("online_roster")
?Boolean.toString(online.contains(entry.getKey().toString())):"unavailable");
part.put("allocated_xp",entry.getValue().xp().toString());
part.put("allocated_coins",entry.getValue().coins().toString());
part.put("receipt_id",plan.id().toString());
if(entry.getValue().xp().signum()>0||entry.getValue().coins().signum()>0)
part.put("allocation_id",UUID.nameUUIDFromBytes(("fishing:"+plan.id()+":"+entry.getKey())
.getBytes(StandardCharsets.UTF_8)).toString());
else part.put("allocation_status","zero_share_completion");
beneficiaries.add(part);
});
data.put("beneficiaries",beneficiaries);
fishingEvents.durable("adventure.items_submitted",plan.id(),actor==null
?new games.dmg.fantasystick.events.FishingEventBridge.Actor("system",null):actor,data);
}
private void submit(Player player,Context context){var quest=fishingQuest(context);if(quest==null){player.sendMessage("Fishing submission refused: no eligible accepted fishing quest.");return;}var beneficiaries=beneficiaries(context,quest);if(beneficiaries.isEmpty()||!beneficiaries.contains(context.record().id())){player.sendMessage("Fishing submission refused: party eligibility changed.");return;}var unit=new RewardAllocator.Unit<UUID>(quest.unit(),beneficiaries);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:beneficiaries)rewards.put(character,new FishingState.Reward(xp.get(character),coins.get(character)));UUID operation=UUID.nameUUIDFromBytes((quest.catalog()+":"+quest.site()+":"+quest.id()+":"+context.record().id()).getBytes(StandardCharsets.UTF_8));var submissionAudit=new LinkedHashMap<String,String>();
submissionAudit.put("unit_kind",quest.unitType().name().toLowerCase(java.util.Locale.ROOT));
submissionAudit.put("unit_id",quest.unit().toString());
var onlineRoster=beneficiaries.stream().sorted().filter(character->{
var holder=sessions.activeCharacter(character).filter(sessions::current).orElse(null);
var found=holder==null?null:plugin.getServer().getPlayer(holder.player());
return found!=null&&found.isOnline();
}).map(UUID::toString).collect(java.util.stream.Collectors.joining(","));
if(onlineRoster.length()<=8192)submissionAudit.put("online_roster",onlineRoster);
else submissionAudit.put("online_roster_status","unavailable_large_roster");
submissionAudit.put("submitter_session_id",context.active().session().toString());
submissionAudit.put("player_id",player.getUniqueId().toString());
submissionAudit.put("player_name",player.getName());
submissionAudit.put("character_name",context.record().name());
submissionAudit.put("submitter_location",capture(player.getLocation()));
var giver=menuGivers.get(player.getUniqueId());
if(giver!=null){submissionAudit.put("giver_id",giver.id().toString());
submissionAudit.put("giver_location",giver.world()+","+giver.x()+","+giver.y()+","+giver.z());}
var plan=new FishingState.Submission(operation,quest.id(),quest.catalog(),quest.site(),context.record().id(),
quest.fishing().itemId(),quest.fishing().required(),rewards,FishingState.SubmissionPhase.PREPARED,submissionAudit);
var auditActor=fishingEvents==null?null:fishingEvents.actor(player);fishing.prepareSubmission(plan).whenComplete((status,failure)->{if(failure!=null){report(operation,context.record().id(),"submit-prepare",failure);return;}if(status==FishingDirectory.PrepareStatus.INSUFFICIENT||status==FishingDirectory.PrepareStatus.COMPLETED){schedule(()->player.sendMessage(status==FishingDirectory.PrepareStatus.INSUFFICIENT?"Fishing submission refused: the full required quantity is not held.":"Fishing submission refused: this character already completed the offered generation."));return;}commitSubmission(plan,player,auditActor);});}
private void recoverSubmission(FishingState.Submission plan){if(plan.phase()==FishingState.SubmissionPhase.PREPARED)fishing.commitSubmission(plan.id()).whenComplete((status,failure)->{if(failure!=null){report(plan.id(),plan.submitter(),"submission-recovery-commit",failure);return;}if(status==FishingDirectory.CommitStatus.APPLIED||status==FishingDirectory.CommitStatus.DUPLICATE)finishRecoveredSubmission(plan);});else finishRecoveredSubmission(plan);}
private void finishRecoveredSubmission(FishingState.Submission plan){adventures.complete(plan.quest(),plan.rewards().keySet()).whenComplete((ignored,failure)->{if(failure!=null){report(plan.id(),plan.submitter(),"submission-recovery-quest",failure);return;}fishing.finishSubmission(plan.id()).whenComplete((finished,finishFailure)->{if(finishFailure!=null)report(plan.id(),plan.submitter(),"submission-recovery-finish",finishFailure);});});}
private void commitSubmission(FishingState.Submission plan,Player player){fishing.commitSubmission(plan.id()).whenComplete((status,failure)->{if(failure!=null){report(plan.id(),plan.submitter(),"submit-commit",failure);return;}if(status==FishingDirectory.CommitStatus.INSUFFICIENT){schedule(()->player.sendMessage("Fishing submission refused: stock changed before commitment."));return;}if(status==FishingDirectory.CommitStatus.NOT_FOUND)return;adventures.complete(plan.quest(),plan.rewards().keySet()).whenComplete((completed,questFailure)->{if(questFailure!=null){report(plan.id(),plan.submitter(),"quest-complete",questFailure);return;}fishing.finishSubmission(plan.id()).whenComplete((finished,finishFailure)->{if(finishFailure!=null)report(plan.id(),plan.submitter(),"submit-finish",finishFailure);schedule(()->{player.sendMessage("Fishing quest submitted; earned shares are ready at any Quest Giver.");open(player);});});});});}
private void redeemTrophy(Player player,Context context,String encoded){UUID trophy;try{trophy=UUID.fromString(encoded);}catch(IllegalArgumentException invalid){player.sendMessage("Trophy redemption refused: invalid authority.");return;}fishing.redeemTrophy(trophy,context.record().id()).whenComplete((status,failure)->schedule(()->{if(failure!=null){report(trophy,context.record().id(),"trophy-redeem",failure);player.sendMessage("Trophy redemption failed; the earned trophy is retained.");return;}if(status==FishingDirectory.TrophyRedeemStatus.APPLIED)player.sendMessage("Adventure trophy redeemed; frozen shares are ready for each beneficiary.");else player.sendMessage("Trophy redemption refused: the receipt is unavailable for this character.");open(player);}));}
private void commitSubmission(FishingState.Submission plan,Player player,games.dmg.fantasystick.events.FishingEventBridge.Actor actor){fishing.commitSubmissionWithEvidence(plan.id()).whenComplete((receipt,failure)->{var status=receipt==null?null:receipt.status();if(failure!=null){report(plan.id(),plan.submitter(),"submit-commit",failure);return;}if(status==FishingDirectory.CommitStatus.INSUFFICIENT){schedule(()->player.sendMessage("Fishing submission refused: stock changed before commitment."));return;}if(status==FishingDirectory.CommitStatus.NOT_FOUND)return;if(status==FishingDirectory.CommitStatus.APPLIED)emitSubmission(plan,actor,receipt);adventures.complete(plan.quest(),plan.rewards().keySet()).whenComplete((completed,questFailure)->{if(questFailure!=null){report(plan.id(),plan.submitter(),"quest-complete",questFailure);return;}fishing.finishSubmission(plan.id()).whenComplete((finished,finishFailure)->{if(finishFailure!=null)report(plan.id(),plan.submitter(),"submit-finish",finishFailure);schedule(()->{player.sendMessage("Fishing quest submitted; earned shares are ready at any Quest Giver.");open(player,0,menuGivers.get(player.getUniqueId()));});});});});}
private void redeemTrophy(Player player,Context context,String encoded){
UUID trophy;try{trophy=UUID.fromString(encoded);}catch(IllegalArgumentException invalid){
player.sendMessage("Trophy redemption refused: invalid authority.");return;}
var captured=fishing.trophy(trophy).orElse(null);
var giver=menuGivers.get(player.getUniqueId());
var actor=fishingEvents==null?null:fishingEvents.actor(player);
var interactionLocation=locationMap(capture(player.getLocation()));
fishing.redeemTrophyWithEvidence(trophy,context.record().id()).whenComplete((receipt,failure)->schedule(()->{
if(failure!=null){report(trophy,context.record().id(),"trophy-redeem",failure);
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&&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());
if(captured.type()==FishingState.TrophyType.CAMP){
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());
data.put("quest_id",captured.quest().toString());data.put("encounter_id",captured.source().toString());
data.put("redeemer_session_id",context.active().session().toString());
data.put("redeemer_location",interactionLocation);
data.put("giver_status",giver==null?"menu_origin_unverified":"entered_via_registered_giver");
if(giver!=null){data.put("giver_id",giver.id().toString());
data.put("giver_location",giverLocation(giver));}
var allocations=receipt.allocations();
var beneficiaries=new ArrayList<Map<String,String>>();
captured.rewards().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry->{
var share=new LinkedHashMap<String,String>();share.put("character_id",entry.getKey().toString());
share.put("allocated_xp",entry.getValue().xp().toString());
share.put("allocated_coins",entry.getValue().coins().toString());
var saved=allocations.stream().filter(value->value.submission().equals(captured.id())
&&value.character().equals(entry.getKey())).findFirst().orElse(null);
share.put("allocation_status",saved==null?"zero_share_completion":"unlocked");
if(saved!=null)share.put("allocation_id",saved.id().toString());
beneficiaries.add(share);
});
data.put("beneficiaries",beneficiaries);
fishingEvents.durable("adventure.trophy_redeemed",captured.id(),actor==null
?new games.dmg.fantasystick.events.FishingEventBridge.Actor("system",null):actor,data);
}
player.sendMessage("Adventure trophy redeemed; frozen shares are ready for each beneficiary.");
}else player.sendMessage("Trophy redemption refused: the receipt is unavailable for this character.");
open(player,0,menuGivers.get(player.getUniqueId()));
}));
}
private void claimLegacy(Player player,Context context){adventures.beginLegacyClaim(context.record().id()).whenComplete((claim,failure)->{if(failure!=null||claim.isEmpty()){if(failure!=null)report(UUID.randomUUID(),context.record().id(),"legacy-claim-prepare",failure);schedule(()->player.sendMessage("Retained adventure reward claim failed; earned shares remain."));return;}awardLegacy(player,claim.orElseThrow(),context.record());});}
private void awardLegacy(Player player,AdventureQuestState.RewardClaim claim,CharacterRecord record){characters.awardCombatReward(record.id(),record.revision(),claim.id(),claim.xp(),claim.coins(),progression,CombatRewardRules.defaults(),null).whenComplete((award,failure)->{if(failure!=null){report(claim.id(),claim.character(),"legacy-claim-award",failure);return;}if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){awardLegacy(player,claim,award.record().orElseThrow());return;}if(award.record().isEmpty())return;var saved=award.record().orElseThrow();adventures.finishClaim(claim.id()).whenComplete((finished,finishFailure)->{if(finishFailure!=null){report(claim.id(),claim.character(),"legacy-claim-finish",finishFailure);return;}schedule(()->{var current=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(current!=null&&award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE)sessions.acknowledgeProgress(current,saved.revision().subtract(BigInteger.ONE),saved);var target=current==null?null:plugin.getServer().getPlayer(current.player());if(target!=null){target.sendMessage(award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE?"Finished a previously applied adventure reward claim.":"Claimed "+saved.experience().subtract(record.experience()).max(BigInteger.ZERO)+" retained adventure XP and "+saved.coins().subtract(record.coins()).max(BigInteger.ZERO)+" character coins.");open(target);}});});});}
private void claim(Player player,Context context){fishing.beginClaim(context.record().id()).whenComplete((claim,failure)->{if(failure!=null||claim.isEmpty()){if(failure!=null)report(UUID.randomUUID(),context.record().id(),"claim-prepare",failure);schedule(()->player.sendMessage("Fishing reward claim failed; earned shares are retained."));return;}awardClaim(player,context.active(),claim.orElseThrow(),context.record());});}
private void recoverClaim(FishingState.Claim claim){characters.find(claim.character()).whenComplete((record,failure)->{if(failure!=null){report(claim.id(),claim.character(),"claim-load",failure);return;}record.ifPresent(value->awardClaim(null,null,claim,value));});}
private void awardClaim(Player player,EquipmentSessions.Active active,FishingState.Claim claim,CharacterRecord record){characters.awardCombatReward(record.id(),record.revision(),claim.id(),claim.xp(),claim.coins(),progression,CombatRewardRules.defaults(),null).whenComplete((award,failure)->{if(failure!=null){report(claim.id(),claim.character(),"claim-award",failure);return;}if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){awardClaim(player,active,claim,award.record().orElseThrow());return;}if(award.record().isEmpty())return;var saved=award.record().orElseThrow();fishing.finishClaim(claim.id()).whenComplete((finished,finishFailure)->{if(finishFailure!=null){report(claim.id(),claim.character(),"claim-finish",finishFailure);return;}schedule(()->{var current=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(current!=null&&award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE)sessions.acknowledgeProgress(current,saved.revision().subtract(BigInteger.ONE),saved);var target=current==null?null:plugin.getServer().getPlayer(current.player());if(target!=null){target.sendMessage(award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE?"Finished a previously applied fishing reward claim.":"Claimed "+saved.experience().subtract(record.experience()).max(BigInteger.ZERO)+" quest XP and "+saved.coins().subtract(record.coins()).max(BigInteger.ZERO)+" character coins.");open(target);}});});});}
private void claim(Player player,Context context){var actor=fishingEvents==null?null:fishingEvents.actor(player);var giver=menuGivers.get(player.getUniqueId());fishing.beginClaim(context.record().id()).whenComplete((claim,failure)->{if(failure!=null||claim.isEmpty()){if(failure!=null)report(UUID.randomUUID(),context.record().id(),"claim-prepare",failure);schedule(()->player.sendMessage("Fishing reward claim failed; earned shares are retained."));return;}awardClaim(player,context.active(),claim.orElseThrow(),context.record(),actor,giver,false);});}
private void recoverClaim(FishingState.Claim claim){characters.find(claim.character()).whenComplete((record,failure)->{if(failure!=null){report(claim.id(),claim.character(),"claim-load",failure);return;}record.ifPresent(value->awardClaim(null,null,claim,value,null,null,true));});}
private void awardClaim(Player player,EquipmentSessions.Active active,FishingState.Claim claim,CharacterRecord record,
games.dmg.fantasystick.events.FishingEventBridge.Actor actor,AdventureQuestState.Giver giver,boolean recovery){
characters.awardCombatReward(record.id(),record.revision(),claim.id(),claim.xp(),claim.coins(),progression,CombatRewardRules.defaults(),null).whenComplete((award,failure)->{
if(failure!=null){report(claim.id(),claim.character(),"claim-award",failure);return;}
if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){
awardClaim(player,active,claim,award.record().orElseThrow(),actor,giver,recovery);return;
}
if(award.record().isEmpty())return;
var saved=award.record().orElseThrow();
var before=fishing.snapshot();
var sources=before.allocations().stream().filter(value->claim.allocations().contains(value.id()))
.sorted(Comparator.comparing(FishingState.Allocation::id)).toList();
fishing.finishClaim(claim.id()).whenComplete((finished,finishFailure)->{
if(finishFailure!=null){report(claim.id(),claim.character(),"claim-finish",finishFailure);return;}
if(finished&&fishingEvents!=null){
var data=new LinkedHashMap<String,Object>();
data.put("claim_id",claim.id().toString());data.put("beneficiary_id",claim.character().toString());
data.put("requested_xp",claim.xp().toString());data.put("requested_coins",claim.coins().toString());
data.put("recovery",recovery);data.put("award_status",award.status().name().toLowerCase(java.util.Locale.ROOT));
data.put("revision_before",record.revision().toString());data.put("revision_after",saved.revision().toString());
data.put("experience_after",saved.experience().toString());data.put("coins_after",saved.coins().toString());
if(award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE)data.put("applied_status","previously_applied_receipt");
else {
var appliedXp=saved.experience().subtract(record.experience()).max(BigInteger.ZERO);
var appliedCoins=saved.coins().subtract(record.coins()).max(BigInteger.ZERO);
data.put("applied_xp",appliedXp.toString());data.put("lost_xp",claim.xp().subtract(appliedXp).max(BigInteger.ZERO).toString());
data.put("applied_coins",appliedCoins.toString());data.put("lost_coins",claim.coins().subtract(appliedCoins).max(BigInteger.ZERO).toString());
}
data.put("giver_status",recovery?"recovered_without_live_giver":giver==null?"menu_origin_unverified":"entered_via_registered_giver");
if(giver!=null){data.put("giver_id",giver.id().toString());data.put("giver_location",giverLocation(giver));}
var provenance=new ArrayList<Map<String,String>>();
for(var source:sources){var detail=new LinkedHashMap<String,String>();
detail.put("allocation_id",source.id().toString());
detail.put("source_receipt_id",source.submission().toString());
detail.put("source_kind",source.sourceKind());
detail.put("catalog_id",source.catalog().toString());detail.put("site_id",source.site().toString());
detail.put("allocated_xp",source.xp().toString());detail.put("allocated_coins",source.coins().toString());
provenance.add(detail);
}
data.put("sources",provenance);
fishingEvents.durable("adventure.reward_claimed",claim.id(),actor==null
?new games.dmg.fantasystick.events.FishingEventBridge.Actor("system",null):actor,data);
}
schedule(()->{var current=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(current!=null&&award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE)sessions.acknowledgeProgress(current,saved.revision().subtract(BigInteger.ONE),saved);var target=current==null?null:plugin.getServer().getPlayer(current.player());if(target!=null){target.sendMessage(award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE?"Finished a previously applied fishing reward claim.":"Claimed "+saved.experience().subtract(record.experience()).max(BigInteger.ZERO)+" quest XP and "+saved.coins().subtract(record.coins()).max(BigInteger.ZERO)+" character coins.");open(target);}});
});
});
}
private AdventureQuestState.Quest fishingQuest(Context context){if(adventures.snapshot().reset()!=null)return null;return adventures.activeFor(context.record().id(),context.party()).filter(value->value.fishing()!=null&&eligible(context,value)).orElse(null);}
private boolean eligible(Context context,AdventureQuestState.Quest quest){if(quest.unitType()==AdventureQuestState.UnitType.SOLO)return quest.unit().equals(context.record().id());return context.party()!=null&&quest.unit().equals(context.party())&&quest.snapshot().contains(context.record().id())&&context.members().contains(context.record().id());}
private List<UUID> beneficiaries(Context context,AdventureQuestState.Quest quest){if(quest.unitType()==AdventureQuestState.UnitType.SOLO)return List.of(context.record().id());var party=parties.partyOfCharacter(context.record().id()).filter(value->value.id().equals(quest.unit())).orElse(null);return party==null?List.of():quest.snapshot().stream().filter(party.characters()::contains).distinct().sorted().toList();}
@@ -99,5 +372,5 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
private void report(FishingState.Catch plan,String phase,Throwable failure){report(plan.id(),plan.character(),phase,failure);}
private void report(UUID operation,UUID character,String phase,Throwable failure){try{failures.accept(new Failure(operation,character,phase,failure));}catch(RuntimeException ignored){}}
private void schedule(Runnable task){if(closed)return;try{nextTick.accept(()->{if(!closed)task.run();});}catch(RuntimeException ignored){}}
@Override public void close(){closed=true;if(pulseTask!=null)pulseTask.cancel();for(var player:plugin.getServer().getOnlinePlayers())if(hooks.containsKey(player.getUniqueId()))cleanup(player);hooks.clear();menus.clear();}
@Override public void close(){closed=true;if(pulseTask!=null)pulseTask.cancel();for(var player:plugin.getServer().getOnlinePlayers())if(hooks.containsKey(player.getUniqueId()))cleanup(player);hooks.clear();retainedHooks.clear();menus.clear();menuGivers.clear();}
}
@@ -11,7 +11,9 @@ import java.util.concurrent.*;
public final class FishingDirectory implements AutoCloseable {
public enum PrepareStatus { APPLIED, EXISTING, DUPLICATE, INSUFFICIENT, COMPLETED }
public enum CompleteStatus { APPLIED, DUPLICATE, NOT_FOUND }
public record CatchReceipt(CompleteStatus status,BigInteger stockBefore,BigInteger stockAfter) { }
public enum CommitStatus { APPLIED, DUPLICATE, INSUFFICIENT, NOT_FOUND }
public record SubmissionReceipt(CommitStatus status,BigInteger stockBefore,BigInteger stockAfter) { }
public enum TrophyPrepareStatus { APPLIED, EXISTING, REDEEMED }
public enum TrophyRedeemStatus { APPLIED, REDEEMED, NOT_FOUND, WRONG_CARRIER }
public enum TrophyCommitStatus { APPLIED, DUPLICATE, NOT_FOUND }
@@ -27,12 +29,87 @@ public final class FishingDirectory implements AutoCloseable {
public List<FishingState.Trophy> trophies(UUID carrier){return snapshot.trophies().stream().filter(value->value.phase()==FishingState.TrophyPhase.EARNED&&value.carrier().equals(carrier)).sorted(Comparator.comparing(FishingState.Trophy::name).thenComparing(FishingState.Trophy::id)).toList();}
public CompletableFuture<TrophyPrepareStatus> prepareTrophy(FishingState.Trophy requested){Objects.requireNonNull(requested);return mutate(state->{if(state.trophyReceipts().contains(requested.id()))return result(state,TrophyPrepareStatus.REDEEMED);var existing=state.trophies().stream().filter(value->value.id().equals(requested.id())).findFirst();if(existing.isPresent()){if(!sameTrophyCompletion(existing.orElseThrow(),requested))throw new IllegalStateException("Conflicting adventure trophy");return result(state,TrophyPrepareStatus.EXISTING);}var trophies=new ArrayList<>(state.trophies());trophies.add(requested);return result(copyTrophies(state,trophies,state.trophyReceipts(),state.allocations()),TrophyPrepareStatus.APPLIED);});}
public CompletableFuture<TrophyCommitStatus> commitTrophy(UUID id){return mutate(state->{var trophy=state.trophies().stream().filter(value->value.id().equals(id)).findFirst().orElse(null);if(trophy==null)return result(state,TrophyCommitStatus.NOT_FOUND);if(trophy.phase()==FishingState.TrophyPhase.EARNED)return result(state,TrophyCommitStatus.DUPLICATE);var trophies=state.trophies().stream().map(value->value.id().equals(id)?value.earned():value).toList();return result(copyTrophies(state,trophies,state.trophyReceipts(),state.allocations()),TrophyCommitStatus.APPLIED);});}
public CompletableFuture<TrophyRedeemStatus> redeemTrophy(UUID id,UUID carrier){Objects.requireNonNull(id);Objects.requireNonNull(carrier);return mutate(state->{if(state.trophyReceipts().contains(id))return result(state,TrophyRedeemStatus.REDEEMED);var trophy=state.trophies().stream().filter(value->value.id().equals(id)).findFirst().orElse(null);if(trophy==null||trophy.phase()!=FishingState.TrophyPhase.EARNED)return result(state,TrophyRedeemStatus.NOT_FOUND);if(!trophy.carrier().equals(carrier))return result(state,TrophyRedeemStatus.WRONG_CARRIER);var allocations=new ArrayList<>(state.allocations());for(var entry:trophy.rewards().entrySet()){var reward=entry.getValue();if(reward.xp().signum()==0&&reward.coins().signum()==0)continue;UUID allocation=trophyAllocationId(trophy.id(),entry.getKey());if(allocations.stream().noneMatch(value->value.id().equals(allocation)))allocations.add(new FishingState.Allocation(allocation,trophy.id(),trophy.catalog(),trophy.site(),entry.getKey(),reward.xp(),reward.coins()));}var receipts=new HashSet<>(state.trophyReceipts());receipts.add(id);return result(copyTrophies(state,state.trophies().stream().filter(value->!value.id().equals(id)).toList(),receipts,allocations),TrophyRedeemStatus.APPLIED);});}
public record TrophyRedemption(TrophyRedeemStatus status,List<FishingState.Allocation> allocations){
public TrophyRedemption{allocations=List.copyOf(allocations);}
}
public CompletableFuture<TrophyRedeemStatus> redeemTrophy(UUID id,UUID carrier){
return redeemTrophyWithEvidence(id,carrier).thenApply(TrophyRedemption::status);
}
public CompletableFuture<TrophyRedemption> redeemTrophyWithEvidence(UUID id,UUID carrier){
Objects.requireNonNull(id);Objects.requireNonNull(carrier);
return mutate(state->{
if(state.trophyReceipts().contains(id))
return result(state,new TrophyRedemption(TrophyRedeemStatus.REDEEMED,List.of()));
var trophy=state.trophies().stream().filter(value->value.id().equals(id)).findFirst().orElse(null);
if(trophy==null||trophy.phase()!=FishingState.TrophyPhase.EARNED)
return result(state,new TrophyRedemption(TrophyRedeemStatus.NOT_FOUND,List.of()));
if(!trophy.carrier().equals(carrier))
return result(state,new TrophyRedemption(TrophyRedeemStatus.WRONG_CARRIER,List.of()));
var allocations=new ArrayList<>(state.allocations());
var created=new ArrayList<FishingState.Allocation>();
for(var entry:trophy.rewards().entrySet()){
var reward=entry.getValue();
if(reward.xp().signum()==0&&reward.coins().signum()==0)continue;
UUID allocation=trophyAllocationId(trophy.id(),entry.getKey());
var existing=allocations.stream().filter(value->value.id().equals(allocation)).findFirst().orElse(null);
if(existing==null){
existing=new FishingState.Allocation(allocation,trophy.id(),trophy.catalog(),trophy.site(),
entry.getKey(),reward.xp(),reward.coins(),
trophy.type().name().toLowerCase(java.util.Locale.ROOT)+"_trophy");
allocations.add(existing);
}
created.add(existing);
}
var receipts=new HashSet<>(state.trophyReceipts());receipts.add(id);
return result(copyTrophies(state,state.trophies().stream().filter(value->!value.id().equals(id))
.toList(),receipts,allocations),new TrophyRedemption(TrophyRedeemStatus.APPLIED,created));
});
}
public List<FishingState.Catch> pendingCatches(){return snapshot.catches();}
public CompletableFuture<PrepareStatus> prepareCatch(FishingState.Catch requested){Objects.requireNonNull(requested);return mutate(state->{if(state.catchReceipts().contains(requested.id()))return result(state,PrepareStatus.DUPLICATE);var existing=state.catches().stream().filter(value->value.id().equals(requested.id())).findFirst();if(existing.isPresent()){if(!existing.orElseThrow().equals(requested))throw new IllegalStateException("Conflicting fishing catch");return result(state,PrepareStatus.EXISTING);}var catches=new ArrayList<>(state.catches());catches.add(requested);return result(copy(state,state.inventory(),catches,state.catchReceipts(),state.submissions(),state.completions(),state.allocations(),state.claims()),PrepareStatus.APPLIED);});}
public CompletableFuture<CompleteStatus> completeCatch(UUID operation){return mutate(state->{if(state.catchReceipts().contains(operation))return result(state,CompleteStatus.DUPLICATE);var caught=state.catches().stream().filter(value->value.id().equals(operation)).findFirst().orElse(null);if(caught==null)return result(state,CompleteStatus.NOT_FOUND);var inventory=new ArrayList<>(state.inventory());if(caught.itemAwarded()){var existing=inventory.stream().filter(value->value.character().equals(caught.character())&&value.item().equals(caught.item())).findFirst().orElse(null);if(existing==null)inventory.add(new FishingState.ItemStack(caught.character(),caught.item(),caught.site(),caught.itemName(),BigInteger.ONE));else{var updated=new FishingState.ItemStack(existing.character(),existing.item(),existing.site(),caught.itemName(),existing.amount().add(BigInteger.ONE));inventory.replaceAll(value->value==existing?updated:value);}}var receipts=new HashSet<>(state.catchReceipts());receipts.add(operation);var catches=state.catches().stream().filter(value->!value.id().equals(operation)).toList();return result(copy(state,inventory,catches,receipts,state.submissions(),state.completions(),state.allocations(),state.claims()),CompleteStatus.APPLIED);});}
public CompletableFuture<CompleteStatus> completeCatch(UUID operation){return completeCatchWithEvidence(operation).thenApply(CatchReceipt::status);}
public CompletableFuture<CatchReceipt> completeCatchWithEvidence(UUID operation){return mutate(state->{
if(state.catchReceipts().contains(operation))return result(state,new CatchReceipt(CompleteStatus.DUPLICATE,null,null));
var caught=state.catches().stream().filter(value->value.id().equals(operation)).findFirst().orElse(null);
if(caught==null)return result(state,new CatchReceipt(CompleteStatus.NOT_FOUND,null,null));
var before=quantity(state,caught.character(),caught.item());
var inventory=new ArrayList<>(state.inventory());
if(caught.itemAwarded()){
var existing=inventory.stream().filter(value->value.character().equals(caught.character())&&value.item().equals(caught.item())).findFirst().orElse(null);
if(existing==null)inventory.add(new FishingState.ItemStack(caught.character(),caught.item(),caught.site(),caught.itemName(),BigInteger.ONE));
else {var updated=new FishingState.ItemStack(existing.character(),existing.item(),existing.site(),caught.itemName(),existing.amount().add(BigInteger.ONE));inventory.replaceAll(value->value==existing?updated:value);}
}
var receipts=new HashSet<>(state.catchReceipts());receipts.add(operation);
var catches=state.catches().stream().filter(value->!value.id().equals(operation)).toList();
return result(copy(state,inventory,catches,receipts,state.submissions(),state.completions(),state.allocations(),state.claims()),
new CatchReceipt(CompleteStatus.APPLIED,before,before.add(caught.itemAwarded()?BigInteger.ONE:BigInteger.ZERO)));
});}
public CompletableFuture<PrepareStatus> prepareSubmission(FishingState.Submission requested){Objects.requireNonNull(requested);if(requested.phase()!=FishingState.SubmissionPhase.PREPARED)throw new IllegalArgumentException("Submission must begin prepared");return mutate(state->{var existing=state.submissions().stream().filter(value->value.id().equals(requested.id())).findFirst();if(existing.isPresent()){if(!sameSubmission(existing.orElseThrow(),requested))throw new IllegalStateException("Conflicting fishing submission");return result(state,PrepareStatus.EXISTING);}if(state.submissions().stream().anyMatch(value->value.quest().equals(requested.quest())))return result(state,PrepareStatus.COMPLETED);boolean completed=requested.rewards().keySet().stream().anyMatch(character->state.completions().contains(new FishingState.Completion(requested.catalog(),requested.site(),character)));if(completed)return result(state,PrepareStatus.COMPLETED);if(quantity(state,requested.submitter(),requested.item()).compareTo(requested.quantity())<0)return result(state,PrepareStatus.INSUFFICIENT);var submissions=new ArrayList<>(state.submissions());submissions.add(requested);return result(copy(state,state.inventory(),state.catches(),state.catchReceipts(),submissions,state.completions(),state.allocations(),state.claims()),PrepareStatus.APPLIED);});}
public CompletableFuture<CommitStatus> commitSubmission(UUID operation){return mutate(state->{var submission=state.submissions().stream().filter(value->value.id().equals(operation)).findFirst().orElse(null);if(submission==null)return result(state,CommitStatus.NOT_FOUND);if(submission.phase()==FishingState.SubmissionPhase.COMMITTED)return result(state,CommitStatus.DUPLICATE);if(quantity(state,submission.submitter(),submission.item()).compareTo(submission.quantity())<0)return result(state,CommitStatus.INSUFFICIENT);var inventory=new ArrayList<>(state.inventory());var existing=inventory.stream().filter(value->value.character().equals(submission.submitter())&&value.item().equals(submission.item())).findFirst().orElseThrow();var remaining=existing.amount().subtract(submission.quantity());if(remaining.signum()==0)inventory.remove(existing);else inventory.replaceAll(value->value==existing?new FishingState.ItemStack(existing.character(),existing.item(),existing.site(),existing.name(),remaining):value);var completions=new HashSet<>(state.completions());var allocations=new ArrayList<>(state.allocations());for(var entry:submission.rewards().entrySet()){completions.add(new FishingState.Completion(submission.catalog(),submission.site(),entry.getKey()));var reward=entry.getValue();if(reward.xp().signum()==0&&reward.coins().signum()==0)continue;UUID id=allocationId(submission.id(),entry.getKey());allocations.add(new FishingState.Allocation(id,submission.id(),submission.catalog(),submission.site(),entry.getKey(),reward.xp(),reward.coins()));}var submissions=state.submissions().stream().map(value->value.id().equals(operation)?value.committed():value).toList();return result(copy(state,inventory,state.catches(),state.catchReceipts(),submissions,completions,allocations,state.claims()),CommitStatus.APPLIED);});}
public CompletableFuture<CommitStatus> commitSubmission(UUID operation){return commitSubmissionWithEvidence(operation).thenApply(SubmissionReceipt::status);}
public CompletableFuture<SubmissionReceipt> commitSubmissionWithEvidence(UUID operation){return mutate(state->{
var submission=state.submissions().stream().filter(value->value.id().equals(operation)).findFirst().orElse(null);
if(submission==null)return result(state,new SubmissionReceipt(CommitStatus.NOT_FOUND,null,null));
if(submission.phase()==FishingState.SubmissionPhase.COMMITTED)
return result(state,new SubmissionReceipt(CommitStatus.DUPLICATE,null,null));
var before=quantity(state,submission.submitter(),submission.item());
if(before.compareTo(submission.quantity())<0)
return result(state,new SubmissionReceipt(CommitStatus.INSUFFICIENT,before,before));
var inventory=new ArrayList<>(state.inventory());
var existing=inventory.stream().filter(value->value.character().equals(submission.submitter())&&value.item().equals(submission.item())).findFirst().orElseThrow();
var remaining=before.subtract(submission.quantity());
if(remaining.signum()==0)inventory.remove(existing);
else inventory.replaceAll(value->value==existing?new FishingState.ItemStack(existing.character(),existing.item(),existing.site(),existing.name(),remaining):value);
var completions=new HashSet<>(state.completions());var allocations=new ArrayList<>(state.allocations());
for(var entry:submission.rewards().entrySet()){
completions.add(new FishingState.Completion(submission.catalog(),submission.site(),entry.getKey()));
var reward=entry.getValue();if(reward.xp().signum()==0&&reward.coins().signum()==0)continue;
allocations.add(new FishingState.Allocation(allocationId(submission.id(),entry.getKey()),submission.id(),
submission.catalog(),submission.site(),entry.getKey(),reward.xp(),reward.coins(),"fishing"));
}
var submissions=state.submissions().stream().map(value->value.id().equals(operation)?value.committed():value).toList();
return result(copy(state,inventory,state.catches(),state.catchReceipts(),submissions,completions,allocations,state.claims()),
new SubmissionReceipt(CommitStatus.APPLIED,before,remaining));
});}
public CompletableFuture<Boolean> finishSubmission(UUID operation){return mutate(state->{var found=state.submissions().stream().filter(value->value.id().equals(operation)&&value.phase()==FishingState.SubmissionPhase.COMMITTED).findFirst();if(found.isEmpty())return result(state,false);return result(copy(state,state.inventory(),state.catches(),state.catchReceipts(),state.submissions().stream().filter(value->!value.id().equals(operation)).toList(),state.completions(),state.allocations(),state.claims()),true);});}
public List<FishingState.Submission> pendingSubmissions(){return snapshot.submissions();}
public boolean completed(UUID catalog,UUID site,UUID character){return snapshot.completions().contains(new FishingState.Completion(catalog,site,character));}
@@ -17,24 +17,58 @@ public record FishingState(List<ItemStack> inventory,List<Trophy> trophies,Set<U
}
public enum TrophyType { CHAMPION, CAMP, DUNGEON }
public enum TrophyPhase { PREPARED, EARNED }
public record Trophy(UUID id,TrophyType type,UUID carrier,UUID catalog,UUID site,UUID quest,UUID source,String name,String origin,Map<UUID,Reward> rewards,TrophyPhase phase) {
public Trophy {Objects.requireNonNull(id);Objects.requireNonNull(type);Objects.requireNonNull(carrier);Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(quest);Objects.requireNonNull(source);name=validName(name);origin=validName(origin);rewards=Map.copyOf(rewards);Objects.requireNonNull(phase);if(name.isBlank()||origin.isBlank()||rewards.isEmpty()||rewards.entrySet().stream().anyMatch(entry->entry.getKey()==null||entry.getValue()==null))throw new IllegalArgumentException("Invalid adventure trophy");}
public record Trophy(UUID id,TrophyType type,UUID carrier,UUID catalog,UUID site,UUID quest,UUID source,String name,String origin,Map<UUID,Reward> rewards,TrophyPhase phase,Map<String,String> audit) {
public Trophy {Objects.requireNonNull(id);Objects.requireNonNull(type);Objects.requireNonNull(carrier);Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(quest);Objects.requireNonNull(source);name=validName(name);origin=validName(origin);rewards=Map.copyOf(rewards);Objects.requireNonNull(phase);audit=Map.copyOf(audit);if(name.isBlank()||origin.isBlank()||rewards.isEmpty()||rewards.entrySet().stream().anyMatch(entry->entry.getKey()==null||entry.getValue()==null)||audit.size()>32||audit.entrySet().stream().anyMatch(entry->entry.getKey().length()>100||entry.getValue().length()>262144))throw new IllegalArgumentException("Invalid adventure trophy");}
public Trophy(UUID id,TrophyType type,UUID carrier,UUID catalog,UUID site,UUID quest,UUID source,String name,String origin,Map<UUID,Reward> rewards,TrophyPhase phase){this(id,type,carrier,catalog,site,quest,source,name,origin,rewards,phase,Map.of());}
public Trophy(UUID id,TrophyType type,UUID carrier,UUID catalog,UUID site,UUID quest,UUID source,String name,String origin,Map<UUID,Reward> rewards){this(id,type,carrier,catalog,site,quest,source,name,origin,rewards,TrophyPhase.PREPARED);}
public Reward total(){return rewards.values().stream().reduce(Reward.empty(),Reward::add);}
public Trophy earned(){return phase==TrophyPhase.EARNED?this:new Trophy(id,type,carrier,catalog,site,quest,source,name,origin,rewards,TrophyPhase.EARNED);}
public Trophy earned(){return phase==TrophyPhase.EARNED?this:new Trophy(id,type,carrier,catalog,site,quest,source,name,origin,rewards,TrophyPhase.EARNED,audit);}
/** Operation-local durable phase revision; not a global character or YAML revision. */
public int revision(){return phase==TrophyPhase.PREPARED?1:2;}
}
public record Catch(UUID id,UUID character,UUID quest,UUID site,UUID item,String itemName,BigInteger xp,boolean itemAwarded) {
public Catch {Objects.requireNonNull(id);Objects.requireNonNull(character);Objects.requireNonNull(quest);Objects.requireNonNull(site);Objects.requireNonNull(item);itemName=validName(itemName);Objects.requireNonNull(xp);if(itemName.isBlank()||xp.signum()<0||xp.compareTo(BigInteger.valueOf(Integer.MAX_VALUE))>0)throw new IllegalArgumentException("Invalid fishing catch");}
public record Catch(UUID id,UUID character,UUID quest,UUID site,UUID item,String itemName,BigInteger xp,
boolean itemAwarded,Map<String,String> audit) {
public Catch {
Objects.requireNonNull(id);Objects.requireNonNull(character);Objects.requireNonNull(quest);
Objects.requireNonNull(site);Objects.requireNonNull(item);itemName=validName(itemName);
Objects.requireNonNull(xp);audit=Map.copyOf(audit);
if(itemName.isBlank()||xp.signum()<0||xp.compareTo(BigInteger.valueOf(Integer.MAX_VALUE))>0
||audit.size()>32||audit.entrySet().stream().anyMatch(entry->entry.getKey().length()>64
||entry.getValue().length()>256))throw new IllegalArgumentException("Invalid fishing catch");
}
public Catch(UUID id,UUID character,UUID quest,UUID site,UUID item,String itemName,BigInteger xp,
boolean itemAwarded){this(id,character,quest,site,item,itemName,xp,itemAwarded,Map.of());}
}
public enum SubmissionPhase { PREPARED, COMMITTED }
public record Submission(UUID id,UUID quest,UUID catalog,UUID site,UUID submitter,UUID item,BigInteger quantity,
Map<UUID,Reward> rewards,SubmissionPhase phase) {
public Submission {Objects.requireNonNull(id);Objects.requireNonNull(quest);Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(submitter);Objects.requireNonNull(item);Objects.requireNonNull(quantity);rewards=Map.copyOf(rewards);Objects.requireNonNull(phase);if(quantity.signum()<=0||rewards.isEmpty()||!rewards.containsKey(submitter)||rewards.entrySet().stream().anyMatch(entry->entry.getKey()==null||entry.getValue()==null))throw new IllegalArgumentException("Invalid fishing submission");}
public Submission committed(){return new Submission(id,quest,catalog,site,submitter,item,quantity,rewards,SubmissionPhase.COMMITTED);}
Map<UUID,Reward> rewards,SubmissionPhase phase,Map<String,String> audit) {
public Submission {
Objects.requireNonNull(id);Objects.requireNonNull(quest);Objects.requireNonNull(catalog);
Objects.requireNonNull(site);Objects.requireNonNull(submitter);Objects.requireNonNull(item);
Objects.requireNonNull(quantity);rewards=Map.copyOf(rewards);Objects.requireNonNull(phase);audit=Map.copyOf(audit);
if(quantity.signum()<=0||rewards.isEmpty()||!rewards.containsKey(submitter)
||rewards.entrySet().stream().anyMatch(entry->entry.getKey()==null||entry.getValue()==null)
||audit.size()>16||audit.entrySet().stream().anyMatch(entry->entry.getKey().length()>64
||entry.getValue().length()>8192))throw new IllegalArgumentException("Invalid fishing submission");
}
public Submission(UUID id,UUID quest,UUID catalog,UUID site,UUID submitter,UUID item,BigInteger quantity,
Map<UUID,Reward> rewards,SubmissionPhase phase){
this(id,quest,catalog,site,submitter,item,quantity,rewards,phase,Map.of());
}
public Submission committed(){return new Submission(id,quest,catalog,site,submitter,item,quantity,rewards,SubmissionPhase.COMMITTED,audit);}
}
public record Completion(UUID catalog,UUID site,UUID character) {public Completion {Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(character);}}
public record Allocation(UUID id,UUID submission,UUID catalog,UUID site,UUID character,BigInteger xp,BigInteger coins) {
public Allocation {Objects.requireNonNull(id);Objects.requireNonNull(submission);Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(character);Objects.requireNonNull(xp);Objects.requireNonNull(coins);if(xp.signum()<0||coins.signum()<0||xp.signum()==0&&coins.signum()==0)throw new IllegalArgumentException("Invalid fishing allocation");}
public record Allocation(UUID id,UUID submission,UUID catalog,UUID site,UUID character,BigInteger xp,
BigInteger coins,String sourceKind) {
public Allocation {Objects.requireNonNull(id);Objects.requireNonNull(submission);Objects.requireNonNull(catalog);
Objects.requireNonNull(site);Objects.requireNonNull(character);Objects.requireNonNull(xp);
Objects.requireNonNull(coins);Objects.requireNonNull(sourceKind);
if(sourceKind.isBlank()||xp.signum()<0||coins.signum()<0||xp.signum()==0&&coins.signum()==0)
throw new IllegalArgumentException("Invalid fishing allocation");}
/** Old receipts lacked a durable source type; do not invent a trophy category on reload. */
public Allocation(UUID id,UUID submission,UUID catalog,UUID site,UUID character,BigInteger xp,BigInteger coins){
this(id,submission,catalog,site,character,xp,coins,"unavailable_legacy_allocation");
}
public Reward reward(){return new Reward(xp,coins);}
}
public record Claim(UUID id,UUID character,Set<UUID> allocations,BigInteger xp,BigInteger coins) {
@@ -17,9 +17,10 @@ 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;
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 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 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")+"."));}
@@ -33,7 +34,7 @@ public final class DungeonCommands {
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 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();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 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);}
@@ -32,22 +32,40 @@ 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 record PendingKill(UUID entity,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,defeatedBoss;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 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;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);}));}
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 synchronized boolean done(UUID player){var edit=playerEdits.remove(player);if(edit==null)return false;dungeonEditors.remove(edit.dungeon(),player);return true;}
@@ -124,9 +142,85 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
@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)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;captureBossDefeat(attempt,event.getEntity(),room);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;attempt.entities.remove(event.getEntity().getUniqueId());pendingKills.putIfAbsent(origin.orElseThrow().source(),new PendingKill(event.getEntity().getUniqueId(),dungeonId,roomId,generation,slot));}
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;}
cleanupAttempt(key);
if(boss){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);}}
@@ -36,7 +36,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);});}
@@ -48,10 +50,15 @@ public final class DungeonDirectory implements AutoCloseable,AdventureResetParti
public CompletableFuture<Boolean> clearRun(UUID dungeon,UUID generation){return mutate(state->{var runs=state.runs().stream().filter(v->!(v.dungeon().equals(dungeon)&&v.generation().equals(generation))).toList();if(runs.size()==state.runs().size())return result(state,false);return result(new DungeonState(state.dungeons(),runs),true);});}
public CompletableFuture<DungeonState.Run> run(UUID dungeon,UUID generation){return mutate(state->{requireGeneration(generation);var found=state.runs().stream().filter(v->v.dungeon().equals(dungeon)&&v.generation().equals(generation)).findFirst();if(found.isPresent())return result(state,found.orElseThrow());var value=new DungeonState.Run(dungeon,generation,Set.of(),Set.of(),Set.of(),Set.of(),false);var runs=new ArrayList<>(state.runs());runs.add(value);return result(new DungeonState(state.dungeons(),runs),value);});}
public Optional<DungeonState.Run> runView(UUID dungeon,UUID generation){return snapshot.runs().stream().filter(v->v.dungeon().equals(dungeon)&&v.generation().equals(generation)).findFirst();}
public CompletableFuture<DungeonState.Run> defeatSlot(UUID dungeon,UUID generation,UUID slot){return updateRun(dungeon,generation,run->{var defeated=new HashSet<>(run.defeatedSlots());defeated.add(slot);return new DungeonState.Run(run.dungeon(),run.generation(),defeated,run.clearedRooms(),run.unitClaims(),run.characterClaims(),run.defeated());});}
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));}
public CompletableFuture<DungeonState.Run> defeatSlot(UUID dungeon,UUID generation,UUID slot){return updateRun(dungeon,generation,run->{var defeated=new HashSet<>(run.defeatedSlots());defeated.add(slot);return new DungeonState.Run(run.dungeon(),run.generation(),defeated,run.clearedRooms(),run.unitClaims(),run.characterClaims(),run.defeated(),run.completionAudit(),run.completionPublished());});}
public CompletableFuture<DungeonState.Run> markCleared(UUID dungeon,UUID generation,UUID room){return updateRun(dungeon,generation,run->{var cleared=new HashSet<>(run.clearedRooms());cleared.add(room);return new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),cleared,run.unitClaims(),run.characterClaims(),run.defeated(),run.completionAudit(),run.completionPublished());});}
public CompletableFuture<DungeonState.Run> clearRoom(UUID dungeon,UUID generation,UUID room,UUID quest,Set<UUID> characters){return updateRun(dungeon,generation,run->{var cleared=new HashSet<>(run.clearedRooms());cleared.add(room);var units=new HashSet<>(run.unitClaims());units.add(new DungeonState.UnitClaim(room,quest));var claims=new HashSet<>(run.characterClaims());characters.forEach(value->claims.add(new DungeonState.CharacterClaim(room,value)));return new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),cleared,units,claims,run.defeated(),run.completionAudit(),run.completionPublished());});}
public CompletableFuture<DungeonState.Run> defeat(UUID dungeon,UUID generation){return defeat(dungeon,generation,Map.of());}
public CompletableFuture<DungeonState.Run> defeat(UUID dungeon,UUID generation,Map<String,String> audit){var frozen=Map.copyOf(audit);return updateRun(dungeon,generation,run->run.defeated()?run:new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),run.clearedRooms(),run.unitClaims(),run.characterClaims(),true,frozen));}
/** Acknowledgement is serialized with the run and bound to its committed native boss. */
public CompletableFuture<Boolean> acknowledgeCompletion(UUID dungeon,UUID generation,UUID boss){return mutate(state->{var found=state.runs().stream().filter(v->v.dungeon().equals(dungeon)&&v.generation().equals(generation)).findFirst();if(found.isEmpty())return result(state,false);var run=found.orElseThrow();if(!run.defeated()||!boss.toString().equals(run.completionAudit().get("boss_id")))return result(state,false);if(run.completionPublished())return result(state,true);var published=new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),run.clearedRooms(),run.unitClaims(),run.characterClaims(),true,run.completionAudit(),true);var runs=state.runs().stream().map(value->value==run?published:value).toList();return result(new DungeonState(state.dungeons(),runs),true);});}
/** Reconcile prior exported proof before replaying a missing completion, on this I/O worker. */
public CompletableFuture<List<DungeonState.Run>> pendingCompletions(java.util.function.Predicate<DungeonState.Run> exported){return mutate(state->{var runs=new ArrayList<DungeonState.Run>();var pending=new ArrayList<DungeonState.Run>();boolean changed=false;for(var run:state.runs()){if(!run.defeated()||run.completionPublished()||run.completionAudit().isEmpty()){runs.add(run);continue;}if(exported.test(run)){runs.add(new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),run.clearedRooms(),run.unitClaims(),run.characterClaims(),true,run.completionAudit(),true));changed=true;}else{runs.add(run);pending.add(run);}}return result(changed?new DungeonState(state.dungeons(),runs):state,List.copyOf(pending));});}
@Override public CompletionStage<Void> reset(AdventureQuestState.Reset reset){currentGeneration=reset.generation();return mutate(state->result(new DungeonState(state.dungeons(),List.of()),null));}
private <T>CompletableFuture<T> updateRoom(UUID id,UUID roomId,java.util.function.UnaryOperator<DungeonState.Room> operation,T result){return mutate(state->{var dungeon=editable(state,id);var found=dungeon.rooms().stream().filter(v->v.id().equals(roomId)).findFirst().orElseThrow(()->new IllegalArgumentException("Room not found"));var updated=operation.apply(found);var rooms=dungeon.rooms().stream().map(v->v.id().equals(roomId)?updated:v).toList();return result(replace(state,definition(dungeon,rooms,dungeon.entrance(),false)),result);});}
private CompletableFuture<DungeonState.Run> updateRun(UUID dungeon,UUID generation,java.util.function.UnaryOperator<DungeonState.Run> operation){return mutate(state->{requireGeneration(generation);var existing=state.runs().stream().filter(v->v.dungeon().equals(dungeon)&&v.generation().equals(generation)).findFirst().orElse(new DungeonState.Run(dungeon,generation,Set.of(),Set.of(),Set.of(),Set.of(),false));var updated=operation.apply(existing);var runs=state.runs().stream().filter(v->!(v.dungeon().equals(dungeon)&&v.generation().equals(generation))).collect(java.util.stream.Collectors.toCollection(ArrayList::new));runs.add(updated);return result(new DungeonState(state.dungeons(),runs),updated);});}
@@ -56,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,105 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
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.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.command.BlockCommandSender;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
/** A persisted exact-character grant is the only authorization for this event, not command feedback. */
public final class AdminExperienceEventBridge implements AutoCloseable {
public record Intent(UUID operation, String actorKind, EventInput.Context actor,
UUID targetPlayer, UUID targetSession, UUID targetCharacter,
String targetName, EventInput.Location targetLocation) { }
private final Clock clock;
private final FeatureEventOutbox outbox;
public AdminExperienceEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock, Path data, Consumer<Throwable> failures) {
this.clock = Objects.requireNonNull(clock);
outbox = new FeatureEventOutbox(data, "admin-experience-event-intents",
java.util.Set.of("experience.granted"), Objects.requireNonNull(sink), failures);
}
public Intent capture(CommandSender sender, EquipmentSessions.Active target, CharacterRecord record,
Player targetPlayer) {
var player = sender instanceof Player value ? value : null;
var actorKind = player != null ? "player" : sender instanceof BlockCommandSender ? "command_block" : "console";
EventInput.Context actor = null;
if (player != null) {
try { actor = new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null,
location(player)); } catch (RuntimeException unavailable) { /* Keep the policy operation; location is optional. */ }
}
return new Intent(UUID.randomUUID(), actorKind, actor, target.player(), target.session(), record.id(),
record.name(), targetPlayer == null ? null : location(targetPlayer));
}
public void granted(Intent intent, CharacterRecord before, CharacterRecord after, BigInteger requested,
int levelsGained, boolean feedbackDelivered) {
if (intent == null) return;
try {
var values = new LinkedHashMap<String, Object>();
values.put("operation_id", intent.operation().toString());
values.put("actor_kind", intent.actorKind());
values.put("target_player_id", intent.targetPlayer().toString());
values.put("target_session_id", intent.targetSession().toString());
values.put("target_character_id", intent.targetCharacter().toString());
values.put("target_character_name", intent.targetName());
if (intent.actor() == null || intent.actor().location() == null)
values.put("admin_location_status", intent.actor() == null ? "not_applicable" : "unavailable");
if (intent.targetLocation() != null) values.put("target_acceptance_location", locationData(intent.targetLocation()));
else values.put("target_location_status", "unavailable");
values.put("requested_xp", requested.toString());
values.put("applied_xp", after.experience().subtract(before.experience()).toString());
values.put("before_xp", before.experience().toString());
values.put("after_xp", after.experience().toString());
values.put("before_level", Integer.toString(before.level()));
values.put("after_level", Integer.toString(after.level()));
values.put("before_unspent_points", before.unspentPoints().toString());
values.put("after_unspent_points", after.unspentPoints().toString());
values.put("before_revision", before.revision().toString());
values.put("after_revision", after.revision().toString());
values.put("levels_gained", Integer.toString(levelsGained));
values.put("cap_clamped", after.experience().subtract(before.experience()).compareTo(requested) < 0);
values.put("same_session_feedback_delivered", feedbackDelivered);
values.put("outcome", "durably_applied");
var event = new EventInput(id(intent.operation()), intent.operation(), clock.instant(),
"experience.granted", intent.actor(), values);
outbox.accepted(event);
} catch (RuntimeException ignored) { /* Audit failure cannot replay, redirect or undo a grant. */ }
}
private EventInput.Location location(Player player) {
try {
Location value = player.getLocation();
if (value == null || value.getWorld() == null) return null;
var world = value.getWorld();
return new EventInput.Location(world.getUID(), world.getName(), value.getX(), value.getY(),
value.getZ(), clock.instant(), "exact");
} catch (RuntimeException unavailable) { return null; }
}
private static Map<String, Object> locationData(EventInput.Location place) {
return Map.of("world_id", place.worldId().toString(), "world_name", place.worldName(),
"x", place.x(), "y", place.y(), "z", place.z(), "captured_at", place.capturedAt().toString(),
"quality", place.quality());
}
private static UUID id(UUID operation) {
return UUID.nameUUIDFromBytes(("fantasystick:admin-xp:" + operation).getBytes(StandardCharsets.UTF_8));
}
public void replay() { outbox.replay(); }
public CompletionStage<Void> drained() { return outbox.drained(); }
@Override public void close() { outbox.close(); }
}
@@ -21,8 +21,16 @@ public final class CombatRewardEventBridge {
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);}
@@ -30,7 +38,7 @@ public final class CombatRewardEventBridge {
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)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");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());try{sink.accept(new EventInput(id(operation),operation,clock.instant(),"combat.reward_resolved",context,Map.copyOf(data)));}catch(RuntimeException ignored){}
}
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());}
@@ -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,
@@ -134,6 +135,8 @@ final class FeatureEventOutbox implements AutoCloseable {
else if (value instanceof java.util.List<?> list && list.stream().allMatch(item -> item instanceof String text
&& (text.equals("hotbar") || text.equals("armor"))))
fields.setProperty(key, "l" + String.join(",", list.stream().map(String.class::cast).toList()));
else if (value instanceof java.util.List<?> list)
fields.setProperty(key, "j" + new com.google.gson.Gson().toJson(list));
else throw new IllegalArgumentException("Unsupported feature intent value");
}
@@ -188,6 +191,11 @@ final class FeatureEventOutbox implements AutoCloseable {
throw new IOException("Invalid slot class");
yield list;
}
case 'j' -> {
var parsed = com.google.gson.JsonParser.parseString(value.substring(1));
if (!parsed.isJsonArray()) throw new IOException("Invalid event collection");
yield decodeCollection(parsed, 0);
}
default -> throw new IOException("Invalid feature event value type");
};
var name = key.substring(5);
@@ -207,13 +215,43 @@ final class FeatureEventOutbox implements AutoCloseable {
} catch (RuntimeException failure) { throw new IOException("Invalid character event intent", failure); }
}
private static Object decodeCollection(com.google.gson.JsonElement value, int depth) throws IOException {
if (depth > 8 || value.isJsonNull()) throw new IOException("Unsafe event collection");
if (value.isJsonArray()) {
var list = new java.util.ArrayList<>();
for (var item : value.getAsJsonArray()) list.add(decodeCollection(item, depth + 1));
return java.util.List.copyOf(list);
}
if (value.isJsonObject()) {
var map = new LinkedHashMap<String, Object>();
for (var entry : value.getAsJsonObject().entrySet())
map.put(entry.getKey(), decodeCollection(entry.getValue(), depth + 1));
return java.util.Map.copyOf(map);
}
var primitive = value.getAsJsonPrimitive();
if (primitive.isBoolean()) return primitive.getAsBoolean();
if (primitive.isString()) return primitive.getAsString();
if (primitive.isNumber()) {
var number = primitive.getAsDouble();
if (!Double.isFinite(number)) throw new IOException("Non-finite event collection number");
return number;
}
throw new IOException("Unsupported event collection value");
}
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 {
@@ -0,0 +1,291 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.adventure.AdventureQuestState;
import games.dmg.fantasystick.adventure.FishingState;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
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.BlockCommandSender;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
/** Acknowledged fishing outcomes and native fishing attempts, independent of chat feedback. */
public final class FishingEventBridge implements AutoCloseable {
public record Actor(String kind, EventInput.Context context) { }
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",
"dungeon.reward_changed", "dungeon.completed");
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final Clock clock;
private final EquipmentSessions sessions;
private final FeatureEventOutbox outbox;
public FishingEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink, Clock clock,
Path data, EquipmentSessions sessions, Consumer<Throwable> failures) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
this.sessions = Objects.requireNonNull(sessions);
outbox = new FeatureEventOutbox(data, "fishing-event-intents", DURABLE, sink, failures);
}
public Actor actor(CommandSender sender) {
try {
if (!(sender instanceof Player player))
return new Actor(sender instanceof BlockCommandSender ? "command_block" : "console", null);
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
return new Actor("player", new EventInput.Context(player.getUniqueId(), player.getName(),
record == null ? null : record.id(), record == null ? null : record.name(),
record == null ? null : active.session(), safeLocation(player)));
} catch (RuntimeException unavailable) { return null; }
}
public void site(String type, UUID operation, Actor actor, AdventureQuestState.Site site,
Map<String, Object> changed) {
if (actor == null || site == null || site.fishing() == null) return;
var terms = site.fishing();
var values = new LinkedHashMap<String, Object>();
values.put("actor_kind", actor.kind());
values.put("site_kind", "fishing");
values.put("site_id", site.id().toString());
values.put("site_name", site.name());
values.put("item_id", terms.itemId().toString());
values.put("item_name", terms.itemName());
values.put("required", terms.required().toString());
values.put("chance_percent", terms.chancePercent().toPlainString());
values.put("reward_xp", terms.rewardXp().toString());
values.put("reward_coins", terms.rewardCoins().toString());
values.put("radius", terms.radius());
values.put("mode", site.participationMode().value());
values.put("world_id", site.world().toString());
values.put("x", site.x()); values.put("y", site.y()); values.put("z", site.z());
values.put("changed", changed);
durable(type, operation, actor, values);
}
public void accepted(UUID operation, Actor actor, AdventureQuestState.Quest quest) {
if (actor == null || quest.fishing() == null) return;
var terms = quest.fishing();
var values = new LinkedHashMap<String, Object>();
values.put("site_kind", "fishing"); values.put("quest_id", quest.id().toString());
values.put("catalog_id", quest.catalog().toString()); values.put("site_id", quest.site().toString());
values.put("unit_kind", quest.unitType().name().toLowerCase(java.util.Locale.ROOT));
values.put("unit_id", quest.unit().toString());
values.put("item_id", terms.itemId().toString()); values.put("item_name", terms.itemName());
values.put("required", terms.required().toString());
values.put("chance_percent", terms.chancePercent().toPlainString());
values.put("radius", terms.radius());
values.put("reward_xp", terms.rewardXp().toString());
values.put("reward_coins", terms.rewardCoins().toString());
values.put("roster", quest.snapshot().stream().sorted().map(UUID::toString).toList());
durable("adventure.quest.accepted", operation, actor, values);
}
public void configRead(Actor actor, String key, String effective, String defaultValue) {
if (actor == null) return;
var data = config(actor, key, defaultValue);
data.put("effective", effective); data.put("outcome", "acknowledged_read");
transientEvent("config.read", UUID.randomUUID(), actor, data);
}
public void configChanged(Actor actor, UUID operation, String key, String before,
String effective, String defaultValue) {
if (actor == null) return;
var data = config(actor, key, defaultValue);
data.put("before", before); data.put("after", effective); data.put("effective", effective);
data.put("outcome", "acknowledged_future_casts");
durable("config.changed", operation, actor, data);
}
private static LinkedHashMap<String, Object> config(Actor actor, String key, String defaultValue) {
var data = new LinkedHashMap<String, Object>();
data.put("actor_kind", actor.kind()); data.put("key", key); data.put("default", defaultValue);
data.put("application_scope", "future_fishing_casts");
if (actor.context() == null) data.put("location_status", "not_applicable");
else if (actor.context().location() == null) data.put("location_status", "unavailable");
return data;
}
public Actor actorFromCatch(FishingState.Catch catchReceipt) {
var audit = catchReceipt.audit();
try {
return new Actor("player", new EventInput.Context(UUID.fromString(audit.get("player_id")),
audit.get("player_name"), catchReceipt.character(), audit.get("character_name"),
UUID.fromString(audit.get("session_id")), null));
} catch (RuntimeException unavailable) { return new Actor("system", null); }
}
/** 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.DUNGEON)
|| receipt.phase() != FishingState.TrophyPhase.EARNED) return;
var audit = receipt.audit();
var evidence = new LinkedHashMap<String, Object>();
evidence.put("receipt_id", receipt.id().toString());
evidence.put("trophy_type", receipt.type().name().toLowerCase(java.util.Locale.ROOT) + "_trophy");
evidence.put("trophy_name", receipt.name());
evidence.put("source_name", receipt.origin());
evidence.put("carrier_character_id", receipt.carrier().toString());
evidence.put("carrier_is_beneficiary", receipt.rewards().containsKey(receipt.carrier()));
evidence.put("quest_id", receipt.quest().toString());
evidence.put("catalog_id", receipt.catalog().toString());
evidence.put("site_id", receipt.site().toString());
evidence.put("encounter_id", receipt.source().toString());
if (receipt.type() == FishingState.TrophyType.CHAMPION) {
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 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"));
var roster = audit.get("accepted_roster");
evidence.put("accepted_roster", roster == null || roster.isEmpty()
? List.of() : List.of(roster.split(",")));
evidence.put("accepted_roster_status", roster == null ? "unavailable_legacy_capture" : "captured");
evidence.put("reward_xp_pool", receipt.total().xp().toString());
evidence.put("reward_coin_pool", receipt.total().coins().toString());
evidence.put("phase", "earned");
evidence.put("receipt_revision", Integer.toString(receipt.revision()));
var beneficiaries = new java.util.ArrayList<Map<String, String>>();
receipt.rewards().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry -> {
var share = new LinkedHashMap<String, String>();
share.put("character_id", entry.getKey().toString());
share.put("allocated_xp", entry.getValue().xp().toString());
share.put("allocated_coins", entry.getValue().coins().toString());
share.put("allocation_status", entry.getValue().xp().signum() == 0
&& entry.getValue().coins().signum() == 0 ? "zero_share_completion" : "positive");
beneficiaries.add(share);
});
evidence.put("beneficiaries", beneficiaries);
durable("adventure.trophy_earned", receipt.id(), new Actor("system", null), evidence);
}
public static Map<String, Object> savedArena(Map<String, String> audit) {
try {
var shape = audit.get("arena_shape");
int x = Integer.parseInt(audit.get("arena_x_blocks"));
int z = Integer.parseInt(audit.get("arena_z_blocks"));
var expected = "circle".equals(shape)
? games.dmg.fantasystick.adventure.AdventureQuestState.Arena.circle(x)
: "rectangle".equals(shape)
? games.dmg.fantasystick.adventure.AdventureQuestState.Arena.rectangle(x, z)
: null;
if (expected == null || expected.zBlocks() != z) return Map.of("status", "unavailable");
return Map.of("shape", shape, "x_blocks", x, "z_blocks", z);
} catch (RuntimeException unavailable) { return Map.of("status", "unavailable"); }
}
public static Map<String, Object> savedDefeatLocation(Map<String, String> audit) {
try {
if (!audit.containsKey("defeat_world_id")) return Map.of("status", "unavailable");
var world = UUID.fromString(audit.get("defeat_world_id"));
double x = Double.parseDouble(audit.get("defeat_x"));
double y = Double.parseDouble(audit.get("defeat_y"));
double z = Double.parseDouble(audit.get("defeat_z"));
if (!Double.isFinite(x) || !Double.isFinite(y) || !Double.isFinite(z))
return Map.of("status", "unavailable");
return Map.of("world_id", world.toString(),
"world_name", audit.getOrDefault("defeat_world_name", "unavailable"),
"x", x, "y", y, "z", z);
} catch (RuntimeException unavailable) { return Map.of("status", "unavailable"); }
}
public void durable(String type, UUID operation, Actor actor, Map<String, Object> data) {
if (actor == null) return;
try {
String primary = data.get("beneficiaries") instanceof List<?> ? "beneficiaries"
: data.get("sources") instanceof List<?> ? "sources"
: data.get("roster") instanceof List<?> ? "roster" : null;
String secondary = data.get("accepted_roster") instanceof List<?> roster && !roster.isEmpty()
? "accepted_roster" : null;
if (primary == null) { primary = secondary; secondary = null; }
if ((primary == null || ((List<?>)data.get(primary)).size() <= 20)
&& (secondary == null || ((List<?>)data.get(secondary)).size() <= 20)) {
outbox.accepted(input(type, operation, actor, data));
return;
}
var first = (List<?>)data.get(primary);
int firstParts = Math.max(1, (first.size() + 19) / 20);
int secondParts = secondary == null ? 0 : (((List<?>)data.get(secondary)).size() + 19) / 20;
int total = firstParts + secondParts;
for (int index = 0; index < total; index++) {
String collection = index < firstParts ? primary : secondary;
int collectionPart = index < firstParts ? index : index - firstParts;
var entries = (List<?>)data.get(collection);
int from = collectionPart * 20, to = Math.min(from + 20, entries.size());
var part = new LinkedHashMap<String, Object>(data);
for (var key : List.of("beneficiaries", "sources", "roster", "accepted_roster"))
if (data.get(key) instanceof List<?>) part.put(key, List.of());
part.put(collection, entries.subList(from, to));
if (collection.equals("beneficiaries") && data.get("roster") instanceof List<?> roster
&& roster.size() == entries.size()) part.put("roster", roster.subList(from, to));
part.put("linked_collection", collection);
part.put("collection_total", entries.size());
part.put("collection_part_index", collectionPart);
part.put("part_index", index);
part.put("part_count", total);
outbox.accepted(input(type, operation, actor, part, index));
}
} catch (RuntimeException ignored) { /* Audit availability cannot change acknowledged fishing. */ }
}
public void transientEvent(String type, UUID operation, Actor actor, Map<String, Object> data) {
if (actor == null) return;
try { sink.apply(input(type, operation, actor, data)); }
catch (RuntimeException ignored) { /* Observation cannot change native fishing. */ }
}
private EventInput input(String type, UUID operation, Actor actor, Map<String, Object> data) {
return input(type, operation, actor, data, -1);
}
private EventInput input(String type, UUID operation, Actor actor, Map<String, Object> data, int part) {
var values = new LinkedHashMap<String, Object>(data);
values.put("operation_id", operation.toString());
var identity = "fantasystick:fishing:" + operation + ':' + type + (part < 0 ? "" : ":part:" + part);
return new EventInput(UUID.nameUUIDFromBytes(identity.getBytes(StandardCharsets.UTF_8)), operation,
clock.instant(), type, actor.context(), values);
}
private EventInput.Location safeLocation(Player player) {
try { return location(player.getLocation()); }
catch (RuntimeException unavailable) { return null; }
}
private EventInput.Location location(Location value) {
if (value == null || value.getWorld() == null) return null;
var world = value.getWorld();
if (world.getName() == null) return null;
return new EventInput.Location(world.getUID(), world.getName(), value.getX(), value.getY(), value.getZ(),
clock.instant(), "exact");
}
public void replay() { outbox.replay(); }
public CompletionStage<Void> drained() { return outbox.drained(); }
@Override public void close() { outbox.close(); }
}
@@ -0,0 +1,109 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.PartySettings;
import games.dmg.fantasystick.session.EquipmentSessions;
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.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.command.BlockCommandSender;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
/** Observes effective party join capacity without reading or mutating protected rosters. */
public final class PartyCapacityEventBridge implements AutoCloseable {
public record Actor(String kind, EventInput.Context context) { }
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final Clock clock;
private final EquipmentSessions sessions;
private final FeatureEventOutbox outbox;
public PartyCapacityEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock, Path data, EquipmentSessions sessions,
Consumer<Throwable> failures) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
this.sessions = Objects.requireNonNull(sessions);
outbox = new FeatureEventOutbox(data, "party-capacity-event-intents",
java.util.Set.of("config.changed"), sink, failures);
}
public Actor actor(CommandSender sender) {
try {
var player = sender instanceof Player value ? value : null;
var kind = player != null ? "player" : sender instanceof BlockCommandSender ? "command_block" : "console";
var active = player == null ? null : sessions.active(player.getUniqueId()).orElse(null);
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
return new Actor(kind, player == null ? null : new EventInput.Context(player.getUniqueId(),
player.getName(), record == null ? null : record.id(),
record == null ? null : record.name(), active == null ? null : active.session(),
safeLocation(player)));
} catch (RuntimeException unavailable) { return null; }
}
public void read(Actor actor, String effective) {
if (actor == null) return;
var values = common(actor);
values.put("effective", effective);
values.put("outcome", "acknowledged_read");
var operation = UUID.randomUUID();
try { sink.apply(new EventInput(id(operation, "config.read"), operation, clock.instant(),
"config.read", actor.context(), values)); }
catch (RuntimeException ignored) { /* Observation cannot change party capacity. */ }
}
public void changed(Actor actor, UUID operation, String before, String effective,
PartySettings.Update saved) {
if (actor == null) return;
try {
var values = common(actor);
values.put("operation_id", operation.toString());
values.put("policy_revision", Long.toString(saved.sequence()));
values.put("before", before);
values.put("after", saved.value());
values.put("effective", effective);
values.put("existing_rosters_grandfathered", true);
values.put("outcome", "acknowledged");
outbox.accepted(new EventInput(id(operation, "config.changed"), operation, clock.instant(),
"config.changed", actor.context(), values));
} catch (RuntimeException ignored) { /* Audit failure cannot roll back a published join limit. */ }
}
private static Map<String, Object> common(Actor actor) {
var values = new LinkedHashMap<String, Object>();
values.put("key", PartySettings.KEY);
values.put("default", PartySettings.DEFAULT);
values.put("actor_kind", actor.kind());
values.put("application_scope", "future_join_checks");
if (actor.context() == null) values.put("location_status", "not_applicable");
else if (actor.context().location() == null) values.put("location_status", "unavailable");
return values;
}
private EventInput.Location safeLocation(Player player) {
try {
Location location = player.getLocation();
if (location == null || location.getWorld() == null) return null;
var world = location.getWorld();
return new EventInput.Location(world.getUID(), world.getName(), location.getX(),
location.getY(), location.getZ(), clock.instant(), "exact");
} catch (RuntimeException unavailable) { return null; }
}
private static UUID id(UUID operation, String type) {
return UUID.nameUUIDFromBytes(("fantasystick:party-capacity:" + operation + ":" + type)
.getBytes(StandardCharsets.UTF_8));
}
public void replay() { outbox.replay(); }
public CompletionStage<Void> drained() { return outbox.drained(); }
@Override public void close() { outbox.close(); }
}
@@ -1,35 +1,191 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings;
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.command.BlockCommandSender;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
/** One authoritative out-of-combat healing publication with bounded passive attribution. */
public final class RecoveryEventBridge {
public record Action(UUID actionId,EventInput.Context context,BigInteger expectedRevision,
BigDecimal healthBefore,BigDecimal proposed,BigInteger configuredBase,
BigInteger configuredQuickerRegeneration){
public Action{Objects.requireNonNull(actionId);Objects.requireNonNull(context);Objects.requireNonNull(expectedRevision);Objects.requireNonNull(healthBefore);Objects.requireNonNull(proposed);Objects.requireNonNull(configuredBase);Objects.requireNonNull(configuredQuickerRegeneration);}
/** Recovery observation is never itself a heal; only native and persisted publication owns combat.healed. */
public final class RecoveryEventBridge implements AutoCloseable {
public record Action(UUID actionId, EventInput.Context context, BigInteger expectedRevision,
BigDecimal healthBefore, BigDecimal proposed, BigInteger configuredBase,
BigInteger configuredQuickerRegeneration, BigInteger configuredQuickerRegenerationII,
Instant lastActivity, Instant eligibleAt,
BigDecimal maximumHealth, long pulseOrdinal) { }
public record Actor(String kind, EventInput.Context context) { }
private final Consumer<EventInput> sink;
private final Clock clock;
private final FeatureEventOutbox outbox;
private final EquipmentSessions sessions;
/** Native/unit-only observer; does not require an export outbox. */
public RecoveryEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink = Objects.requireNonNull(sink); this.clock = Objects.requireNonNull(clock);
outbox = null; sessions = null;
}
private final Consumer<EventInput> sink;private final Clock clock;
public RecoveryEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
public Action begin(Player player,EquipmentSessions.Active active,CharacterRecord record,double current,double proposed,BigInteger base,BigInteger bonus){var action=new Action(UUID.randomUUID(),context(player,active,record.name()),record.revision(),BigDecimal.valueOf(current),BigDecimal.valueOf(proposed),base,bonus);emit("action.observed",action,"observed",null,null,null,null,null);return action;}
public void refused(Action action,String reason){emit("action.resolved",action,"refused",reason,null,null,null,null);}
public void failed(Action action,String reason){emit("action.resolved",action,"failed",reason,null,null,null,null);}
public void healed(Action action,BigDecimal healthAfter,BigDecimal acceptedTotal,BigDecimal baseContribution,BigDecimal passiveContribution){emit("action.resolved",action,"accepted","acknowledged",healthAfter,acceptedTotal,baseContribution,passiveContribution);emit("combat.healed",action,"accepted","acknowledged",healthAfter,acceptedTotal,baseContribution,passiveContribution);}
private void emit(String type,Action action,String outcome,String reason,BigDecimal healthAfter,BigDecimal acceptedTotal,BigDecimal baseContribution,BigDecimal passiveContribution){var data=new LinkedHashMap<String,Object>();data.put("action_kind","out_of_combat_recovery");data.put("recovery_kind","out_of_combat");data.put("cause","recovery_timer");data.put("expected_revision",action.expectedRevision().toString());data.put("health_before",action.healthBefore().toPlainString());data.put("proposed_total",action.proposed().toPlainString());data.put("configured_base",action.configuredBase().toString());data.put("configured_quicker_regeneration_i",action.configuredQuickerRegeneration().toString());data.put("outcome",outcome);if(action.context().location()==null)data.put("location_status","unavailable");if(healthAfter!=null)data.put("health_after",healthAfter.toPlainString());if(acceptedTotal!=null){data.put("accepted_total",acceptedTotal.toPlainString());data.put("base_contribution",baseContribution.toPlainString());if(passiveContribution.signum()>0)data.put("passive_contributions",Map.of("quicker_regeneration_i",passiveContribution.toPlainString()));data.put("native_publication","acknowledged");}if(reason!=null)data.put(type.equals("combat.healed")?"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){}
/** Registered plugin path retains acknowledged pulses and policy changes for writer restart. */
public RecoveryEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink, Clock clock,
Path data, EquipmentSessions sessions, Consumer<Throwable> failures) {
this.sink = event -> sink.apply(event);
this.clock = Objects.requireNonNull(clock);
this.sessions = Objects.requireNonNull(sessions);
outbox = new FeatureEventOutbox(data, "out-of-combat-recovery-event-intents",
java.util.Set.of("combat.healed", "config.changed"), sink, failures);
}
private EventInput.Context context(Player player,EquipmentSessions.Active active,String name){EventInput.Location captured;try{captured=location(player.getLocation());}catch(RuntimeException unavailable){captured=null;}return new EventInput.Context(player.getUniqueId(),player.getName(),active.character(),name,active.session(),captured);}
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");}
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:recovery:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
public Actor actor(CommandSender sender) {
if (sender instanceof Player player) {
try {
var active = sessions.active(player.getUniqueId()).orElse(null);
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
return new Actor("player", new EventInput.Context(player.getUniqueId(), player.getName(),
record == null ? null : record.id(), record == null ? null : record.name(),
record == null ? null : active.session(), location(player)));
} catch (RuntimeException unavailable) { return null; }
}
return new Actor(sender instanceof BlockCommandSender ? "command_block" : "console", null);
}
public void configRead(Actor actor, String key, String effective) {
if (actor == null) return;
var values = config(actor, key);
values.put("effective", effective);
values.put("outcome", "acknowledged_read");
var operation = UUID.randomUUID();
try { sink.accept(new EventInput(id(operation, "config.read"), operation, clock.instant(),
"config.read", actor.context(), values)); }
catch (RuntimeException ignored) { /* Audit reads cannot change recovery. */ }
}
public void configChanged(Actor actor, UUID operation, String key, String before, String effective,
OutOfCombatRecoverySettings.Update saved) {
if (actor == null || outbox == null) return;
try {
var values = config(actor, key);
values.put("operation_id", operation.toString());
values.put("policy_revision", Long.toString(saved.sequence()));
values.put("before", before);
values.put("after", saved.value());
values.put("effective", effective);
values.put("outcome", "acknowledged_future_evaluations");
outbox.accepted(new EventInput(id(operation, "config.changed"), operation, clock.instant(),
"config.changed", actor.context(), values));
} catch (RuntimeException ignored) { /* An audit cannot reconfigure or reset the damage clock. */ }
}
private static Map<String, Object> config(Actor actor, String key) {
var values = new LinkedHashMap<String, Object>();
values.put("key", key);
values.put("default", OutOfCombatRecoverySettings.defaultValue(key));
values.put("application_scope", "future_recovery_evaluations");
values.put("activity_timestamp_retained", true);
values.put("actor_kind", actor.kind());
if (actor.context() == null) values.put("location_status", "not_applicable");
else if (actor.context().location() == null) values.put("location_status", "unavailable");
return values;
}
public Action begin(Player player, EquipmentSessions.Active active, CharacterRecord record,
double current, double proposed, BigInteger base, BigInteger bonus, BigInteger bonusII,
Instant lastActivity, Instant eligibleAt, double maximum, long pulseOrdinal) {
var action = new Action(UUID.randomUUID(), context(player, active, record.name()), record.revision(),
BigDecimal.valueOf(current), BigDecimal.valueOf(proposed), base, bonus, bonusII, lastActivity,
eligibleAt, BigDecimal.valueOf(maximum), pulseOrdinal);
emit("action.observed", action, "observed", null, null, null, null, null, null);
return action;
}
public void refused(Action action, String reason) {
emit("action.resolved", action, "refused", reason, null, null, null, null, null);
}
public void failed(Action action, String reason) {
emit("action.resolved", action, "failed", reason, null, null, null, null, null);
}
public void healed(Action action, BigDecimal healthAfter, BigDecimal acceptedTotal,
BigDecimal baseContribution, BigDecimal quickerContribution, BigDecimal quickerIIContribution) {
emit("action.resolved", action, "accepted", "acknowledged", healthAfter, acceptedTotal,
baseContribution, quickerContribution, quickerIIContribution);
emit("combat.healed", action, "accepted", "acknowledged", healthAfter, acceptedTotal,
baseContribution, quickerContribution, quickerIIContribution);
}
private void emit(String type, Action action, String outcome, String reason, BigDecimal healthAfter,
BigDecimal acceptedTotal, BigDecimal baseContribution, BigDecimal quickerContribution,
BigDecimal quickerIIContribution) {
var data = new LinkedHashMap<String, Object>();
data.put("action_kind", "out_of_combat_recovery");
data.put("recovery_kind", "out_of_combat");
data.put("healing_source", "out_of_combat_recovery");
data.put("cause", "recovery_timer");
data.put("pulse_application_id", action.actionId().toString());
data.put("pulse_ordinal", Long.toString(action.pulseOrdinal()));
data.put("last_activity_at", action.lastActivity().toString());
data.put("eligible_at", action.eligibleAt().toString());
data.put("maximum_health", action.maximumHealth().toPlainString());
data.put("expected_revision", action.expectedRevision().toString());
data.put("health_before", action.healthBefore().toPlainString());
data.put("proposed_total", action.proposed().toPlainString());
data.put("configured_base", action.configuredBase().toString());
data.put("configured_quicker_regeneration_i", action.configuredQuickerRegeneration().toString());
data.put("configured_quicker_regeneration_ii", action.configuredQuickerRegenerationII().toString());
data.put("outcome", outcome);
if (action.context().location() == null) data.put("location_status", "unavailable");
if (healthAfter != null) data.put("health_after", healthAfter.toPlainString());
if (acceptedTotal != null) {
data.put("accepted_total", acceptedTotal.toPlainString());
data.put("base_contribution", baseContribution.toPlainString());
var passives = new LinkedHashMap<String, String>();
if (quickerContribution.signum() > 0) passives.put("quicker_regeneration_i", quickerContribution.toPlainString());
if (quickerIIContribution.signum() > 0) passives.put("quicker_regeneration_ii", quickerIIContribution.toPlainString());
if (!passives.isEmpty()) data.put("passive_contributions", passives);
data.put("native_publication", "acknowledged");
}
if (reason != null) data.put(type.equals("combat.healed") ? "native_boundary" : "reason", reason);
try {
var event = new EventInput(id(action.actionId(), type), action.actionId(), clock.instant(),
type, action.context(), data);
if (type.equals("combat.healed") && outbox != null) outbox.accepted(event);
else sink.accept(event);
} catch (RuntimeException ignored) { /* No audit failure can heal or replay a pulse. */ }
}
private EventInput.Context context(Player player, EquipmentSessions.Active active, String name) {
return new EventInput.Context(player.getUniqueId(), player.getName(), active.character(), name,
active.session(), location(player));
}
private EventInput.Location location(Player player) {
try {
Location 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 action, String type) {
return UUID.nameUUIDFromBytes(("fantasystick:recovery:" + action + ":" + 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(); }
}
@@ -0,0 +1,127 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.RoleplaySettings;
import games.dmg.fantasystick.session.EquipmentSessions;
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.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.command.BlockCommandSender;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
/** A policy publication requests ordinary exits; it never attests their later receipt completion. */
public final class RoleplayAvailabilityEventBridge implements AutoCloseable {
public record Actor(String kind, EventInput.Context context) { }
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final Clock clock;
private final EquipmentSessions sessions;
private final FeatureEventOutbox outbox;
public RoleplayAvailabilityEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock, Path data, EquipmentSessions sessions,
Consumer<Throwable> failures) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
this.sessions = Objects.requireNonNull(sessions);
outbox = new FeatureEventOutbox(data, "roleplay-availability-event-intents",
java.util.Set.of("config.changed", "game.availability_changed"), sink, failures);
}
public Actor actor(CommandSender sender) {
try {
var player = sender instanceof Player value ? value : null;
var kind = player != null ? "player" : sender instanceof BlockCommandSender ? "command_block" : "console";
var active = player == null ? null : sessions.active(player.getUniqueId()).orElse(null);
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
return new Actor(kind, player == null ? null : new EventInput.Context(player.getUniqueId(),
player.getName(), record == null ? null : record.id(),
record == null ? null : record.name(), record == null ? null : active.session(),
safeLocation(player)));
} catch (RuntimeException unavailable) { return null; }
}
public void read(Actor actor, boolean disabled) {
if (actor == null) return;
var values = common(actor);
values.put("effective", disabled);
values.put("available", !disabled);
values.put("outcome", "acknowledged_read");
var operation = UUID.randomUUID();
try { sink.apply(new EventInput(id(operation, "config.read"), operation, clock.instant(),
"config.read", actor.context(), values)); }
catch (RuntimeException ignored) { /* An observation cannot change availability. */ }
}
public void changed(Actor actor, UUID operation, boolean before, RoleplaySettings.Update saved,
EquipmentSessions.DisableSummary exits) {
if (actor == null) return;
try {
var config = common(actor);
config.put("operation_id", operation.toString());
config.put("policy_revision", Long.toString(saved.sequence()));
config.put("before", before);
config.put("after", saved.disabled());
config.put("effective", saved.disabled());
config.put("outcome", "acknowledged");
durable(operation, "config.changed", actor.context(), config);
var availability = common(actor);
availability.put("origin_admin_action_id", operation.toString());
availability.put("policy_revision", Long.toString(saved.sequence()));
availability.put("available_before", !before);
availability.put("available_after", !saved.disabled());
availability.put("exit_requested_count", exits == null ? 0 : exits.exitRequested());
availability.put("exit_request_accepted_count", exits == null ? 0 : exits.exitRequested());
availability.put("exit_native_restored_pending_receipt_count",
exits == null ? 0 : exits.nativeRestoredPendingReceipt());
availability.put("exit_confirmed_count", 0);
availability.put("exit_failed_count", exits == null ? 0 : exits.exitFailed());
availability.put("exit_pending_count", exits == null ? 0 : exits.exitPending());
availability.put("pending_entry_refused_count", exits == null ? 0 : exits.entryRefused());
availability.put("receipt_state", "not_yet_observed");
availability.put("outcome", "policy_published_exit_requests_only");
durable(operation, "game.availability_changed", actor.context(), availability);
} catch (RuntimeException ignored) { /* Logging failure cannot reverse policy or reissue exits. */ }
}
private static Map<String, Object> common(Actor actor) {
var values = new LinkedHashMap<String, Object>();
values.put("key", RoleplaySettings.KEY);
values.put("actor_kind", actor.kind());
values.put("application_scope", "global_and_active_sessions");
if (actor.context() == null) values.put("location_status", "not_applicable");
else if (actor.context().location() == null) values.put("location_status", "unavailable");
return values;
}
private EventInput.Location safeLocation(Player player) {
try {
Location location = player.getLocation();
if (location == null || location.getWorld() == null) return null;
var world = location.getWorld();
return new EventInput.Location(world.getUID(), world.getName(), location.getX(),
location.getY(), location.getZ(), clock.instant(), "exact");
} catch (RuntimeException unavailable) { return null; }
}
private void durable(UUID operation, String type, EventInput.Context actor, Map<String, Object> values) {
outbox.accepted(new EventInput(id(operation, type), operation, clock.instant(), type, actor, values));
}
private static UUID id(UUID operation, String type) {
return UUID.nameUUIDFromBytes(("fantasystick:roleplay-availability:" + operation + ":" + type)
.getBytes(StandardCharsets.UTF_8));
}
public void replay() { outbox.replay(); }
public CompletionStage<Void> drained() { return outbox.drained(); }
@Override public void close() { outbox.close(); }
}
@@ -0,0 +1,178 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.SheetResourcePackSettings;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.Locale;
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.command.BlockCommandSender;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerResourcePackStatusEvent;
/** Pack events describe native offers and callbacks, never actual client rendering or gameplay authority. */
public final class SheetResourcePackEventBridge implements AutoCloseable {
public record Actor(String kind, EventInput.Context context) { }
public record Request(UUID action, UUID pack, String assetIdentity, String sha1) { }
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final Clock clock;
private final EquipmentSessions sessions;
private final FeatureEventOutbox outbox;
public SheetResourcePackEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock, Path data, EquipmentSessions sessions,
Consumer<Throwable> failures) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
this.sessions = Objects.requireNonNull(sessions);
outbox = new FeatureEventOutbox(data, "sheet-resource-pack-event-intents",
java.util.Set.of("config.changed", "resource_pack.delivery_requested",
"resource_pack.delivery_resolved"), sink, failures);
}
public Actor actor(CommandSender sender) {
try {
var player = sender instanceof Player value ? value : null;
var kind = player != null ? "player" : sender instanceof BlockCommandSender ? "command_block" : "console";
var active = player == null ? null : sessions.active(player.getUniqueId()).orElse(null);
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
return new Actor(kind, player == null ? null : new EventInput.Context(player.getUniqueId(),
player.getName(), record == null ? null : record.id(),
record == null ? null : record.name(), active == null ? null : active.session(),
safeLocation(player)));
} catch (RuntimeException unavailable) { return null; }
}
public void read(Actor actor, String key, String value) {
if (actor == null) return;
var values = config(actor, key);
values.put("effective", safeValue(key, value));
values.put("outcome", "acknowledged_read");
var action = UUID.randomUUID();
try { sink.apply(new EventInput(id(action, "config.read"), action, clock.instant(),
"config.read", actor.context(), values)); }
catch (RuntimeException ignored) { /* A read cannot change a delivery. */ }
}
public void changed(Actor actor, UUID action, String key, String before, String effective,
SheetResourcePackSettings.Update saved) {
if (actor == null) return;
try {
var values = config(actor, key);
values.put("operation_id", action.toString());
values.put("policy_revision", Long.toString(saved.sequence()));
values.put("before", safeValue(key, before));
values.put("after", safeValue(key, key.equals(SheetResourcePackSettings.URL_KEY)
? saved.snapshot().url() : saved.snapshot().sha1()));
values.put("effective", safeValue(key, effective));
values.put("outcome", "acknowledged_before_optional_redelivery");
durable(action, "config.changed", actor.context(), values);
} catch (RuntimeException ignored) { /* An audit failure cannot reverse a paired setting. */ }
}
public Request requested(Player player, UUID pack, String url, String sha1, String trigger,
boolean delivered) {
var action = UUID.randomUUID();
var request = new Request(action, pack, identity(url), sha1);
try {
var values = new LinkedHashMap<String, Object>();
values.put("pack_id", pack.toString());
values.put("asset_identity", request.assetIdentity());
values.put("sha1", sha1);
values.put("trigger", trigger);
values.put("prompt", "Optional Fantasy Stick first-person sheet presentation");
values.put("required", false);
values.put("outcome", delivered ? "native_offer_returned" : "native_offer_failed");
var context = playerContext(player);
values.put("location_quality", context.location() == null ? "unavailable" : context.location().quality());
if (context.location() == null) values.put("location_status", "unavailable");
durable(action, "resource_pack.delivery_requested", context, values);
} catch (RuntimeException ignored) { /* Pack requests and gameplay never depend on the audit writer. */ }
return request;
}
public void resolved(Player player, Request request, PlayerResourcePackStatusEvent.Status status) {
try {
var outcome = status.name().toLowerCase(Locale.ROOT);
if (status == PlayerResourcePackStatusEvent.Status.FAILED_RELOAD) outcome = "reload_failed";
var values = new LinkedHashMap<String, Object>();
values.put("pack_id", request.pack().toString());
values.put("asset_identity", request.assetIdentity());
values.put("sha1", request.sha1());
values.put("origin_delivery_action_id", request.action().toString());
values.put("status", outcome);
values.put("reason", "client_reported_status");
values.put("callback_time", clock.instant().toString());
var context = playerContext(player);
values.put("location_quality", context.location() == null ? "unavailable" : context.location().quality());
if (context.location() == null) values.put("location_status", "unavailable");
durable(request.action(), "resource_pack.delivery_resolved", context, values, status.name());
} catch (RuntimeException ignored) { /* A status cannot authorize gameplay. */ }
}
private static Map<String, Object> config(Actor actor, String key) {
var values = new LinkedHashMap<String, Object>();
values.put("key", key);
values.put("actor_kind", actor.kind());
values.put("application_scope", "later_optional_redelivery");
if (actor.context() == null) values.put("location_status", "not_applicable");
else if (actor.context().location() == null) values.put("location_status", "unavailable");
return values;
}
/** A URL's query can contain credentials; only a one-way identity escapes the policy boundary. */
public static String safeValue(String key, String value) {
return SheetResourcePackSettings.URL_KEY.equals(key) ? identity(value) : value;
}
private static String identity(String url) {
try {
return "sha256:" + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(url.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException unavailable) { throw new IllegalStateException(unavailable); }
}
private EventInput.Context playerContext(Player player) {
return new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null,
safeLocation(player));
}
private EventInput.Location safeLocation(Player player) {
try {
Location location = player.getLocation();
if (location == null || location.getWorld() == null) return null;
var world = location.getWorld();
return new EventInput.Location(world.getUID(), world.getName(), location.getX(),
location.getY(), location.getZ(), clock.instant(), "exact");
} catch (RuntimeException unavailable) { return null; }
}
private void durable(UUID action, String type, EventInput.Context context, Map<String, Object> values) {
durable(action, type, context, values, type);
}
private void durable(UUID action, String type, EventInput.Context context, Map<String, Object> values,
String discriminator) {
outbox.accepted(new EventInput(id(action, discriminator), action, clock.instant(), type, context, values));
}
private static UUID id(UUID action, String discriminator) {
return UUID.nameUUIDFromBytes(("fantasystick:sheet-pack:" + action + ":" + discriminator)
.getBytes(StandardCharsets.UTF_8));
}
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;}
@@ -247,7 +252,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
var experiencePool=capturedRewards.pool(kind,level);
var xp = games.dmg.fantasystick.progression.RewardAllocator.allocate(experiencePool, units, random);
var coins = games.dmg.fantasystick.progression.RewardAllocator.allocate(coinPool, units, random);
CombatRewardRules.KillPosition restricted = managed.isPresent()||kind == CombatRewardRules.Target.PASSIVE
CombatRewardRules.KillPosition restricted = managed.isPresent()
|| kind == CombatRewardRules.Target.FANTASY_CHARACTER ? null : position(target.getLocation());
UUID targetReward = defeatedCharacter == null ? target.getUniqueId() : defeatedCharacter;
String authority=managed.map(value->value.kind()+":"+value.source()).orElse("ordinary");
@@ -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);
}
@@ -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());}
@@ -20,13 +20,13 @@ public final class YamlFishingRepository {
var root=map(yaml().load(Files.readString(file,StandardCharsets.UTF_8)));
int schema=number(root.get("schema-version")).intValueExact();if(schema<1||schema>2)throw new IllegalArgumentException();
var inventory=new ArrayList<FishingState.ItemStack>();for(Object value:list(root.get("inventory"))){var row=map(value);inventory.add(new FishingState.ItemStack(uuid(row,"character"),uuid(row,"item"),uuid(row,"site"),text(row.get("name")),number(row.get("amount"))));}
var trophies=new ArrayList<FishingState.Trophy>();if(schema>=2)for(Object value:list(root.get("trophies"))){var row=map(value);var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(Object rewardValue:list(row.get("rewards"))){var reward=map(rewardValue);if(rewards.put(uuid(reward,"character"),new FishingState.Reward(number(reward.get("xp")),number(reward.get("coins"))))!=null)throw new IllegalArgumentException();}trophies.add(new FishingState.Trophy(uuid(row,"id"),FishingState.TrophyType.valueOf(text(row.get("type"))),uuid(row,"carrier"),uuid(row,"catalog"),uuid(row,"site"),uuid(row,"quest"),uuid(row,"source"),text(row.get("name")),text(row.get("origin")),rewards,FishingState.TrophyPhase.valueOf(text(row.get("phase")))));}
var trophies=new ArrayList<FishingState.Trophy>();if(schema>=2)for(Object value:list(root.get("trophies"))){var row=map(value);var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(Object rewardValue:list(row.get("rewards"))){var reward=map(rewardValue);if(rewards.put(uuid(reward,"character"),new FishingState.Reward(number(reward.get("xp")),number(reward.get("coins"))))!=null)throw new IllegalArgumentException();}trophies.add(new FishingState.Trophy(uuid(row,"id"),FishingState.TrophyType.valueOf(text(row.get("type"))),uuid(row,"carrier"),uuid(row,"catalog"),uuid(row,"site"),uuid(row,"quest"),uuid(row,"source"),text(row.get("name")),text(row.get("origin")),rewards,FishingState.TrophyPhase.valueOf(text(row.get("phase"))),strings(row.get("audit"))));}
var trophyReceipts=schema>=2?uuids(root.get("trophy-receipts")):Set.<UUID>of();
var catches=new ArrayList<FishingState.Catch>();for(Object value:list(root.get("catches"))){var row=map(value);catches.add(new FishingState.Catch(uuid(row,"id"),uuid(row,"character"),uuid(row,"quest"),uuid(row,"site"),uuid(row,"item"),text(row.get("item-name")),number(row.get("xp")),bool(row.get("item-awarded"))));}
var catches=new ArrayList<FishingState.Catch>();for(Object value:list(root.get("catches"))){var row=map(value);catches.add(new FishingState.Catch(uuid(row,"id"),uuid(row,"character"),uuid(row,"quest"),uuid(row,"site"),uuid(row,"item"),text(row.get("item-name")),number(row.get("xp")),bool(row.get("item-awarded")),strings(row.get("audit"))));}
var receipts=uuids(root.get("catch-receipts"));
var submissions=new ArrayList<FishingState.Submission>();for(Object value:list(root.get("submissions"))){var row=map(value);var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(Object rewardValue:list(row.get("rewards"))){var reward=map(rewardValue);if(rewards.put(uuid(reward,"character"),new FishingState.Reward(number(reward.get("xp")),number(reward.get("coins"))))!=null)throw new IllegalArgumentException();}submissions.add(new FishingState.Submission(uuid(row,"id"),uuid(row,"quest"),uuid(row,"catalog"),uuid(row,"site"),uuid(row,"submitter"),uuid(row,"item"),number(row.get("quantity")),rewards,FishingState.SubmissionPhase.valueOf(text(row.get("phase")))));}
var submissions=new ArrayList<FishingState.Submission>();for(Object value:list(root.get("submissions"))){var row=map(value);var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(Object rewardValue:list(row.get("rewards"))){var reward=map(rewardValue);if(rewards.put(uuid(reward,"character"),new FishingState.Reward(number(reward.get("xp")),number(reward.get("coins"))))!=null)throw new IllegalArgumentException();}submissions.add(new FishingState.Submission(uuid(row,"id"),uuid(row,"quest"),uuid(row,"catalog"),uuid(row,"site"),uuid(row,"submitter"),uuid(row,"item"),number(row.get("quantity")),rewards,FishingState.SubmissionPhase.valueOf(text(row.get("phase"))),strings(row.get("audit"))));}
var completions=new HashSet<FishingState.Completion>();for(Object value:list(root.get("completions"))){var row=map(value);completions.add(new FishingState.Completion(uuid(row,"catalog"),uuid(row,"site"),uuid(row,"character")));}
var allocations=new ArrayList<FishingState.Allocation>();for(Object value:list(root.get("allocations"))){var row=map(value);allocations.add(new FishingState.Allocation(uuid(row,"id"),uuid(row,"submission"),uuid(row,"catalog"),uuid(row,"site"),uuid(row,"character"),number(row.get("xp")),number(row.get("coins"))));}
var allocations=new ArrayList<FishingState.Allocation>();for(Object value:list(root.get("allocations"))){var row=map(value);allocations.add(new FishingState.Allocation(uuid(row,"id"),uuid(row,"submission"),uuid(row,"catalog"),uuid(row,"site"),uuid(row,"character"),number(row.get("xp")),number(row.get("coins")),row.get("source-kind") instanceof String kind&&!kind.isBlank()?kind:"unavailable_legacy_allocation"));}
var claims=new ArrayList<FishingState.Claim>();for(Object value:list(root.get("claims"))){var row=map(value);claims.add(new FishingState.Claim(uuid(row,"id"),uuid(row,"character"),uuids(row.get("allocations")),number(row.get("xp")),number(row.get("coins"))));}
return new FishingState(inventory,trophies,trophyReceipts,catches,receipts,submissions,completions,allocations,claims);
}catch(RuntimeException invalid){throw new IOException("Invalid fishing file",invalid);}
@@ -34,19 +34,25 @@ public final class YamlFishingRepository {
public void save(FishingState state)throws IOException{
Objects.requireNonNull(state);var root=new LinkedHashMap<String,Object>();root.put("schema-version",2);
root.put("inventory",state.inventory().stream().sorted(Comparator.comparing(value->value.character().toString()+value.item())).map(value->row("character",value.character(),"item",value.item(),"site",value.site(),"name",value.name(),"amount",value.amount())).toList());
root.put("trophies",state.trophies().stream().sorted(Comparator.comparing(FishingState.Trophy::id)).map(value->row("id",value.id(),"type",value.type(),"carrier",value.carrier(),"catalog",value.catalog(),"site",value.site(),"quest",value.quest(),"source",value.source(),"name",value.name(),"origin",value.origin(),"phase",value.phase(),"rewards",value.rewards().entrySet().stream().sorted(Map.Entry.comparingByKey()).map(entry->row("character",entry.getKey(),"xp",entry.getValue().xp(),"coins",entry.getValue().coins())).toList())).toList());
root.put("trophies",state.trophies().stream().sorted(Comparator.comparing(FishingState.Trophy::id)).map(value->row("id",value.id(),"type",value.type(),"carrier",value.carrier(),"catalog",value.catalog(),"site",value.site(),"quest",value.quest(),"source",value.source(),"name",value.name(),"origin",value.origin(),"phase",value.phase(),"audit",value.audit(),"rewards",value.rewards().entrySet().stream().sorted(Map.Entry.comparingByKey()).map(entry->row("character",entry.getKey(),"xp",entry.getValue().xp(),"coins",entry.getValue().coins())).toList())).toList());
root.put("trophy-receipts",state.trophyReceipts().stream().sorted().map(UUID::toString).toList());
root.put("catches",state.catches().stream().sorted(Comparator.comparing(FishingState.Catch::id)).map(value->row("id",value.id(),"character",value.character(),"quest",value.quest(),"site",value.site(),"item",value.item(),"item-name",value.itemName(),"xp",value.xp(),"item-awarded",value.itemAwarded())).toList());
root.put("catches",state.catches().stream().sorted(Comparator.comparing(FishingState.Catch::id)).map(value->row("id",value.id(),"character",value.character(),"quest",value.quest(),"site",value.site(),"item",value.item(),"item-name",value.itemName(),"xp",value.xp(),"item-awarded",value.itemAwarded(),"audit",value.audit())).toList());
root.put("catch-receipts",state.catchReceipts().stream().sorted().map(UUID::toString).toList());
root.put("submissions",state.submissions().stream().sorted(Comparator.comparing(FishingState.Submission::id)).map(value->row("id",value.id(),"quest",value.quest(),"catalog",value.catalog(),"site",value.site(),"submitter",value.submitter(),"item",value.item(),"quantity",value.quantity(),"phase",value.phase(),"rewards",value.rewards().entrySet().stream().sorted(Map.Entry.comparingByKey()).map(entry->row("character",entry.getKey(),"xp",entry.getValue().xp(),"coins",entry.getValue().coins())).toList())).toList());
root.put("submissions",state.submissions().stream().sorted(Comparator.comparing(FishingState.Submission::id)).map(value->row("id",value.id(),"quest",value.quest(),"catalog",value.catalog(),"site",value.site(),"submitter",value.submitter(),"item",value.item(),"quantity",value.quantity(),"phase",value.phase(),"audit",value.audit(),"rewards",value.rewards().entrySet().stream().sorted(Map.Entry.comparingByKey()).map(entry->row("character",entry.getKey(),"xp",entry.getValue().xp(),"coins",entry.getValue().coins())).toList())).toList());
root.put("completions",state.completions().stream().sorted(Comparator.comparing(value->value.catalog().toString()+value.site()+value.character())).map(value->row("catalog",value.catalog(),"site",value.site(),"character",value.character())).toList());
root.put("allocations",state.allocations().stream().sorted(Comparator.comparing(FishingState.Allocation::id)).map(value->row("id",value.id(),"submission",value.submission(),"catalog",value.catalog(),"site",value.site(),"character",value.character(),"xp",value.xp(),"coins",value.coins())).toList());
root.put("allocations",state.allocations().stream().sorted(Comparator.comparing(FishingState.Allocation::id)).map(value->row("id",value.id(),"submission",value.submission(),"catalog",value.catalog(),"site",value.site(),"character",value.character(),"xp",value.xp(),"coins",value.coins(),"source-kind",value.sourceKind())).toList());
root.put("claims",state.claims().stream().sorted(Comparator.comparing(FishingState.Claim::id)).map(value->row("id",value.id(),"character",value.character(),"allocations",value.allocations().stream().sorted().toList(),"xp",value.xp(),"coins",value.coins())).toList());
Files.createDirectories(file.getParent());Path temporary=Files.createTempFile(file.getParent(),"fishing-",".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 fishing persistence unavailable",unsupported);}DurableFiles.forceDirectory(file.getParent());}finally{Files.deleteIfExists(temporary);}
}
private static Map<String,Object> row(Object...pairs){var result=new LinkedHashMap<String,Object>();for(int index=0;index<pairs.length;index+=2)result.put((String)pairs[index],encode(pairs[index+1]));return result;}
private static Object encode(Object value){if(value instanceof UUID||value instanceof Enum<?>||value instanceof BigInteger)return value.toString();if(value instanceof Collection<?> values)return values.stream().map(YamlFishingRepository::encode).toList();return value;}
private static Yaml yaml(){var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(64);options.setCodePointLimit(4_194_304);return new Yaml(new SafeConstructor(options));}
private static Map<String,String> strings(Object value){
if(value==null)return Map.of();
var result=new LinkedHashMap<String,String>();
map(value).forEach((key,item)->{if(result.put(text(key),text(item))!=null)throw new IllegalArgumentException();});
return result;
}
private static Map<?,?> map(Object value){if(value instanceof Map<?,?> result)return result;throw new IllegalArgumentException();}
private static List<?> list(Object value){if(value instanceof List<?> result)return result;throw new IllegalArgumentException();}
private static String text(Object value){if(value instanceof String result)return result;throw new IllegalArgumentException();}
@@ -1,7 +1,12 @@
package games.dmg.fantasystick.presentation;
import games.dmg.fantasystick.persistence.SheetResourcePackSettings;
import games.dmg.fantasystick.events.SheetResourcePackEventBridge;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.Objects;
import java.util.UUID;
import java.util.function.BiConsumer;
@@ -9,6 +14,7 @@ import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.player.PlayerResourcePackStatusEvent;
/** Optional cosmetic pack delivery. Status never authorizes or cancels gameplay. */
@@ -16,36 +22,71 @@ public final class SheetResourcePack implements Listener, AutoCloseable {
public static final UUID ID = UUID.fromString("0d53e627-f13b-5a49-b8d5-882d3f4122bb");
private final SheetResourcePackSettings settings;
private final BiConsumer<UUID, Throwable> failures;
private final Map<UUID, Offer> offers = new HashMap<>();
private final Map<UUID, Player> recipients = new HashMap<>();
private SheetResourcePackEventBridge events;
private boolean closed;
private record Offer(SheetResourcePackEventBridge.Request request,
Set<PlayerResourcePackStatusEvent.Status> statuses) { }
public SheetResourcePack(SheetResourcePackSettings settings, BiConsumer<UUID, Throwable> failures) {
this.settings = Objects.requireNonNull(settings);
this.failures = Objects.requireNonNull(failures);
}
public void deliverAll(Collection<? extends Player> players) {
if (closed) return;
for (var player : players) if (player != null && player.isOnline()) deliver(player);
public void attachEvents(SheetResourcePackEventBridge bridge) {
if (closed || events != null) throw new IllegalStateException("Sheet pack events unavailable");
events = Objects.requireNonNull(bridge);
}
public void deliver(Player player) {
public void deliverAll(Collection<? extends Player> players) { deliverAll(players, "startup"); }
public void deliverAll(Collection<? extends Player> players, String trigger) {
if (closed) return;
for (var player : players) if (player != null && player.isOnline()) deliver(player, trigger);
}
public void deliver(Player player) { deliver(player, "explicit"); }
private void deliver(Player player, String trigger) {
if (closed || !player.isOnline()) return;
var playerId = player.getUniqueId();
var prior = offers.get(playerId);
var pack = UUID.randomUUID(); // Native callbacks expose only this ID, not a usable hash.
var url = settings.url(); var sha1 = settings.sha1();
try {
player.addResourcePack(ID, settings.url(), java.util.HexFormat.of().parseHex(settings.sha1()),
player.addResourcePack(pack, url, java.util.HexFormat.of().parseHex(sha1),
"Optional Fantasy Stick first-person sheet presentation", false);
if (events != null) offers.put(playerId, new Offer(
events.requested(player, pack, url, sha1, trigger, true), new HashSet<>()));
recipients.put(playerId, player);
if (prior != null) try { player.removeResourcePack(prior.request().pack()); }
catch (RuntimeException failure) { report(playerId, failure); }
} catch (RuntimeException failure) {
report(player.getUniqueId(), failure);
player.sendMessage("Fantasy Stick cosmetic resource pack could not be offered; gameplay is unchanged.");
if (events != null) events.requested(player, pack, url, sha1, trigger, false);
report(playerId, failure);
try { player.sendMessage("Fantasy Stick cosmetic resource pack could not be offered; gameplay is unchanged."); }
catch (RuntimeException feedbackFailure) { report(playerId, feedbackFailure); }
}
}
@EventHandler public void join(PlayerJoinEvent event) { deliver(event.getPlayer()); }
@EventHandler public void join(PlayerJoinEvent event) { deliver(event.getPlayer(), "join"); }
@EventHandler public void quit(PlayerQuitEvent event) {
var id = event.getPlayer().getUniqueId(); offers.remove(id); recipients.remove(id);
}
@EventHandler public void status(PlayerResourcePackStatusEvent event) {
if (closed || !ID.equals(event.getID())) return;
if (closed) return;
var offer = offers.get(event.getPlayer().getUniqueId());
if (offer == null || !offer.request().pack().equals(event.getID())) return;
if (offer.statuses().add(event.getStatus()) && events != null)
events.resolved(event.getPlayer(), offer.request(), event.getStatus());
switch (event.getStatus()) {
case DECLINED, FAILED_DOWNLOAD, INVALID_URL, FAILED_RELOAD, DISCARDED ->
event.getPlayer().sendMessage("Fantasy Stick cosmetic resource pack is unavailable; gameplay is unchanged.");
case DECLINED, FAILED_DOWNLOAD, INVALID_URL, FAILED_RELOAD, DISCARDED -> {
try { event.getPlayer().sendMessage("Fantasy Stick cosmetic resource pack is unavailable; gameplay is unchanged."); }
catch (RuntimeException feedbackFailure) { report(event.getPlayer().getUniqueId(), feedbackFailure); }
}
default -> { /* Acceptance/loading is cosmetic and needs no gameplay callback. */ }
}
}
@@ -55,5 +96,13 @@ public final class SheetResourcePack implements Listener, AutoCloseable {
catch (RuntimeException ignored) { /* Diagnostics cannot make a cosmetic pack mandatory. */ }
}
@Override public void close() { closed = true; }
@Override public void close() {
closed = true;
offers.forEach((id, offer) -> {
var player = recipients.get(id);
if (player != null) try { player.removeResourcePack(offer.request().pack()); }
catch (RuntimeException failure) { report(id, failure); }
});
offers.clear(); recipients.clear(); events = null;
}
}
@@ -1,5 +1,6 @@
package games.dmg.fantasystick.session;
import games.dmg.fantasystick.events.AdminExperienceEventBridge;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.ProgressionRules;
@@ -11,6 +12,7 @@ import java.util.function.Function;
import org.bukkit.Color;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.bukkit.command.CommandSender;
/** Acknowledged administrator XP mutation captured against one exact active character/session. */
public final class AdminExperienceGrant implements AutoCloseable {
@@ -27,6 +29,20 @@ public final class AdminExperienceGrant implements AutoCloseable {
private final Consumer<Failure> failures;
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private volatile boolean closed;
private AdminExperienceEventBridge events;
private final java.util.concurrent.ConcurrentMap<UUID, Pending> pending = new java.util.concurrent.ConcurrentHashMap<>();
private static final class Pending {
final AdminExperienceEventBridge.Intent intent;
final CharacterRecord captured;
final BigInteger amount;
CharacterStorage.ExperienceAward award;
boolean resolved;
boolean emitted;
Pending(AdminExperienceEventBridge.Intent intent, CharacterRecord captured, BigInteger amount) {
this.intent = intent; this.captured = captured; this.amount = amount;
}
}
public AdminExperienceGrant(CharacterStorage characters, ProgressionRules progression, EquipmentSessions sessions,
Consumer<Runnable> nextTick, Function<UUID, Player> players, Consumer<Failure> failures) {
@@ -45,32 +61,59 @@ public final class AdminExperienceGrant implements AutoCloseable {
this.particles = Objects.requireNonNull(particles);
}
public void attachEvents(AdminExperienceEventBridge bridge) {
if (closed || events != null) throw new IllegalStateException("Experience grant events unavailable");
events = Objects.requireNonNull(bridge);
}
/** Returns false before persistence when the captured active identity is no longer authoritative. */
public boolean grant(EquipmentSessions.Active active, CharacterRecord captured, BigInteger amount,
Consumer<Result> completion) {
return grant(active, captured, amount, null, completion);
}
public boolean grant(EquipmentSessions.Active active, CharacterRecord captured, BigInteger amount,
CommandSender administrator, Consumer<Result> completion) {
Objects.requireNonNull(active); Objects.requireNonNull(captured);
Objects.requireNonNull(amount); Objects.requireNonNull(completion);
if (amount.signum() <= 0) throw new IllegalArgumentException("Experience grant must be positive");
if (closed || !sessions.current(active)
|| sessions.currentRecord(active).filter(captured::equals).isEmpty()) return false;
AdminExperienceEventBridge.Intent intent = null;
if (events != null && administrator != null) {
Player target = null;
try { target = players.apply(active.player()); }
catch (RuntimeException failure) { report(captured.id(), failure); }
try { intent = events.capture(administrator, active, captured, target); }
catch (RuntimeException failure) { report(captured.id(), failure); }
}
var operation = new Pending(intent, captured, amount);
if (intent != null) pending.put(intent.operation(), operation);
characters.awardExperience(captured.id(), captured.revision(), amount, progression)
.whenComplete((award, failure) -> schedule(active, captured, amount, completion, award, failure));
.whenComplete((award, failure) -> schedule(active, operation, completion, award, failure));
return true;
}
private void schedule(EquipmentSessions.Active active, CharacterRecord captured, BigInteger amount,
private void schedule(EquipmentSessions.Active active, Pending operation,
Consumer<Result> completion, CharacterStorage.ExperienceAward award, Throwable failure) {
if (failure != null) report(captured.id(), failure);
if (closed) return;
if (failure != null) report(operation.captured.id(), failure);
synchronized (operation) { operation.award = award; operation.resolved = true; }
if (closed) { publish(operation, false); return; }
try {
nextTick.accept(() -> complete(active, captured, amount, completion, award, failure));
} catch (RuntimeException rejected) { report(captured.id(), rejected); }
nextTick.accept(() -> complete(active, operation, completion, award, failure));
} catch (RuntimeException rejected) {
report(operation.captured.id(), rejected);
publish(operation, false);
}
}
private void complete(EquipmentSessions.Active active, CharacterRecord captured, BigInteger amount,
private void complete(EquipmentSessions.Active active, Pending operation,
Consumer<Result> completion, CharacterStorage.ExperienceAward award, Throwable failure) {
if (closed) return;
if (closed) { publish(operation, false); return; }
var captured = operation.captured;
var amount = operation.amount;
if (failure != null) {
publish(operation, false);
completion.accept(new Result(Status.FAILED, captured.name(), amount, BigInteger.ZERO,
captured.level(), 0));
return;
@@ -84,6 +127,7 @@ public final class AdminExperienceGrant implements AutoCloseable {
};
BigInteger applied = status == Status.APPLIED
? saved.experience().subtract(captured.experience()) : BigInteger.ZERO;
boolean feedbackDelivered = false;
if (status == Status.APPLIED && sessions.current(active)) {
boolean synchronizedSession = sessions.acknowledgeProgress(active, captured.revision(), saved);
if (!synchronizedSession && sessions.current(active)
@@ -91,17 +135,34 @@ public final class AdminExperienceGrant implements AutoCloseable {
report(captured.id(), new IllegalStateException(
"Saved administrator XP could not synchronize with its captured active session"));
}
if (synchronizedSession && sessions.current(active)) feedback(active, saved, applied, award.levels());
if (synchronizedSession && sessions.current(active))
feedbackDelivered = feedback(active, saved, applied, award.levels());
}
publish(operation, feedbackDelivered);
completion.accept(new Result(status, captured.name(), amount, applied, saved.level(), award.levels()));
}
private void feedback(EquipmentSessions.Active active, CharacterRecord saved, BigInteger applied, int levels) {
private void publish(Pending operation, boolean feedbackDelivered) {
synchronized (operation) {
if (operation.emitted) return;
var award = operation.award;
if (!operation.resolved) return; // A later worker completion will observe close and retain the intent.
operation.emitted = true;
if (award != null && award.status() == CharacterStorage.ExperienceAwardStatus.APPLIED
&& award.record().isPresent() && events != null) {
events.granted(operation.intent, operation.captured, award.record().orElseThrow(),
operation.amount, award.levels(), feedbackDelivered);
}
}
if (operation.intent != null) pending.remove(operation.intent.operation(), operation);
}
private boolean feedback(EquipmentSessions.Active active, CharacterRecord saved, BigInteger applied, int levels) {
Player player;
try { player = players.apply(active.player()); }
catch (RuntimeException failure) { report(saved.id(), failure); return; }
catch (RuntimeException failure) { report(saved.id(), failure); return false; }
if (player == null || !player.isOnline() || !sessions.current(active)
|| sessions.currentRecord(active).filter(saved::equals).isEmpty()) return;
|| sessions.currentRecord(active).filter(saved::equals).isEmpty()) return false;
try {
player.sendMessage("Received " + applied + " fantasy XP from an administrator.");
for (int gained = 1; gained <= levels; gained++) {
@@ -111,7 +172,8 @@ public final class AdminExperienceGrant implements AutoCloseable {
0.3, 0.4, 0.3, 0, new org.bukkit.Particle.DustOptions(Color.BLUE, 1));
player.playSound(player.getLocation(), Sound.ENTITY_PLAYER_LEVELUP, 1, 1);
}
} catch (RuntimeException failure) { report(saved.id(), failure); }
return true;
} catch (RuntimeException failure) { report(saved.id(), failure); return false; }
}
private void report(UUID character, Throwable failure) {
@@ -119,5 +181,8 @@ public final class AdminExperienceGrant implements AutoCloseable {
catch (RuntimeException ignored) { /* Diagnostics do not replay a grant. */ }
}
@Override public void close() { closed = true; }
@Override public void close() {
closed = true;
for (var operation : pending.values()) publish(operation, false);
}
}
@@ -74,20 +74,25 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.persistence.SwitchDelaySettings switchDelaySettings;
private games.dmg.fantasystick.events.SwitchConfigEventBridge switchConfigEvents;
private games.dmg.fantasystick.persistence.RoleplaySettings roleplaySettings;
private games.dmg.fantasystick.events.RoleplayAvailabilityEventBridge roleplayAvailabilityEvents;
private games.dmg.fantasystick.persistence.PartySettings partySettings;
private games.dmg.fantasystick.events.PartyCapacityEventBridge partyCapacityEvents;
private games.dmg.fantasystick.persistence.PartyEconomySettings partyEconomySettings;
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.persistence.HardMobXpSettings hardMobXpSettings;
private games.dmg.fantasystick.persistence.AdventureQuestSettings adventureQuestSettings;
private games.dmg.fantasystick.events.FishingEventBridge fishingEvents;
private games.dmg.fantasystick.adventure.AdventureQuestCommands adventureQuestCommands;
private games.dmg.fantasystick.dungeon.DungeonCommands dungeonCommands;
private games.dmg.fantasystick.party.CombatExperience combatExperience;
private games.dmg.fantasystick.persistence.SheetResourcePackSettings sheetResourcePackSettings;
private games.dmg.fantasystick.presentation.SheetResourcePack sheetResourcePack;
private games.dmg.fantasystick.events.SheetResourcePackEventBridge sheetResourcePackEvents;
private games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings outOfCombatRecoverySettings;
private OutOfCombatRecovery outOfCombatRecovery;
private games.dmg.fantasystick.events.RecoveryEventBridge recoveryEvents;
private games.dmg.fantasystick.persistence.QuickerRegenerationSettings quickerRegenerationSettings;
private games.dmg.fantasystick.passive.QuickerRegenerationI quickerRegeneration;
private games.dmg.fantasystick.events.PassiveEventBridge passiveEvents;
@@ -343,7 +348,15 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
switchConfigEvents=Objects.requireNonNull(events);
}
public void attachRoleplaySettings(games.dmg.fantasystick.persistence.RoleplaySettings settings){if(closed||roleplaySettings!=null)throw new IllegalStateException("Roleplay settings already attached or command closed");roleplaySettings=Objects.requireNonNull(settings);}
public void attachRoleplayAvailabilityEvents(games.dmg.fantasystick.events.RoleplayAvailabilityEventBridge events){
if(closed||roleplaySettings==null||roleplayAvailabilityEvents!=null)throw new IllegalStateException("Roleplay availability events unavailable");
roleplayAvailabilityEvents=Objects.requireNonNull(events);
}
public void attachPartySettings(games.dmg.fantasystick.persistence.PartySettings settings){if(closed||partySettings!=null)throw new IllegalStateException("Party settings already attached or command closed");partySettings=Objects.requireNonNull(settings);}
public void attachPartyCapacityEvents(games.dmg.fantasystick.events.PartyCapacityEventBridge events){
if(closed||partySettings==null||partyCapacityEvents!=null)throw new IllegalStateException("Party capacity events unavailable");
partyCapacityEvents=Objects.requireNonNull(events);
}
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);}
@@ -359,14 +372,25 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed || adventureQuestCommands != null) throw new IllegalStateException("Adventure quests already attached or command closed");
adventureQuestCommands = Objects.requireNonNull(commands); adventureQuestSettings = Objects.requireNonNull(settings);
}
public void attachFishingEvents(games.dmg.fantasystick.events.FishingEventBridge value){fishingEvents=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){
if(closed||sheetResourcePackSettings==null||sheetResourcePackEvents!=null)
throw new IllegalStateException("Sheet pack events unavailable");
sheetResourcePackEvents=Objects.requireNonNull(events);
}
public void attachOutOfCombatRecovery(games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings settings,
OutOfCombatRecovery recovery) {
if (closed || outOfCombatRecoverySettings != null) throw new IllegalStateException("Out-of-combat recovery already attached or command closed");
outOfCombatRecoverySettings = Objects.requireNonNull(settings);
outOfCombatRecovery = Objects.requireNonNull(recovery);
}
public void attachRecoveryEvents(games.dmg.fantasystick.events.RecoveryEventBridge events) {
if (closed || outOfCombatRecoverySettings == null || recoveryEvents != null)
throw new IllegalStateException("Recovery config events unavailable");
recoveryEvents = Objects.requireNonNull(events);
}
public void attachPassiveEvents(games.dmg.fantasystick.events.PassiveEventBridge events){if(closed||passiveEvents!=null)throw new IllegalStateException("Passive events already attached or command closed");passiveEvents=Objects.requireNonNull(events);}
@@ -806,7 +830,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
sender.sendMessage("Experience grant refused before persistence. No fantasy XP changed."); return;
}
var target = matches.getFirst();
boolean accepted = experienceGrant.grant(target.session(), target.record(), amount, result -> {
boolean accepted = experienceGrant.grant(target.session(), target.record(), amount, sender, result -> {
if (closed || !sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(switch (result.status()) {
@@ -1183,12 +1207,16 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
}
private void outOfCombatRecoveryConfiguration(CommandSender sender, String[] args) {
String key = args[1];
String key = args[1]; var audit = recoveryEvents;
if (args.length == 2) {
sender.sendMessage(key + "=" + outOfCombatRecoverySettings.value(key)
var effective = outOfCombatRecoverySettings.value(key);
if (audit != null) audit.configRead(audit.actor(sender), key, effective);
sender.sendMessage(key + "=" + effective
+ " (applies to future recovery evaluations; the current activity timestamp is retained)");
return;
}
var actor = audit == null ? null : audit.actor(sender);
var operationId = UUID.randomUUID();
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.Update> operation;
try { operation = outOfCombatRecoverySettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) {
@@ -1201,8 +1229,14 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed) return;
boolean applied = false;
if (failure == null) {
var before = outOfCombatRecoverySettings.value(key);
applied = outOfCombatRecoverySettings.publish(saved);
if (applied) outOfCombatRecovery.configure(saved.settings());
if (applied) {
outOfCombatRecovery.configure(saved.settings());
var effective = outOfCombatRecoverySettings.value(key);
if (audit != null && !before.equals(effective))
audit.configChanged(actor, operationId, key, before, effective, saved);
}
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
@@ -1827,7 +1861,40 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
});
}
private void sheetResourcePackConfiguration(CommandSender sender,String[] args){if(sheetResourcePackSettings==null){configurationUsage(sender);return;}String key=args[1];if(args.length==2){sender.sendMessage(key+"="+sheetResourcePackSettings.value(key));return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SheetResourcePackSettings.Update> operation;try{operation=sheetResourcePackSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Sheet resource-pack value is invalid; use an absolute HTTPS URL or 40 hexadecimal SHA-1 characters.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-sheet-resource-pack",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null){applied=sheetResourcePackSettings.publish(saved);if(applied)sheetResourcePack.deliverAll(server.getOnlinePlayers());}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Sheet resource-pack configuration save failed; effective values are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+sheetResourcePackSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+sheetResourcePackSettings.value(key)+"; optional delivery was requested for online players.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void sheetResourcePackConfiguration(CommandSender sender,String[] args){
if(sheetResourcePackSettings==null){configurationUsage(sender);return;}
String key=args[1]; var audit=sheetResourcePackEvents;
if(args.length==2){
var effective=sheetResourcePackSettings.value(key);
if(audit!=null)audit.read(audit.actor(sender),key,effective);
sender.sendMessage(key+"="+effective); return;
}
var actor=audit==null?null:audit.actor(sender);
var action=UUID.randomUUID();
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SheetResourcePackSettings.Update> operation;
try{operation=sheetResourcePackSettings.set(key,args[2]);}
catch(IllegalArgumentException invalid){sender.sendMessage("Sheet resource-pack value is invalid; use an absolute HTTPS URL or 40 hexadecimal SHA-1 characters.");return;}
operation.whenComplete((saved,failure)->{
if(failure!=null)report(null,"config-sheet-resource-pack",failure);
if(closed)return;
try{nextTick.accept(()->{
if(closed)return;
boolean applied=false;
if(failure==null){
var before=sheetResourcePackSettings.value(key);
applied=sheetResourcePackSettings.publish(saved);
var effective=sheetResourcePackSettings.value(key);
if(audit!=null&&applied&&!before.equals(effective))
audit.changed(actor,action,key,before,effective,saved);
if(applied)sheetResourcePack.deliverAll(server.getOnlinePlayers(),"config_change");
}
if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;
if(failure!=null)sender.sendMessage("Sheet resource-pack configuration save failed; effective values are unchanged.");
else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+sheetResourcePackSettings.value(key)+".");
else sender.sendMessage("Saved "+key+"="+sheetResourcePackSettings.value(key)+"; optional delivery was requested for online players.");
});}catch(RuntimeException rejected){report(null,"config-response",rejected);}
});
}
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);}});}
@@ -1865,15 +1932,104 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
}
private void adventureQuestConfiguration(CommandSender sender, String[] args) {
String key=args[1]; if(args.length==2){sender.sendMessage(key+"="+adventureQuestSettings.value(key)+" (applies to the owning later catalog, acceptance or encounter attempt)");return;}
String key=args[1];
boolean fishingKey=key.equals(games.dmg.fantasystick.persistence.AdventureQuestSettings.FISHING_CATCH_XP_MIN)
||key.equals(games.dmg.fantasystick.persistence.AdventureQuestSettings.FISHING_CATCH_XP_MAX);
var audit=fishingKey?fishingEvents:null;
var actor=audit==null?null:audit.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));
sender.sendMessage(key+"="+effective+" (applies to the owning later catalog, acceptance or encounter attempt)");return;
}
var operationId=UUID.randomUUID();
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.AdventureQuestSettings.Update> operation;
try{operation=adventureQuestSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Adventure quest setting is invalid; the effective value is unchanged.");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&&adventureQuestSettings.publish(saved);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+"="+adventureQuestSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for later catalogs, acceptances or attempts; active state is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});
operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{
if(closed)return;
var before=adventureQuestSettings.value(key);
boolean applied=failure==null&&adventureQuestSettings.publish(saved);
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(!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+".");
else sender.sendMessage("Saved "+key+"="+saved.value()+" for later catalogs, acceptances or attempts; active state is unchanged.");
});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});
}
private void partyConfiguration(CommandSender sender,String[] args){if(partySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.PartySettings.KEY+"="+partySettings.value()+" (existing parties are retained)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PartySettings.Update> operation;try{operation=partySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Party maximum is invalid; enter a positive whole number.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-party",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null)applied=partySettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Party configuration save failed; the effective limit and rosters are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+games.dmg.fantasystick.persistence.PartySettings.KEY+"="+partySettings.value()+".");else sender.sendMessage("Saved "+games.dmg.fantasystick.persistence.PartySettings.KEY+"="+saved.value()+"; existing members are retained.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void partyConfiguration(CommandSender sender,String[] args){
if(partySettings==null){configurationUsage(sender);return;}
var audit=partyCapacityEvents;
if(args.length==2){
var effective=partySettings.value();
if(audit!=null)audit.read(audit.actor(sender),effective);
sender.sendMessage(games.dmg.fantasystick.persistence.PartySettings.KEY+"="+effective+" (existing parties are retained)");
return;
}
var actor=audit==null?null:audit.actor(sender);
var operationId=java.util.UUID.randomUUID();
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PartySettings.Update> operation;
try{operation=partySettings.set(args[2]);}
catch(IllegalArgumentException invalid){sender.sendMessage("Party maximum is invalid; enter a positive whole number.");return;}
operation.whenComplete((saved,failure)->{
if(failure!=null)report(null,"config-party",failure);
if(closed)return;
try{nextTick.accept(()->{
if(closed)return;
boolean applied=false;
if(failure==null){
var before=partySettings.value();
applied=partySettings.publish(saved);
var effective=partySettings.value();
if(audit!=null&&applied&&!before.equals(effective))
audit.changed(actor,operationId,before,effective,saved);
}
if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;
if(failure!=null)sender.sendMessage("Party configuration save failed; the effective limit and rosters are unchanged.");
else if(!applied)sender.sendMessage("Saved request was superseded; effective "+games.dmg.fantasystick.persistence.PartySettings.KEY+"="+partySettings.value()+".");
else sender.sendMessage("Saved "+games.dmg.fantasystick.persistence.PartySettings.KEY+"="+saved.value()+"; existing members are retained.");
});}catch(RuntimeException rejected){report(null,"config-response",rejected);}
});
}
private void roleplayConfiguration(CommandSender sender,String[] args){if(roleplaySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value());return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RoleplaySettings.Update> operation;try{operation=roleplaySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Usage: /fstickadmin config roleplay-disabled [true|false]");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-roleplay",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null){applied=roleplaySettings.publish(saved);if(applied&&saved.disabled())sessions.disableRoleplay();}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Roleplay configuration save failed; policy and sessions are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value()+".");else sender.sendMessage("Saved "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+saved.disabled()+(saved.disabled()?"; ordinary exits were requested for active sessions.":"; normal sheet activation is available."));});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void roleplayConfiguration(CommandSender sender,String[] args){
if(roleplaySettings==null){configurationUsage(sender);return;}
var audit=roleplayAvailabilityEvents;
if(args.length==2){
var disabled=roleplaySettings.disabled();
if(audit!=null)audit.read(audit.actor(sender),disabled);
sender.sendMessage(games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+disabled);
return;
}
var actor=audit==null?null:audit.actor(sender);
var operationId=java.util.UUID.randomUUID();
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RoleplaySettings.Update> operation;
try{operation=roleplaySettings.set(args[2]);}
catch(IllegalArgumentException invalid){sender.sendMessage("Usage: /fstickadmin config roleplay-disabled [true|false]");return;}
operation.whenComplete((saved,failure)->{
if(failure!=null)report(null,"config-roleplay",failure);
if(closed)return;
try{nextTick.accept(()->{
if(closed)return;
boolean applied=false;
if(failure==null){
var before=roleplaySettings.disabled();
applied=roleplaySettings.publish(saved);
EquipmentSessions.DisableSummary exits=null;
if(applied&&saved.disabled())exits=sessions.disableRoleplay();
if(audit!=null&&applied&&before!=saved.disabled())audit.changed(actor,operationId,before,saved,exits);
}
if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;
if(failure!=null)sender.sendMessage("Roleplay configuration save failed; policy and sessions are unchanged.");
else if(!applied)sender.sendMessage("Saved request was superseded; effective "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value()+".");
else sender.sendMessage("Saved "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+saved.disabled()
+(saved.disabled()?"; ordinary exits were requested for active sessions."
:"; normal sheet activation is available."));
});}catch(RuntimeException rejected){report(null,"config-response",rejected);}
});
}
private void switchDelayConfiguration(CommandSender sender,String[] args){
if(switchDelaySettings==null){configurationUsage(sender);return;}
@@ -1345,12 +1345,30 @@ public final class EquipmentSessions implements AutoCloseable {
});
}
/** A synchronous exit request is not proof of an acknowledged character/receipt restoration. */
public record DisableSummary(int entryRefused, int exitRequested, int nativeRestoredPendingReceipt,
int exitFailed, int exitPending) { }
/** Cancel pending activation and route every active player through ordinary restoration. */
public synchronized void disableRoleplay() {
for (var attempt : java.util.List.copyOf(pending.values())) {
public synchronized DisableSummary disableRoleplay() {
var pendingEntries = java.util.List.copyOf(pending.values());
var activeSessions = java.util.List.copyOf(active.values());
for (var attempt : pendingEntries) {
refuse(attempt, "ROLEPLAY_DISABLED", "Fantasy roleplaying was disabled before activation completed.");
}
for (var attempt : java.util.List.copyOf(active.values())) exit(attempt.player);
int nativeRestored = 0, failed = 0;
for (var attempt : activeSessions) {
try {
var result = exit(attempt.player);
if (result == EquipmentStorage.RestoreResult.RESTORED) nativeRestored++;
else if (result != EquipmentStorage.RestoreResult.NOT_FOUND) failed++;
} catch (RuntimeException uncertain) {
diagnostic(attempt, uncertain);
failed++;
}
}
return new DisableSummary(pendingEntries.size(), activeSessions.size(), nativeRestored,
failed, activeSessions.size() - failed);
}
public synchronized void cancelEntry(UUID player) {
@@ -52,6 +52,7 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
Instant activity;
Instant lastAttempt;
long generation;
long pulses;
Object pending;
State(EquipmentSessions.Active active, Instant activity) { this.active = active; this.activity = activity; }
}
@@ -67,6 +68,7 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
private final CombatSources sources;
private java.util.function.Function<CharacterRecord, BigInteger> bonusHealing = ignored -> BigInteger.ZERO;
private java.util.function.Function<CharacterRecord, BigInteger> quickerRegenerationContribution = ignored -> BigInteger.ZERO;
private java.util.function.Function<CharacterRecord, BigInteger> quickerRegenerationIIContribution = ignored -> BigInteger.ZERO;
private games.dmg.fantasystick.events.RecoveryEventBridge recoveryEvents;
private final Map<UUID, State> states = new HashMap<>();
private final ArrayDeque<UUID> order = new ArrayDeque<>();
@@ -98,7 +100,18 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
if (closed || task != null || !states.isEmpty()) throw new IllegalStateException("Recovery evaluation already started");
bonusHealing = Objects.requireNonNull(bonus);
}
public synchronized void attachEvents(games.dmg.fantasystick.events.RecoveryEventBridge events,java.util.function.Function<CharacterRecord,BigInteger> quickerContribution){if(closed||recoveryEvents!=null)throw new IllegalStateException("Recovery events already attached or recovery closed");recoveryEvents=Objects.requireNonNull(events);quickerRegenerationContribution=Objects.requireNonNull(quickerContribution);}
public synchronized void attachEvents(games.dmg.fantasystick.events.RecoveryEventBridge events,
java.util.function.Function<CharacterRecord, BigInteger> quickerContribution) {
attachEvents(events, quickerContribution, ignored -> BigInteger.ZERO);
}
public synchronized void attachEvents(games.dmg.fantasystick.events.RecoveryEventBridge events,
java.util.function.Function<CharacterRecord, BigInteger> quickerContribution,
java.util.function.Function<CharacterRecord, BigInteger> quickerIIContribution) {
if (closed || recoveryEvents != null) throw new IllegalStateException("Recovery events already attached or recovery closed");
recoveryEvents = Objects.requireNonNull(events);
quickerRegenerationContribution = Objects.requireNonNull(quickerContribution);
quickerRegenerationIIContribution = Objects.requireNonNull(quickerIIContribution);
}
public synchronized void install(org.bukkit.plugin.Plugin plugin) {
Objects.requireNonNull(plugin);
@@ -159,6 +172,7 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
} else {
state.activity = now;
state.lastAttempt = null;
state.pulses = 0;
state.generation++;
}
}
@@ -180,14 +194,19 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
state.lastAttempt = now; // Full-health checks are not banked for later replay.
return;
}
BigInteger exactAmount;BigInteger quickerContribution;
try { exactAmount = configured.amount().add(Objects.requireNonNull(bonusHealing.apply(record)));quickerContribution=Objects.requireNonNull(quickerRegenerationContribution.apply(record)); }
BigInteger exactAmount; BigInteger quickerContribution; BigInteger quickerIIContribution;
try {
exactAmount = configured.amount().add(Objects.requireNonNull(bonusHealing.apply(record)));
quickerContribution = Objects.requireNonNull(quickerRegenerationContribution.apply(record));
quickerIIContribution = Objects.requireNonNull(quickerRegenerationIIContribution.apply(record));
}
catch (RuntimeException failure) { state.lastAttempt = now; report(record.id(), failure); return; }
if (exactAmount.signum() < 0) { state.lastAttempt = now; report(record.id(), new IllegalStateException("Negative recovery bonus")); return; }
double amount = exactAmount.bitLength() > 1023 ? maximum - current : exactAmount.doubleValue();
double proposed = Math.min(maximum - current, amount);
if (!Double.isFinite(proposed) || proposed <= 0) return;
var audit=recoveryEvents==null?null:recoveryEvents.begin(player,state.active,record,current,proposed,configured.amount(),quickerContribution);
var audit=recoveryEvents==null?null:recoveryEvents.begin(player,state.active,record,current,proposed,
configured.amount(),quickerContribution,quickerIIContribution,state.activity,eligible,maximum,state.pulses+1);
var event = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
try{healingEvents.accept(event);}catch(RuntimeException failure){if(audit!=null)recoveryEvents.failed(audit,"native_healing_event_failed");throw failure;}
double accepted = Math.min(proposed, Math.min(maximum - current, event.getAmount()));
@@ -203,7 +222,8 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
catch (RuntimeException failure) { state.pending = null; state.lastAttempt = clock.instant();if(audit!=null)recoveryEvents.failed(audit,"native_health_limit_unavailable"); report(record.id(), failure); return; }
characters.saveHealth(record.id(), expected, target, limit).whenComplete((saved, failure) -> {
try {
nextTick.accept(() -> complete(state, token, generation, expected, current, configured.amount(), quickerContribution, audit, saved, failure));
nextTick.accept(() -> complete(state, token, generation, expected, current, configured.amount(),
quickerContribution, quickerIIContribution, audit, saved, failure));
} catch (RuntimeException rejected) {
synchronized (OutOfCombatRecovery.this) {
if (state.pending == token) { state.pending = null; state.lastAttempt = clock.instant(); }
@@ -214,7 +234,8 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
}
private synchronized void complete(State state, Object token, long generation, BigInteger expected,
double nativeBefore, BigInteger configuredBase,BigInteger quickerConfigured,games.dmg.fantasystick.events.RecoveryEventBridge.Action audit,java.util.Optional<CharacterRecord> saved, Throwable failure) {
double nativeBefore, BigInteger configuredBase, BigInteger quickerConfigured, BigInteger quickerIIConfigured,
games.dmg.fantasystick.events.RecoveryEventBridge.Action audit,java.util.Optional<CharacterRecord> saved, Throwable failure) {
if (state.pending != token) return;
state.pending = null;
if (failure != null || saved == null || saved.isEmpty()) {
@@ -238,7 +259,14 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
double publish = Math.min(maximum, persisted.health().doubleValue());
if (!Double.isFinite(maximum) || !Double.isFinite(publish) || publish <= nativeBefore) {if(audit!=null)recoveryEvents.failed(audit,"saved_but_no_native_heal_available");return;}
try{player.setHealth(publish);}catch(RuntimeException nativeFailure){if(audit!=null)recoveryEvents.failed(audit,"native_health_publication_failed");throw nativeFailure;}
if(audit!=null){var total=BigDecimal.valueOf(publish-nativeBefore);var base=total.min(new BigDecimal(configuredBase));var passive=total.subtract(base).min(new BigDecimal(quickerConfigured)).max(BigDecimal.ZERO);recoveryEvents.healed(audit,BigDecimal.valueOf(publish),total,base,passive);}
state.pulses++;
if(audit!=null){
var total=BigDecimal.valueOf(publish-nativeBefore);
var base=total.min(new BigDecimal(configuredBase));
var quicker=total.subtract(base).min(new BigDecimal(quickerConfigured)).max(BigDecimal.ZERO);
var quickerII=total.subtract(base).subtract(quicker).min(new BigDecimal(quickerIIConfigured)).max(BigDecimal.ZERO);
recoveryEvents.healed(audit,BigDecimal.valueOf(publish),total,base,quicker,quickerII);
}
if (feedback != null) feedback.report("Out-of-Combat Recovery", player, AbilityReport.Type.HEALING, player,
BigDecimal.valueOf(publish - nativeBefore).stripTrailingZeros());
}
@@ -0,0 +1,261 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import games.dmg.fantasystick.persistence.CharacterStorage;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** The registered grant and authoritative saved revision, not command feedback, own this event. */
class AdminExperienceEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void exactUnicodeActiveCharacterGrantPersistsAndExportsOnlyAcknowledgedProgress() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
storage.renameCharacter(target.core.record.id(), "Mira 雪").get();
f.players.put(target.core.id, target.player); f.activate(target);
var before = storage.find(target.core.record.id()).get().orElseThrow();
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"experience", "give", "Mira", "雪", "250"});
storage.find(target.core.record.id()).get(); f.tick();
var after = storage.find(target.core.record.id()).get().orElseThrow();
assertEquals(before.experience().add(BigInteger.valueOf(250)), after.experience());
assertEquals(3, after.level());
for (int i = 0; i < 200 && grants().isEmpty(); i++) Thread.sleep(10);
var exported = grants(); assertEquals(1, exported.size());
var values = exported.getFirst().getAsJsonObject("data");
assertEquals(target.core.id.toString(), values.get("target_player_id").getAsString());
assertEquals(target.core.record.id().toString(), values.get("target_character_id").getAsString());
assertEquals(before.revision().toString(), values.get("before_revision").getAsString());
assertEquals(after.revision().toString(), values.get("after_revision").getAsString());
assertEquals(before.unspentPoints().toString(), values.get("before_unspent_points").getAsString());
assertEquals(after.unspentPoints().toString(), values.get("after_unspent_points").getAsString());
assertTrue(values.get("same_session_feedback_delivered").getAsBoolean());
assertTrue(values.has("target_acceptance_location"));
f.plugin.setEnabled(false);
}
var entries = grants(); assertEquals(1, entries.size(), "Only exported JSONL proves a grant event");
var data = entries.getFirst().getAsJsonObject("data");
assertEquals("Mira 雪", data.get("target_character_name").getAsString());
assertEquals("250", data.get("requested_xp").getAsString());
assertEquals("250", data.get("applied_xp").getAsString());
assertEquals("2", data.get("levels_gained").getAsString());
}
@Test void refusedInvalidAndAlreadyCappedRequestsCannotInventASecondGrant() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
storage.renameCharacter(target.core.record.id(), "Árvíztűrő Tükörfúrógép").get();
f.players.put(target.core.id, target.player); f.activate(target);
var admin = new EquipmentAdminCommandNativeTest.Sender(); admin.allowed = false;
var command = new String[]{"experience", "give", "Árvíztűrő", "Tükörfúrógép", "9"};
f.adminCommand.execute(admin.port, "fstickadmin", command);
admin.allowed = true;
for (var invalid : List.of("0", "-1", "1.5", "oops")) {
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"experience", "give", "Árvíztűrő", "Tükörfúrógép", invalid});
}
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"experience", "give", "árvíztűrő", "Tükörfúrógép", "9"});
assertTrue(grants().isEmpty());
var huge = "9".repeat(90);
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"experience", "give", "Árvíztűrő", "Tükörfúrógép", huge});
storage.find(target.core.record.id()).get(); f.tick();
for (int i = 0; i < 200 && grants().isEmpty(); i++) Thread.sleep(10);
var entries = grants(); assertEquals(1, entries.size());
var values = entries.getFirst().getAsJsonObject("data");
assertEquals(huge, values.get("requested_xp").getAsString());
assertTrue(values.get("cap_clamped").getAsBoolean());
assertTrue(new BigInteger(values.get("applied_xp").getAsString()).compareTo(new BigInteger(huge)) < 0);
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"experience", "give", "Árvíztűrő", "Tükörfúrógép", "20"});
storage.find(target.core.record.id()).get(); f.tick();
f.plugin.setEnabled(false);
}
assertEquals(1, grants().size(), "A capped no-op, denial or invalid amount is not a grant");
}
@Test void unavailableWriterReplaysOnlyOriginalSavedGrantWithoutRegrantingOnRestart() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
storage.renameCharacter(target.core.record.id(), "Replay Hero").get();
f.players.put(target.core.id, target.player); f.activate(target);
var log = (games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog");
log.close();
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"experience", "give", "Replay", "Hero", "50"});
storage.find(target.core.record.id()).get(); f.tick();
assertEquals(BigInteger.valueOf(50), storage.find(target.core.record.id()).get()
.orElseThrow().experience());
f.plugin.setEnabled(false);
assertTrue(grants().isEmpty(), "Pending intents are not exported evidence");
f.plugin.setEnabled(true);
for (int i = 0; i < 200 && grants().isEmpty(); i++) Thread.sleep(10);
assertEquals(BigInteger.valueOf(50), ((CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "characters")).find(target.core.record.id()).get().orElseThrow().experience());
f.plugin.setEnabled(false);
}
var entries = grants(); assertEquals(1, entries.size());
assertEquals("50", entries.getFirst().getAsJsonObject("data").get("applied_xp").getAsString());
}
@Test void physicalAdministratorIdentityAndAcceptanceLocationsAreCapturedBeforeDurableGrant() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
storage.renameCharacter(target.core.record.id(), "Scout 雪").get();
f.players.put(target.core.id, target.player); f.activate(target);
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
doReturn("Administrator").when(admin.player).getName();
f.players.put(admin.core.id, admin.player);
f.adminCommand.execute(admin.player, "fstickadmin", new String[]{
"experience", "give", "Scout", "雪", "7"});
storage.find(target.core.record.id()).get(); f.tick();
for (int i = 0; i < 200 && grants().isEmpty(); i++) Thread.sleep(10);
var entries = grants(); assertEquals(1, entries.size());
var values = entries.getFirst().getAsJsonObject("data");
assertEquals(admin.core.id.toString(), values.get("player_id").getAsString());
assertEquals("Administrator", values.get("player_name").getAsString());
assertEquals(admin.core.location.getX(), values.getAsJsonObject("location").get("x").getAsDouble());
assertEquals(target.core.location.getX(), values.getAsJsonObject("target_acceptance_location")
.get("x").getAsDouble());
f.plugin.setEnabled(false);
}
}
@Test void closedLifetimeKeepsDurableGrantButCannotInventNativeFeedback() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
storage.renameCharacter(target.core.record.id(), "Shutdown Hero").get();
f.players.put(target.core.id, target.player); f.activate(target);
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"experience", "give", "Shutdown", "Hero", "25"});
storage.find(target.core.record.id()).get();
assertEquals(BigInteger.valueOf(25), storage.find(target.core.record.id()).get()
.orElseThrow().experience());
f.plugin.setEnabled(false);
f.plugin.setEnabled(true);
for (int i = 0; i < 200 && grants().isEmpty(); i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var entries = grants(); assertEquals(1, entries.size());
assertFalse(entries.getFirst().getAsJsonObject("data").get("same_session_feedback_delivered").getAsBoolean());
}
@Test void staleRevisionAndAuditStoreFailureCannotFabricateOrUndoAnExperienceGrant() throws Exception {
Files.writeString(data.resolve("admin-experience-event-intents"), "blocked");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
storage.renameCharacter(target.core.record.id(), "Old Hero").get();
f.players.put(target.core.id, target.player); f.activate(target);
storage.renameCharacter(target.core.record.id(), "New Hero").get();
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"experience", "give", "Old", "Hero", "5"});
storage.find(target.core.record.id()).get(); f.tick();
assertEquals(BigInteger.ZERO, storage.find(target.core.record.id()).get().orElseThrow().experience());
assertTrue(grants().isEmpty());
var fresh = new FantasyStickLifecycleNativeTest.Actor(data);
storage.renameCharacter(fresh.core.record.id(), "Fresh Hero").get();
f.players.put(fresh.core.id, fresh.player); f.activate(fresh);
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"experience", "give", "Fresh", "Hero", "5"});
storage.find(fresh.core.record.id()).get(); f.tick();
assertEquals(BigInteger.valueOf(5), storage.find(fresh.core.record.id()).get()
.orElseThrow().experience());
f.plugin.setEnabled(false);
}
assertTrue(grants().isEmpty(), "The rejected audit store cannot invent exported success");
}
@Test void failedTargetFeedbackCannotUndoDurableGrantOrClaimDeliveredSessionFeedback() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
storage.renameCharacter(target.core.record.id(), "Feedback Hero").get();
f.players.put(target.core.id, target.player); f.activate(target);
doThrow(new IllegalStateException("feedback failed")).when(target.player)
.sendMessage(org.mockito.ArgumentMatchers.anyString());
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"experience", "give", "Feedback", "Hero", "33"});
storage.find(target.core.record.id()).get(); f.tick();
assertEquals(BigInteger.valueOf(33), storage.find(target.core.record.id()).get()
.orElseThrow().experience());
for (int i = 0; i < 200 && grants().isEmpty(); i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var entries = grants(); assertEquals(1, entries.size());
assertFalse(entries.getFirst().getAsJsonObject("data").get("same_session_feedback_delivered").getAsBoolean());
}
@Test void ambiguousExactActiveNameCannotChooseAHolderOrPublishGrant() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var first = new FantasyStickLifecycleNativeTest.Actor(data);
var second = new FantasyStickLifecycleNativeTest.Actor(data);
storage.renameCharacter(first.core.record.id(), "Twin 雪").get();
storage.renameCharacter(second.core.record.id(), "Twin 雪").get();
f.players.put(first.core.id, first.player); f.players.put(second.core.id, second.player);
f.activate(first); f.activate(second);
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"experience", "give", "Twin", "雪", "100"});
assertTrue(admin.messages.getLast().contains("ambiguous"));
assertEquals(BigInteger.ZERO, storage.find(first.core.record.id()).get().orElseThrow().experience());
assertEquals(BigInteger.ZERO, storage.find(second.core.record.id()).get().orElseThrow().experience());
f.plugin.setEnabled(false);
}
assertTrue(grants().isEmpty());
}
private List<JsonObject> grants() throws Exception {
var entries = new ArrayList<JsonObject>(); var root = data.resolve("events");
if (!Files.isDirectory(root)) return entries;
try (var days = Files.list(root)) {
for (var day : days.filter(p -> p.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")
&& Files.isDirectory(p, java.nio.file.LinkOption.NOFOLLOW_LINKS)).toList()) {
try (var segments = Files.list(day)) {
for (var path : segments.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList()) {
var content = Files.readString(path); int end = content.lastIndexOf('\n');
if (end < 0) continue;
for (var line : content.substring(0, end).lines().toList()) if (!line.isBlank()) {
var event = JsonParser.parseString(line).getAsJsonObject();
if (event.get("type").getAsString().endsWith("experience.granted")) entries.add(event);
}
}
}
}
}
return entries;
}
}
@@ -145,7 +145,21 @@ class AdventureQuestNativeTest {
for(Runnable pulse:List.copyOf(f.pulses))pulse.run();for(int retry=0;retry<10&&spawned.stream().filter(entity->!(entity instanceof Villager)).count()<4;retry++)drainOne(f);
var enemies=spawned.stream().filter(entity->!(entity instanceof Villager)).toList();assertEquals(4,enemies.size());assertTrue(controller.hasActiveEncounter());
var enemy=enemies.getFirst();assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CAMP,controller.encounterOrigin(enemy).orElseThrow().kind());when(enemy.getLocation()).thenReturn(new Location(actor.world,siteRecord.x()+11,siteRecord.y(),siteRecord.z()));when(actor.world.getEntities()).thenReturn(new ArrayList<Entity>(spawned));for(Runnable pulse:List.copyOf(f.pulses))pulse.run();verify(enemy).teleport(argThat((Location location)->location.getX()==siteRecord.x()&&location.getY()==siteRecord.y()&&location.getZ()==siteRecord.z()));
var enemy=enemies.getFirst();assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CAMP,controller.encounterOrigin(enemy).orElseThrow().kind());
var unrelated=mock(org.bukkit.entity.Cow.class);
when(unrelated.getUniqueId()).thenReturn(UUID.randomUUID());
var copiedCampTag=enemy.getPersistentDataContainer();
when(unrelated.getPersistentDataContainer()).thenReturn(copiedCampTag);
assertTrue(controller.encounterOrigin(unrelated).isEmpty(),
"a passive with a copied camp tag is not a registered spawn");
var nativeDrop=new org.bukkit.inventory.ItemStack(Material.BEEF);
var unrelatedDeath=new org.bukkit.event.entity.EntityDeathEvent(unrelated,
mock(org.bukkit.damage.DamageSource.class),new ArrayList<>(List.of(nativeDrop)),7);
f.dispatcher.accept(unrelatedDeath);
assertEquals(7,unrelatedDeath.getDroppedExp());
assertEquals(List.of(nativeDrop),unrelatedDeath.getDrops(),
"an unrelated in-arena passive cannot consume camp completion or native loot");
when(enemy.getLocation()).thenReturn(new Location(actor.world,siteRecord.x()+11,siteRecord.y(),siteRecord.z()));when(actor.world.getEntities()).thenReturn(new ArrayList<Entity>(spawned));for(Runnable pulse:List.copyOf(f.pulses))pulse.run();verify(enemy).teleport(argThat((Location location)->location.getX()==siteRecord.x()&&location.getY()==siteRecord.y()&&location.getZ()==siteRecord.z()));
var outsider=new FantasyStickLifecycleNativeTest.Actor(data);outsider.core.location.setWorld(actor.world);f.players.put(outsider.core.id,outsider.player);var outsiderSource=mock(org.bukkit.damage.DamageSource.class);when(outsiderSource.getCausingEntity()).thenReturn(outsider.player);when(outsiderSource.getDirectEntity()).thenReturn(outsider.player);var refusedHit=new org.bukkit.event.entity.EntityDamageByEntityEvent(outsider.player,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,outsiderSource,1);f.dispatcher.accept(refusedHit);assertTrue(refusedHit.isCancelled());var enemySource=mock(org.bukkit.damage.DamageSource.class);when(enemySource.getCausingEntity()).thenReturn(enemy);when(enemySource.getDirectEntity()).thenReturn(enemy);var refusedAttack=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,outsider.player,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,enemySource,1);f.dispatcher.accept(refusedAttack);assertTrue(refusedAttack.isCancelled());var targetEvent=new org.bukkit.event.entity.EntityTargetLivingEntityEvent(enemy,outsider.player,org.bukkit.event.entity.EntityTargetEvent.TargetReason.CLOSEST_PLAYER);f.dispatcher.accept(targetEvent);assertTrue(targetEvent.isCancelled());
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,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());
@@ -155,8 +169,243 @@ class AdventureQuestNativeTest {
for(int i=0;i<50&&(directory.active().size()!=1||trophies.trophies(actor.core.record.id()).isEmpty()||actor.core.inventory.getItem(0).getType()!=Material.BOOK);i++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}
var banked=storage.find(actor.core.record.id()).get().orElseThrow();assertEquals(java.math.BigInteger.ZERO,banked.experience());assertEquals(actorCoins,banked.coins());assertEquals(games.dmg.fantasystick.adventure.FishingState.TrophyType.CAMP,trophies.trophies(actor.core.record.id()).getFirst().type());assertEquals(Material.BOOK,actor.core.inventory.getItem(0).getType(),"completion restores the US-069 book control");assertEquals(1,directory.active().size(),"Unrepresented party retains its quest");assertEquals(java.math.BigInteger.ZERO,storage.find(partyA.core.record.id()).get().orElseThrow().experience());assertEquals(java.math.BigInteger.ZERO,storage.find(partyB.core.record.id()).get().orElseThrow().experience());
var soloTrophy=trophies.trophies(actor.core.record.id()).getFirst();assertEquals("Spoils of Dawn Camp",soloTrophy.name());assertEquals("Dawn Camp",soloTrophy.origin());assertEquals(new games.dmg.fantasystick.adventure.FishingState.Reward(java.math.BigInteger.valueOf(100),java.math.BigInteger.valueOf(20)),soloTrophy.total());assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward.empty(),directory.unclaimedAll(actor.core.record.id()),"camp clear does not immediately unlock rewards");
int priorEnemies=enemies.size();for(Runnable pulse:List.copyOf(f.pulses))pulse.run();for(int retry=0;retry<10&&spawned.stream().filter(entity->!(entity instanceof Villager)).count()<priorEnemies+4;retry++)drainOne(f);var second=spawned.stream().filter(entity->!(entity instanceof Villager)).skip(priorEnemies).toList();assertEquals(4,second.size());var partySource=mock(org.bukkit.damage.DamageSource.class);when(partySource.getCausingEntity()).thenReturn(partyA.player);when(partySource.getDirectEntity()).thenReturn(partyA.player);var partyHit=new org.bukkit.event.entity.EntityDamageByEntityEvent(partyA.player,second.getFirst(),org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1);f.dispatcher.accept(partyHit);partyA.core.location.setX(100);doReturn(false).when(partyA.player).isOnline();for(var value:second)f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(value,partySource,new ArrayList<>(),0));await(f,()->directory.active().isEmpty()&&!trophies.trophies(partyA.core.record.id()).isEmpty());var partyTrophy=trophies.trophies(partyA.core.record.id()).getFirst();assertEquals(games.dmg.fantasystick.adventure.FishingState.TrophyType.CAMP,partyTrophy.type());assertEquals(Set.of(partyB.core.record.id()),partyTrophy.rewards().keySet(),"absent leader carries the spoils without gaining a share");assertEquals(new games.dmg.fantasystick.adventure.FishingState.Reward(java.math.BigInteger.valueOf(100),java.math.BigInteger.valueOf(20)),partyTrophy.total());assertEquals(java.math.BigInteger.ZERO,storage.find(partyA.core.record.id()).get().orElseThrow().experience());assertEquals(java.math.BigInteger.ZERO,storage.find(partyB.core.record.id()).get().orElseThrow().experience());var completedCatalog=directory.snapshot().catalog();assertFalse(directory.snapshot().rosters().isEmpty());f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll","confirm"});for(int retry=0;retry<15&&(directory.snapshot().catalog().revision().equals(completedCatalog.revision())||directory.snapshot().reset()!=null);retry++)drainOne(f);var replacement=directory.snapshot().catalog();assertNotEquals(completedCatalog.revision(),replacement.revision());assertTrue(directory.snapshot().rosters().isEmpty());var turn=(games.dmg.fantasystick.adventure.FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");redeemAndClaim(f,turn,trophies,storage,actor,actorCoins,100,20);doReturn(true).when(partyA.player).isOnline();redeem(f,turn,partyA);claim(f,turn,trophies,storage,partyB,partyBCoins,100,20);assertEquals(partyACoins,storage.find(partyA.core.record.id()).get().orElseThrow().coins(),"carrier without a share receives no coins");assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Acceptance.APPLIED,directory.accept(replacement.revision(),replacement.sites().getFirst(),games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,actor.core.record.id(),Set.of(actor.core.record.id())).get(5,java.util.concurrent.TimeUnit.SECONDS).status());
int priorEnemies=enemies.size();for(Runnable pulse:List.copyOf(f.pulses))pulse.run();for(int retry=0;retry<10&&spawned.stream().filter(entity->!(entity instanceof Villager)).count()<priorEnemies+4;retry++)drainOne(f);var second=spawned.stream().filter(entity->!(entity instanceof Villager)).skip(priorEnemies).toList();assertEquals(4,second.size());var partySource=mock(org.bukkit.damage.DamageSource.class);when(partySource.getCausingEntity()).thenReturn(partyA.player);when(partySource.getDirectEntity()).thenReturn(partyA.player);var partyHit=new org.bukkit.event.entity.EntityDamageByEntityEvent(partyA.player,second.getFirst(),org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1);f.dispatcher.accept(partyHit);partyA.core.location.setX(100);doReturn(false).when(partyA.player).isOnline();for(var value:second)f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(value,partySource,new ArrayList<>(),0));await(f,()->directory.active().isEmpty()&&!trophies.trophies(partyA.core.record.id()).isEmpty());var partyTrophy=trophies.trophies(partyA.core.record.id()).getFirst();assertEquals(games.dmg.fantasystick.adventure.FishingState.TrophyType.CAMP,partyTrophy.type());assertEquals(Set.of(partyB.core.record.id()),partyTrophy.rewards().keySet(),"absent leader carries the spoils without gaining a share");assertEquals(new games.dmg.fantasystick.adventure.FishingState.Reward(java.math.BigInteger.valueOf(100),java.math.BigInteger.valueOf(20)),partyTrophy.total());assertEquals(java.math.BigInteger.ZERO,storage.find(partyA.core.record.id()).get().orElseThrow().experience());assertEquals(java.math.BigInteger.ZERO,storage.find(partyB.core.record.id()).get().orElseThrow().experience());var completedCatalog=directory.snapshot().catalog();assertFalse(directory.snapshot().rosters().isEmpty());f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll","confirm"});for(int retry=0;retry<15&&(directory.snapshot().catalog().revision().equals(completedCatalog.revision())||directory.snapshot().reset()!=null);retry++)drainOne(f);var replacement=directory.snapshot().catalog();assertNotEquals(completedCatalog.revision(),replacement.revision());assertTrue(directory.snapshot().rosters().isEmpty());var turn=(games.dmg.fantasystick.adventure.FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");
var giverLocation=directory.snapshot().givers().getFirst();
actor.core.location.setX(giverLocation.x());actor.core.location.setZ(giverLocation.z());
int beforeOffers=actor.inventories.size();
f.dispatcher.accept(new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.HAND));
await(f,()->actor.inventories.size()>beforeOffers);
var giverOffers=actor.inventories.getLast();
var turnEntry=mock(InventoryClickEvent.class);when(turnEntry.getWhoClicked()).thenReturn(actor.player);
when(turnEntry.getInventory()).thenReturn(giverOffers);
when(turnEntry.getCurrentItem()).thenReturn(item(giverOffers,"Turn in quest items"));
controller.click(turnEntry);
var turnMenu=actor.inventories.getLast();int beforeClaimMenu=actor.inventories.size();
click(turn,actor,turnMenu,item(turnMenu,"Redeem Spoils of"));
await(f,()->trophies.trophies(actor.core.record.id()).isEmpty()
&&actor.inventories.size()>beforeClaimMenu);
var claimMenu=actor.inventories.getLast();
click(turn,actor,claimMenu,item(claimMenu,"Claim earned quest rewards"));
await(f,()->trophies.unclaimed(actor.core.record.id()).equals(
games.dmg.fantasystick.adventure.FishingState.Reward.empty()));
var paid=storage.find(actor.core.record.id()).get().orElseThrow();
assertEquals(java.math.BigInteger.valueOf(100),paid.experience());
assertEquals(actorCoins.add(java.math.BigInteger.valueOf(20)),paid.coins());doReturn(true).when(partyA.player).isOnline();redeem(f,turn,partyA);claim(f,turn,trophies,storage,partyB,partyBCoins,100,20);assertEquals(partyACoins,storage.find(partyA.core.record.id()).get().orElseThrow().coins(),"carrier without a share receives no coins");assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Acceptance.APPLIED,directory.accept(replacement.revision(),replacement.sites().getFirst(),games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,actor.core.record.id(),Set.of(actor.core.record.id())).get(5,java.util.concurrent.TimeUnit.SECONDS).status());
}
var exported=exportedCampEvents(data);
var earned=exported.stream().filter(event->event.get("type").getAsString()
.endsWith("adventure.trophy_earned")).toList();
var redeemed=exported.stream().filter(event->event.get("type").getAsString()
.endsWith("adventure.trophy_redeemed")).toList();
var claims=exported.stream().filter(event->event.get("type").getAsString()
.endsWith("adventure.reward_claimed")&&event.getAsJsonObject("data")
.getAsJsonArray("sources").asList().stream().anyMatch(source->source
.getAsJsonObject().get("source_kind").getAsString().equals("camp_trophy"))).toList();
assertEquals(2,earned.size(),"two native represented camp clears export separate earned receipts");
assertEquals(2,redeemed.size(),"exact earned camp spoils unlock once per carrier");
assertTrue(redeemed.stream().anyMatch(event->event.getAsJsonObject("data")
.get("giver_status").getAsString().equals("entered_via_registered_giver")),
"at least one redemption must follow the registered Quest Giver menu");
assertEquals(2,claims.size(),"each beneficiary claims only their saved camp allocation");
assertTrue(claims.stream().anyMatch(event->event.getAsJsonObject("data")
.get("giver_status").getAsString().equals("entered_via_registered_giver")),
"the redeemed camp's beneficiary claims through the same registered Giver");
assertEquals(2,earned.stream().map(event->event.getAsJsonObject("data")
.get("receipt_id").getAsString()).distinct().count());
assertTrue(earned.stream().allMatch(event->event.getAsJsonObject("data")
.get("trophy_type").getAsString().equals("camp_trophy")));
assertTrue(earned.stream().allMatch(event->event.getAsJsonObject("data")
.getAsJsonObject("arena").get("shape").getAsString().equals("rectangle")));
assertTrue(earned.stream().allMatch(event->event.getAsJsonObject("data")
.getAsJsonObject("clear_location").has("world_id")));
assertTrue(earned.stream().allMatch(event->event.getAsJsonObject("data")
.get("receipt_revision").getAsString().equals("2")));
var partyEarned=earned.stream().map(event->event.getAsJsonObject("data"))
.filter(value->!value.get("carrier_is_beneficiary").getAsBoolean())
.findFirst().orElseThrow();
assertEquals("party",partyEarned.get("unit_kind").getAsString());
assertEquals(2,partyEarned.getAsJsonArray("accepted_roster").size());
assertEquals(1,partyEarned.getAsJsonArray("beneficiaries").size());
assertEquals("100",partyEarned.get("reward_xp_pool").getAsString());
assertEquals("20",partyEarned.get("reward_coin_pool").getAsString());
var partyRedemption=redeemed.stream().map(event->event.getAsJsonObject("data"))
.filter(value->value.get("receipt_id").getAsString()
.equals(partyEarned.get("receipt_id").getAsString())).findFirst().orElseThrow();
assertEquals(partyEarned.get("encounter_id").getAsString(),
partyRedemption.get("encounter_id").getAsString());
assertTrue(partyRedemption.getAsJsonArray("beneficiaries").get(0).getAsJsonObject()
.has("allocation_id"),"a positive frozen camp share keeps its exact unlock identity");
assertEquals("rectangle",partyRedemption.getAsJsonObject("arena")
.get("shape").getAsString());
assertEquals(8,exported.stream().filter(event->event.get("type").getAsString()
.endsWith("combat.reward_resolved")&&event.getAsJsonObject("data").has("managed_origin_kind")
&&event.getAsJsonObject("data").get("managed_origin_kind").getAsString().equals("camp")
&&event.getAsJsonObject("data").get("resolution").getAsString()
.equals("encounter_suppressed")).count());
}
@SuppressWarnings({"unchecked", "rawtypes"})
@Test void registeredCircularCampClearExportsItsFrozenArenaAndFinalEnemy() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"adventure-daily-site-count: 1\nadventure-site-reset-seconds: 0\nadventure-camp-enemies: 4\n");
UUID finalEnemy;
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 world=actor.world;var worldId=UUID.randomUUID();when(world.getUID()).thenReturn(worldId);
when(world.getName()).thenReturn("Circle Realm");when(world.isChunkLoaded(anyInt(),anyInt()))
.thenReturn(true);
var chunk=mock(Chunk.class);when(chunk.getWorld()).thenReturn(world);
when(chunk.getX()).thenReturn(actor.core.location.getBlockX()>>4);
when(chunk.getZ()).thenReturn(actor.core.location.getBlockZ()>>4);
when(chunk.getEntities()).thenReturn(new Entity[0]);
when(world.getChunkAt(anyInt(),anyInt())).thenReturn(chunk);
when(world.getLoadedChunks()).thenReturn(new Chunk[]{chunk});
var spawned=new ArrayList<LivingEntity>();
doAnswer(call->{Class<?> type=call.getArgument(1);
var entity=mock(type.asSubclass(LivingEntity.class));
when(entity.getPersistentDataContainer()).thenReturn(
new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
when(entity.getUniqueId()).thenReturn(UUID.randomUUID());
when(entity.getWorld()).thenReturn(world);
when(entity.getLocation()).thenReturn(((Location)call.getArgument(0)).clone());
((java.util.function.Consumer<LivingEntity>)call.getArgument(2)).accept(entity);
spawned.add(entity);return entity;
}).when(world).spawn(any(Location.class),any(Class.class),any(java.util.function.Consumer.class));
f.command.execute(actor.player,"fantasystick",
new String[]{"adventure-site","place","camp","10","Round Trial"});drainOne(f);
var directory=(games.dmg.fantasystick.adventure.AdventureQuestDirectory)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
var old=directory.snapshot();var snapshot=directory.getClass().getDeclaredField("snapshot");
snapshot.setAccessible(true);snapshot.set(directory,
new games.dmg.fantasystick.adventure.AdventureQuestState(old.sites(),old.givers(),
null,old.quests(),old.unitClaims(),old.characterClaims(),old.rosters()));
var catalog=directory.ensureToday().get(5,java.util.concurrent.TimeUnit.SECONDS);
var site=catalog.sites().getFirst();f.activate(actor);
assertEquals(games.dmg.fantasystick.adventure.AdventureQuestState.Arena.circle(10),
directory.findSite(site).orElseThrow().arena());
assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Acceptance.APPLIED,
directory.accept(catalog.revision(),site,
games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,
actor.core.record.id(),Set.of(actor.core.record.id()))
.get(5,java.util.concurrent.TimeUnit.SECONDS).status());
for(Runnable pulse:List.copyOf(f.pulses))pulse.run();
await(f,()->spawned.size()==4);
var source=mock(org.bukkit.damage.DamageSource.class);
when(source.getCausingEntity()).thenReturn(actor.player);
when(source.getDirectEntity()).thenReturn(actor.player);
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,
spawned.getFirst(),org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,4));
finalEnemy=spawned.getLast().getUniqueId();
for(var enemy:spawned)f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(
enemy,source,new ArrayList<>(),0));
var trophies=(games.dmg.fantasystick.adventure.FishingDirectory)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");
await(f,()->!trophies.trophies(actor.core.record.id()).isEmpty());
assertEquals("Round Trial",trophies.trophies(actor.core.record.id()).getFirst().origin());
f.command.execute(actor.player,"fantasystick",
new String[]{"adventure-site","remove",site.toString()});
await(f,()->directory.findSite(site).isEmpty());
var storage=(games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");
var beforeCoins=storage.find(actor.core.record.id()).get().orElseThrow().coins();
var turn=(games.dmg.fantasystick.adventure.FishingController)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");
redeem(f,turn,actor);
claim(f,turn,trophies,storage,actor,beforeCoins,100,20);
}
var earned=exportedCampEvents(data).stream().filter(event->event.get("type").getAsString()
.endsWith("adventure.trophy_earned")).toList();
assertEquals(1,earned.size());
var evidence=earned.getFirst().getAsJsonObject("data");
assertEquals("circle",evidence.getAsJsonObject("arena").get("shape").getAsString());
assertEquals(10,evidence.getAsJsonObject("arena").get("x_blocks").getAsInt());
assertEquals(finalEnemy.toString(),evidence.get("final_enemy_id").getAsString());
assertTrue(evidence.getAsJsonObject("clear_location").has("world_id"));
var redemption=exportedCampEvents(data).stream().filter(event->event.get("type")
.getAsString().endsWith("adventure.trophy_redeemed")).toList();
assertEquals(1,redemption.size(),"deleted-circle site cannot expire an earned camp item");
assertEquals(evidence.get("site_id").getAsString(),redemption.getFirst()
.getAsJsonObject("data").get("site_id").getAsString());
}
@Test void failedCampCommitRestartsWithSavedArenaAndOneExportedEarnedEvent() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"adventure-daily-site-count: 1\nadventure-camp-enemies: 4\n");
UUID carrier,receiptId=UUID.randomUUID(),finalEnemy=UUID.randomUUID();
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);f.activate(actor);
doReturn(true).when(actor.player).hasPermission("fantasystick.admin");
when(actor.world.getUID()).thenReturn(UUID.randomUUID());
f.command.execute(actor.player,"fantasystick",
new String[]{"adventure-site","place","camp","10","Restart Circle"});drainOne(f);
var quests=(games.dmg.fantasystick.adventure.AdventureQuestDirectory)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
var old=quests.snapshot();var snapshot=quests.getClass().getDeclaredField("snapshot");
snapshot.setAccessible(true);snapshot.set(quests,
new games.dmg.fantasystick.adventure.AdventureQuestState(old.sites(),old.givers(),
null,old.quests(),old.unitClaims(),old.characterClaims(),old.rosters()));
var catalog=quests.ensureToday().get(5,java.util.concurrent.TimeUnit.SECONDS);
carrier=actor.core.record.id();
var site=catalog.sites().getFirst();
assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Acceptance.APPLIED,
quests.accept(catalog.revision(),site,
games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,carrier,
Set.of(carrier)).get(5,java.util.concurrent.TimeUnit.SECONDS).status());
var quest=quests.activeFor(carrier,null).orElseThrow();
var audit=new java.util.LinkedHashMap<String,String>();
audit.put("final_enemy_id",finalEnemy.toString());audit.put("unit_kind","solo");
audit.put("unit_id",carrier.toString());audit.put("accepted_roster",carrier.toString());
audit.put("arena_shape","circle");audit.put("arena_x_blocks","10");
audit.put("arena_z_blocks","10");audit.put("defeat_world_id",actor.world.getUID().toString());
audit.put("defeat_world_name","Circle Realm");audit.put("defeat_x","12.0");
audit.put("defeat_y","64.0");audit.put("defeat_z","-8.0");
audit.put("defeated_at",java.time.Instant.now().truncatedTo(
java.time.temporal.ChronoUnit.SECONDS).toString());
var plan=new games.dmg.fantasystick.adventure.FishingState.Trophy(receiptId,
games.dmg.fantasystick.adventure.FishingState.TrophyType.CAMP,carrier,
quest.catalog(),quest.site(),quest.id(),UUID.randomUUID(),
"Spoils of Restart Circle","Restart Circle",
Map.of(carrier,new games.dmg.fantasystick.adventure.FishingState.Reward(
java.math.BigInteger.valueOf(100),java.math.BigInteger.valueOf(20))),
games.dmg.fantasystick.adventure.FishingState.TrophyPhase.PREPARED,audit);
var fishing=(games.dmg.fantasystick.adventure.FishingDirectory)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");
assertEquals(games.dmg.fantasystick.adventure.FishingDirectory.TrophyPrepareStatus.APPLIED,
fishing.prepareTrophy(plan).get(5,java.util.concurrent.TimeUnit.SECONDS));
var file=data.resolve("fishing.yml");var saved=java.nio.file.Files.readAllBytes(file);
java.nio.file.Files.delete(file);java.nio.file.Files.createDirectory(file);
var controller=(games.dmg.fantasystick.adventure.FishingController)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");
assertThrows(java.util.concurrent.ExecutionException.class,()->controller
.earnTrophyWithEvidence(plan).toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS));
assertTrue(fishing.trophies(carrier).isEmpty(),"failed commit cannot turn prepared proof into stock");
java.nio.file.Files.delete(file);java.nio.file.Files.write(file,saved);
f.plugin.setEnabled(false);
}
try(var restarted=new FantasyStickLifecycleNativeTest.Fixture(data)){
restarted.plugin.setEnabled(true);
var fishing=(games.dmg.fantasystick.adventure.FishingDirectory)
FantasyStickLifecycleNativeTest.Fixture.field(restarted.plugin,"fishing");
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(),()->"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());
assertEquals("circle",details.getAsJsonObject("arena").get("shape").getAsString());
assertEquals("12.0",details.getAsJsonObject("clear_location").get("x").getAsString());
}
@SuppressWarnings({"unchecked", "rawtypes"})
@@ -533,6 +782,39 @@ 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"))
.flatMap(path->{try{return java.nio.file.Files.lines(path);}
catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}})
.map(line->com.google.gson.JsonParser.parseString(line).getAsJsonObject()).toList();
}
}
private static void redeemAndClaim(FantasyStickLifecycleNativeTest.Fixture f,games.dmg.fantasystick.adventure.FishingController turn,games.dmg.fantasystick.adventure.FishingDirectory trophies,games.dmg.fantasystick.persistence.CharacterStorage storage,FantasyStickLifecycleNativeTest.Actor actor,java.math.BigInteger initialCoins,long xp,long coins)throws Exception{redeem(f,turn,actor);claim(f,turn,trophies,storage,actor,initialCoins,xp,coins);}
private static void redeem(FantasyStickLifecycleNativeTest.Fixture f,games.dmg.fantasystick.adventure.FishingController turn,FantasyStickLifecycleNativeTest.Actor actor)throws Exception{turn.open(actor.player);var menu=actor.inventories.getLast();click(turn,actor,menu,item(menu,"Redeem Spoils of"));await(f,()->actor.inventories.size()>0&&menu!=actor.inventories.getLast());}
private static void claim(FantasyStickLifecycleNativeTest.Fixture f,games.dmg.fantasystick.adventure.FishingController turn,games.dmg.fantasystick.adventure.FishingDirectory trophies,games.dmg.fantasystick.persistence.CharacterStorage storage,FantasyStickLifecycleNativeTest.Actor actor,java.math.BigInteger initialCoins,long xp,long coins)throws Exception{turn.open(actor.player);var menu=actor.inventories.getLast();click(turn,actor,menu,item(menu,"Claim earned quest rewards"));await(f,()->trophies.unclaimed(actor.core.record.id()).equals(games.dmg.fantasystick.adventure.FishingState.Reward.empty()));var saved=storage.find(actor.core.record.id()).get().orElseThrow();assertEquals(java.math.BigInteger.valueOf(xp),saved.experience());assertEquals(initialCoins.add(java.math.BigInteger.valueOf(coins)),saved.coins());}
@@ -23,6 +23,8 @@ class ChampionQuestNativeTest {
@SuppressWarnings({"unchecked", "rawtypes"})
@Test void registeredChampionOfferNativeEncounterBossBarAndExactRewardUseConnectedPath() throws Exception {
UUID unrelatedOrdinaryId;
double redemptionX;
java.nio.file.Files.writeString(data.resolve("config.yml"),
"adventure-daily-site-count: 1\nadventure-site-reset-seconds: 0\n"+
"adventure-champion-health: 100\nadventure-champion-damage: 8\n"+
@@ -39,12 +41,250 @@ class ChampionQuestNativeTest {
f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","place","champion","20","60","Summit"});drainOne(f);f.command.execute(actor.player,"fantasystick",new String[]{"quest-giver","place","Guide"});drainOne(f);
var directory=(games.dmg.fantasystick.adventure.AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");var trophies=(games.dmg.fantasystick.adventure.FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");var current=directory.snapshot();var snapshotField=directory.getClass().getDeclaredField("snapshot");snapshotField.setAccessible(true);snapshotField.set(directory,new games.dmg.fantasystick.adventure.AdventureQuestState(current.sites(),current.givers(),null,current.quests(),current.unitClaims(),current.characterClaims(),current.rosters()));directory.ensureToday().get(5,java.util.concurrent.TimeUnit.SECONDS);
var controller=(games.dmg.fantasystick.adventure.AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");controller.reconcile(chunk);var giver=spawned.stream().filter(Villager.class::isInstance).map(Villager.class::cast).findFirst().orElseThrow();f.activate(actor);var member=new FantasyStickLifecycleNativeTest.Actor(data);member.core.location.setWorld(actor.world);f.players.put(member.core.id,member.player);f.activate(member);var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(actor.core.id).orElseThrow();assertTrue(parties.accept(member.core.id,parties.invite(actor.core.id,party.id(),member.core.id).orElseThrow().id()));f.dispatcher.accept(new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.HAND));drainOne(f);var menu=actor.inventories.getLast();assertEquals(Material.PAPER,menu.getItem(0).getType());var lore=menu.getItem(0).getItemMeta().lore().toString();assertTrue(lore.contains("Champion"));assertTrue(lore.contains("150 XP, 40 coins"));var click=mock(InventoryClickEvent.class);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getInventory()).thenReturn(menu);when(click.getCurrentItem()).thenReturn(menu.getItem(0));when(click.isRightClick()).thenReturn(true);controller.click(click);drainOne(f);
var solo=new FantasyStickLifecycleNativeTest.Actor(data);solo.core.location.setWorld(actor.world);f.players.put(solo.core.id,solo.player);f.activate(solo);var catalog=directory.snapshot().catalog();var site=catalog.sites().getFirst();assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Acceptance.APPLIED,directory.accept(catalog.revision(),site,games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,solo.core.record.id(),Set.of(solo.core.record.id())).get(5,java.util.concurrent.TimeUnit.SECONDS).status());var soloQuest=directory.activeFor(solo.core.record.id(),null).orElseThrow();assertTrue(directory.bank(soloQuest.id(),UUID.randomUUID(),Map.of(solo.core.record.id(),new games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward(java.math.BigInteger.valueOf(7),java.math.BigInteger.valueOf(3)))).get(5,java.util.concurrent.TimeUnit.SECONDS));var siteRecord=directory.findSite(site).orElseThrow();assertEquals(games.dmg.fantasystick.adventure.AdventureQuestState.Arena.rectangle(20,60),siteRecord.arena());actor.core.location.setZ(siteRecord.z()+25);member.core.location.setZ(siteRecord.z()+25);solo.core.location.setZ(siteRecord.z()+25);var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var initial=storage.find(actor.core.record.id()).get().orElseThrow();var memberInitial=storage.find(member.core.record.id()).get().orElseThrow();var soloInitial=storage.find(solo.core.record.id()).get().orElseThrow();for(Runnable pulse:List.copyOf(f.pulses))pulse.run();var champion=spawned.stream().filter(Vindicator.class::isInstance).map(Vindicator.class::cast).findFirst().orElseThrow();assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CHAMPION,controller.encounterOrigin(champion).orElseThrow().kind());verify(maxHealth).setBaseValue(100);verify(attack).setBaseValue(9);verify(champion).setHealth(100);verify(champion).customName(argThat(component->component.toString().contains("Daybreak Champion")));assertEquals(1,f.bossBars.size());verify(f.bossBars.getFirst()).addPlayer(actor.player);verify(f.bossBars.getFirst()).addPlayer(member.player);verify(f.bossBars.getFirst()).addPlayer(solo.player);assertFalse(actor.particleCalls.isEmpty());assertFalse(member.particleCalls.isEmpty());assertFalse(solo.particleCalls.isEmpty());int memberParticles=member.particleCalls.size();member.core.location.setX(100);for(Runnable pulse:List.copyOf(f.pulses))pulse.run();verify(f.bossBars.getFirst()).removePlayer(member.player);assertEquals(memberParticles,member.particleCalls.size());member.core.location.setX(siteRecord.x());for(Runnable pulse:List.copyOf(f.pulses))pulse.run();verify(f.bossBars.getFirst(),atLeast(2)).addPlayer(member.player);f.command.execute(actor.player,"fantasystick",new String[]{"config","adventure-champion-health","120"});drainOne(f);f.command.execute(actor.player,"fantasystick",new String[]{"config","adventure-champion-damage","10"});drainOne(f);var liveSettings=(games.dmg.fantasystick.persistence.AdventureQuestSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestSettings");for(int retry=0;retry<10&&liveSettings.rules().championDamage()!=10;retry++)drainOne(f);verify(maxHealth,times(1)).setBaseValue(100);verify(attack,times(1)).setBaseValue(9);
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);var cancelled=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,champion,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);cancelled.setCancelled(true);f.dispatcher.accept(cancelled);verify(f.bossBars.getFirst(),never()).setProgress(0.99);var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,champion,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());verify(f.bossBars.getFirst()).setProgress(0.99);actor.core.location.setX(100);var death=new org.bukkit.event.entity.EntityDeathEvent(champion,source,new ArrayList<>(),9);f.dispatcher.accept(death);assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CHAMPION,controller.encounterOrigin(champion).orElseThrow().kind(),"death processing retains immutable managed origin authority");assertEquals(0,death.getDroppedExp());assertTrue(death.getDrops().isEmpty());for(int i=0;i<50&&(directory.active().size()!=1||trophies.trophies(actor.core.record.id()).isEmpty());i++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}var saved=storage.find(actor.core.record.id()).get().orElseThrow();var savedMember=storage.find(member.core.record.id()).get().orElseThrow();assertEquals(initial.experience(),saved.experience());assertEquals(memberInitial.experience(),savedMember.experience());assertEquals(initial.restrictedKills(),saved.restrictedKills());assertEquals(memberInitial.restrictedKills(),savedMember.restrictedKills());var partyTrophy=trophies.trophies(actor.core.record.id()).getFirst();assertEquals(games.dmg.fantasystick.adventure.FishingState.TrophyType.CHAMPION,partyTrophy.type());assertEquals("Summit",partyTrophy.origin());assertEquals(java.math.BigInteger.valueOf(150),partyTrophy.total().xp());assertEquals(java.math.BigInteger.valueOf(40),partyTrophy.total().coins());assertFalse(partyTrophy.rewards().containsKey(actor.core.record.id()),"absent leader custody does not invent a beneficiary share");assertTrue(partyTrophy.rewards().containsKey(member.core.record.id()));assertEquals(games.dmg.fantasystick.adventure.FishingState.Reward.empty(),trophies.unclaimed(actor.core.record.id()));assertEquals(1,directory.active().size(),"Unrepresented solo quest remains");verify(f.bossBars.getFirst(),atLeastOnce()).removeAll();int memberParticlesAfterCompletion=member.particleCalls.size();
var solo=new FantasyStickLifecycleNativeTest.Actor(data);solo.core.location.setWorld(actor.world);f.players.put(solo.core.id,solo.player);f.activate(solo);var catalog=directory.snapshot().catalog();var site=catalog.sites().getFirst();assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Acceptance.APPLIED,directory.accept(catalog.revision(),site,games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,solo.core.record.id(),Set.of(solo.core.record.id())).get(5,java.util.concurrent.TimeUnit.SECONDS).status());var soloQuest=directory.activeFor(solo.core.record.id(),null).orElseThrow();assertTrue(directory.bank(soloQuest.id(),UUID.randomUUID(),Map.of(solo.core.record.id(),new games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward(java.math.BigInteger.valueOf(7),java.math.BigInteger.valueOf(3)))).get(5,java.util.concurrent.TimeUnit.SECONDS));var siteRecord=directory.findSite(site).orElseThrow();assertEquals(games.dmg.fantasystick.adventure.AdventureQuestState.Arena.rectangle(20,60),siteRecord.arena());actor.core.location.setZ(siteRecord.z()+25);member.core.location.setZ(siteRecord.z()+25);solo.core.location.setZ(siteRecord.z()+25);var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var initial=storage.find(actor.core.record.id()).get().orElseThrow();var memberInitial=storage.find(member.core.record.id()).get().orElseThrow();var soloInitial=storage.find(solo.core.record.id()).get().orElseThrow();for(Runnable pulse:List.copyOf(f.pulses))pulse.run();var champion=spawned.stream().filter(Vindicator.class::isInstance).map(Vindicator.class::cast).findFirst().orElseThrow();assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CHAMPION,controller.encounterOrigin(champion).orElseThrow().kind());
var unrelatedPassive=mock(org.bukkit.entity.Cow.class);
when(unrelatedPassive.getUniqueId()).thenReturn(UUID.randomUUID());
var championData=champion.getPersistentDataContainer();
when(unrelatedPassive.getPersistentDataContainer()).thenReturn(championData);
assertTrue(controller.encounterOrigin(unrelatedPassive).isEmpty(),
"copied tags on an unrelated passive cannot acquire a registered managed origin");
var nativeDrop=new org.bukkit.inventory.ItemStack(Material.BEEF);
var foreignDeath=new org.bukkit.event.entity.EntityDeathEvent(unrelatedPassive,
mock(org.bukkit.damage.DamageSource.class),new ArrayList<>(List.of(nativeDrop)),7);
f.dispatcher.accept(foreignDeath);
assertEquals(7,foreignDeath.getDroppedExp(),"unowned passive keeps native XP");
assertEquals(List.of(nativeDrop),foreignDeath.getDrops(),"unowned passive keeps native loot");
verify(maxHealth).setBaseValue(100);verify(attack).setBaseValue(9);verify(champion).setHealth(100);verify(champion).customName(argThat(component->component.toString().contains("Daybreak Champion")));assertEquals(1,f.bossBars.size());verify(f.bossBars.getFirst()).addPlayer(actor.player);verify(f.bossBars.getFirst()).addPlayer(member.player);verify(f.bossBars.getFirst()).addPlayer(solo.player);assertFalse(actor.particleCalls.isEmpty());assertFalse(member.particleCalls.isEmpty());assertFalse(solo.particleCalls.isEmpty());int memberParticles=member.particleCalls.size();member.core.location.setX(100);for(Runnable pulse:List.copyOf(f.pulses))pulse.run();verify(f.bossBars.getFirst()).removePlayer(member.player);assertEquals(memberParticles,member.particleCalls.size());member.core.location.setX(siteRecord.x());for(Runnable pulse:List.copyOf(f.pulses))pulse.run();verify(f.bossBars.getFirst(),atLeast(2)).addPlayer(member.player);f.command.execute(actor.player,"fantasystick",new String[]{"config","adventure-champion-health","120"});drainOne(f);f.command.execute(actor.player,"fantasystick",new String[]{"config","adventure-champion-damage","10"});drainOne(f);var liveSettings=(games.dmg.fantasystick.persistence.AdventureQuestSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestSettings");for(int retry=0;retry<10&&liveSettings.rules().championDamage()!=10;retry++)drainOne(f);verify(maxHealth,times(1)).setBaseValue(100);verify(attack,times(1)).setBaseValue(9);
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);var cancelled=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,champion,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);cancelled.setCancelled(true);f.dispatcher.accept(cancelled);verify(f.bossBars.getFirst(),never()).setProgress(0.99);var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,champion,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());verify(f.bossBars.getFirst()).setProgress(0.99);actor.core.location.setX(100);var death=new org.bukkit.event.entity.EntityDeathEvent(champion,source,new ArrayList<>(),9);f.dispatcher.accept(death);assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CHAMPION,controller.encounterOrigin(champion).orElseThrow().kind(),"death processing retains immutable managed origin authority");assertEquals(0,death.getDroppedExp());assertTrue(death.getDrops().isEmpty());for(int i=0;i<50&&(directory.active().size()!=1||trophies.trophies(actor.core.record.id()).isEmpty());i++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}var saved=storage.find(actor.core.record.id()).get().orElseThrow();var savedMember=storage.find(member.core.record.id()).get().orElseThrow();assertEquals(initial.experience(),saved.experience());assertEquals(memberInitial.experience(),savedMember.experience());assertEquals(initial.restrictedKills(),saved.restrictedKills());assertEquals(memberInitial.restrictedKills(),savedMember.restrictedKills());var partyTrophy=trophies.trophies(actor.core.record.id()).getFirst();assertEquals(games.dmg.fantasystick.adventure.FishingState.TrophyType.CHAMPION,partyTrophy.type());assertEquals("Summit",partyTrophy.origin());
assertEquals(champion.getUniqueId().toString(),partyTrophy.audit().get("champion_id"));
assertEquals("party",partyTrophy.audit().get("unit_kind"));
assertEquals(2,partyTrophy.audit().get("accepted_roster").split(",").length);
assertEquals(java.math.BigInteger.valueOf(150),partyTrophy.total().xp());assertEquals(java.math.BigInteger.valueOf(40),partyTrophy.total().coins());assertFalse(partyTrophy.rewards().containsKey(actor.core.record.id()),"absent leader custody does not invent a beneficiary share");assertTrue(partyTrophy.rewards().containsKey(member.core.record.id()));assertEquals(games.dmg.fantasystick.adventure.FishingState.Reward.empty(),trophies.unclaimed(actor.core.record.id()));assertEquals(1,directory.active().size(),"Unrepresented solo quest remains");
var ordinaryActor=new FantasyStickLifecycleNativeTest.Actor(data);
ordinaryActor.core.location.setWorld(actor.world);
f.players.put(ordinaryActor.core.id,ordinaryActor.player);f.activate(ordinaryActor);
var unrelatedInArena=mock(org.bukkit.entity.Cow.class);
unrelatedOrdinaryId=UUID.randomUUID();when(unrelatedInArena.getUniqueId()).thenReturn(unrelatedOrdinaryId);
when(unrelatedInArena.getPersistentDataContainer()).thenReturn(championData);
when(unrelatedInArena.getType()).thenReturn(org.bukkit.entity.EntityType.COW);
when(unrelatedInArena.getWorld()).thenReturn(actor.world);
when(unrelatedInArena.getLocation()).thenReturn(new Location(actor.world,siteRecord.x()+5,64,siteRecord.z()+25));
assertTrue(controller.encounterOrigin(unrelatedInArena).isEmpty(),
"a copied tag near the next registered encounter cannot bypass ordinary spacing");
var ordinarySource=mock(org.bukkit.damage.DamageSource.class);
when(ordinarySource.getCausingEntity()).thenReturn(ordinaryActor.player);
when(ordinarySource.getDirectEntity()).thenReturn(ordinaryActor.player);
var ordinaryHit=new org.bukkit.event.entity.EntityDamageByEntityEvent(ordinaryActor.player,
unrelatedInArena,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,ordinarySource,4);
f.dispatcher.accept(ordinaryHit);assertFalse(ordinaryHit.isCancelled());
var ordinaryDrop=new org.bukkit.inventory.ItemStack(Material.BEEF);
var ordinaryDeath=new org.bukkit.event.entity.EntityDeathEvent(unrelatedInArena,ordinarySource,
new ArrayList<>(List.of(ordinaryDrop)),5);
f.dispatcher.accept(ordinaryDeath);
for(int attempt=0;attempt<100&&storage.find(ordinaryActor.core.record.id()).get().orElseThrow()
.experience().equals(java.math.BigInteger.ZERO);attempt++){
var task=f.tasks.poll(20,java.util.concurrent.TimeUnit.MILLISECONDS);
if(task!=null)task.run();Thread.sleep(5);
}
assertEquals(List.of(ordinaryDrop),ordinaryDeath.getDrops());
assertEquals(5,ordinaryDeath.getDroppedExp());
assertEquals(java.math.BigInteger.ONE,
storage.find(ordinaryActor.core.record.id()).get().orElseThrow().experience());
assertTrue(storage.find(ordinaryActor.core.record.id()).get().orElseThrow().restrictedKills()
.containsKey(worldId));
assertEquals(initial.experience(),storage.find(actor.core.record.id()).get().orElseThrow().experience(),
"an unrelated ordinary passive never pays the nonparticipating trophy carrier");
verify(f.bossBars.getFirst(),atLeastOnce()).removeAll();int memberParticlesAfterCompletion=member.particleCalls.size();
awaitPulse(f,()->spawned.stream().filter(Vindicator.class::isInstance).count()>=2);assertEquals(memberParticlesAfterCompletion,member.particleCalls.size(),"completed party members stop receiving the shared boundary");var second=spawned.stream().filter(Vindicator.class::isInstance).map(Vindicator.class::cast).skip(1).findFirst().orElseThrow();assertEquals(2,f.bossBars.size());verify(maxHealth).setBaseValue(120);verify(attack).setBaseValue(10);verify(second).setHealth(120);var soloSource=mock(org.bukkit.damage.DamageSource.class);when(soloSource.getCausingEntity()).thenReturn(solo.player);when(soloSource.getDirectEntity()).thenReturn(solo.player);f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(solo.player,second,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,soloSource,1));f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(second,soloSource,new ArrayList<>(),0));await(f,()->directory.active().isEmpty()&&!trophies.trophies(solo.core.record.id()).isEmpty());var savedSolo=storage.find(solo.core.record.id()).get().orElseThrow();assertEquals(soloInitial.experience(),savedSolo.experience());assertEquals(soloInitial.coins(),savedSolo.coins());var soloTrophy=trophies.trophies(solo.core.record.id()).getFirst();assertEquals(new games.dmg.fantasystick.adventure.FishingState.Reward(java.math.BigInteger.valueOf(150),java.math.BigInteger.valueOf(40)),soloTrophy.total());assertEquals(solo.core.record.id(),soloTrophy.carrier());assertEquals(games.dmg.fantasystick.adventure.FishingState.Reward.empty(),trophies.unclaimed(solo.core.record.id()));verify(f.bossBars.get(1),atLeastOnce()).removeAll();
PartyBookNativeTest.open(f,actor);var inventoryText=PartyBookNativeTest.text(actor).replace("\n","");assertTrue(inventoryText.contains("Daybreak Champion Head: 1"),inventoryText);assertTrue(inventoryText.contains("Trophy origin: Summit"),inventoryText);
var reroll=directory.rerollToday(catalog.revision()).get(5,java.util.concurrent.TimeUnit.SECONDS);assertTrue(reroll.catalog().isPresent());assertTrue(controller.publishPendingReset().toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS));assertTrue(directory.removeSite(site).get(5,java.util.concurrent.TimeUnit.SECONDS).isPresent());assertTrue(parties.disband(actor.core.id,party.id()));doReturn(false).when(member.player).isOnline();
var turn=(games.dmg.fantasystick.adventure.FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");turn.open(actor.player);var turnMenu=actor.inventories.getLast();var redeem=menuItem(turnMenu,"Redeem Daybreak Champion Head");assertTrue(redeem.getItemMeta().lore().toString().contains("Beneficiaries: 1"));click(turn,actor,turnMenu,redeem);await(f,()->trophies.trophies(actor.core.record.id()).isEmpty()&&trophies.unclaimed(member.core.record.id()).xp().equals(java.math.BigInteger.valueOf(150)));var memberShare=trophies.unclaimed(member.core.record.id());assertEquals(games.dmg.fantasystick.adventure.FishingState.Reward.empty(),trophies.unclaimed(actor.core.record.id()),"carrier custody does not claim or invent a share");assertEquals(memberShare,trophies.unclaimed(member.core.record.id()),"offline beneficiary retains the full frozen pool");doReturn(true).when(member.player).isOnline();turn.open(member.player);var memberTurn=member.inventories.getLast();click(turn,member,memberTurn,menuItem(memberTurn,"Claim earned quest rewards"));await(f,()->trophies.unclaimed(member.core.record.id()).equals(games.dmg.fantasystick.adventure.FishingState.Reward.empty()));var claimedMember=storage.find(member.core.record.id()).get().orElseThrow();assertEquals(memberInitial.experience().add(java.math.BigInteger.valueOf(150)),claimedMember.experience());assertEquals(memberInitial.coins().add(java.math.BigInteger.valueOf(40)),claimedMember.coins());turn.open(solo.player);var soloTurn=solo.inventories.getLast();click(turn,solo,soloTurn,menuItem(soloTurn,"Claim retained adventure rewards"));await(f,()->directory.unclaimedAll(solo.core.record.id()).equals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward.empty()));assertEquals(1,trophies.trophies(solo.core.record.id()).size(),"legacy claims do not consume an earned trophy");
var turn=(games.dmg.fantasystick.adventure.FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");actor.core.location.setX(siteRecord.x());actor.core.location.setZ(siteRecord.z());
int priorOffers=actor.inventories.size();
f.dispatcher.accept(new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.HAND));
await(f,()->actor.inventories.size()>priorOffers);
var giverOffers=actor.inventories.getLast();var giverEntry=menuItem(giverOffers,"Turn in quest items");
var giverClick=mock(InventoryClickEvent.class);when(giverClick.getWhoClicked()).thenReturn(actor.player);
when(giverClick.getInventory()).thenReturn(giverOffers);when(giverClick.getCurrentItem()).thenReturn(giverEntry);
controller.click(giverClick);var turnMenu=actor.inventories.getLast();var redeem=menuItem(turnMenu,"Redeem Daybreak Champion Head");assertTrue(redeem.getItemMeta().lore().toString().contains("Beneficiaries: 1"));
var fishingFile=data.resolve("fishing.yml");var savedFishing=java.nio.file.Files.readAllBytes(fishingFile);
java.nio.file.Files.delete(fishingFile);java.nio.file.Files.createDirectory(fishingFile);
click(turn,actor,turnMenu,redeem);
await(f,()->actor.messages.stream().anyMatch(value->value.contains("Trophy redemption failed")));
assertEquals(1,trophies.trophies(actor.core.record.id()).size());
assertEquals(games.dmg.fantasystick.adventure.FishingState.Reward.empty(),
trophies.unclaimed(member.core.record.id()));
assertEquals(0,exportedCount(data,"adventure.trophy_redeemed"),
"failed redemption cannot attest receipt consumption");
java.nio.file.Files.delete(fishingFile);java.nio.file.Files.write(fishingFile,savedFishing);
int beforeRetryOffers=actor.inventories.size();
f.dispatcher.accept(new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.HAND));
await(f,()->actor.inventories.size()>beforeRetryOffers);
var retryOffers=actor.inventories.getLast();var retryEntry=menuItem(retryOffers,"Turn in quest items");
var retryClick=mock(InventoryClickEvent.class);when(retryClick.getWhoClicked()).thenReturn(actor.player);
when(retryClick.getInventory()).thenReturn(retryOffers);
when(retryClick.getCurrentItem()).thenReturn(retryEntry);
controller.click(retryClick);var retryMenu=actor.inventories.getLast();
redemptionX=actor.core.location.getX();
click(turn,actor,retryMenu,menuItem(retryMenu,"Redeem Daybreak Champion Head"));
actor.core.location.setX(redemptionX+100);
await(f,()->trophies.trophies(actor.core.record.id()).isEmpty()&&trophies.unclaimed(member.core.record.id()).xp().equals(java.math.BigInteger.valueOf(150)));var memberShare=trophies.unclaimed(member.core.record.id());assertEquals(games.dmg.fantasystick.adventure.FishingState.Reward.empty(),trophies.unclaimed(actor.core.record.id()),"carrier custody does not claim or invent a share");assertEquals(memberShare,trophies.unclaimed(member.core.record.id()),"offline beneficiary retains the full frozen pool");doReturn(true).when(member.player).isOnline();member.core.location.setZ(siteRecord.z());
int beforeMemberOffers=member.inventories.size();
f.dispatcher.accept(new PlayerInteractEntityEvent(member.player,giver,EquipmentSlot.HAND));
await(f,()->member.inventories.size()>beforeMemberOffers);
var memberOffers=member.inventories.getLast();var memberEntry=menuItem(memberOffers,"Turn in quest items");
var memberEntryClick=mock(InventoryClickEvent.class);when(memberEntryClick.getWhoClicked()).thenReturn(member.player);
when(memberEntryClick.getInventory()).thenReturn(memberOffers);
when(memberEntryClick.getCurrentItem()).thenReturn(memberEntry);
controller.click(memberEntryClick);var memberTurn=member.inventories.getLast();click(turn,member,memberTurn,menuItem(memberTurn,"Claim earned quest rewards"));await(f,()->trophies.unclaimed(member.core.record.id()).equals(games.dmg.fantasystick.adventure.FishingState.Reward.empty()));var claimedMember=storage.find(member.core.record.id()).get().orElseThrow();assertEquals(memberInitial.experience().add(java.math.BigInteger.valueOf(150)),claimedMember.experience());assertEquals(memberInitial.coins().add(java.math.BigInteger.valueOf(40)),claimedMember.coins());turn.open(solo.player);var soloTurn=solo.inventories.getLast();click(turn,solo,soloTurn,menuItem(soloTurn,"Claim retained adventure rewards"));await(f,()->directory.unclaimedAll(solo.core.record.id()).equals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward.empty()));assertEquals(1,trophies.trophies(solo.core.record.id()).size(),"legacy claims do not consume an earned trophy");
for(int attempt=0;attempt<100&&(exportedCount(data,"adventure.trophy_earned")<2
||exportedCount(data,"adventure.trophy_redeemed")<1
||exportedCount(data,"adventure.reward_claimed")<1);attempt++){
var task=f.tasks.poll(20,java.util.concurrent.TimeUnit.MILLISECONDS);
if(task!=null)task.run();Thread.sleep(5);
}
assertEquals(2,exportedCount(data,"adventure.trophy_earned"),
"redeemed="+exportedCount(data,"adventure.trophy_redeemed")
+" claims="+exportedCount(data,"adventure.reward_claimed"));
assertEquals(1,exportedCount(data,"adventure.trophy_redeemed"));
assertTrue(exportedCount(data,"adventure.reward_claimed")>=1);
}
var exported=exported(data);
assertEquals(2,exported.stream().filter(event->event.get("type").getAsString()
.endsWith("adventure.trophy_earned")).count());
var redemptions=exported.stream().filter(event->event.get("type").getAsString()
.endsWith("adventure.trophy_redeemed")).toList();
assertEquals(1,redemptions.size());
assertEquals("entered_via_registered_giver",redemptions.getFirst().getAsJsonObject("data")
.get("giver_status").getAsString());
var earned=exported.stream().filter(event->event.get("type").getAsString()
.endsWith("adventure.trophy_earned")).toList();
assertEquals(2,earned.stream().map(event->event.get("id").getAsString()).distinct().count());
assertEquals(2,earned.stream().map(event->event.getAsJsonObject("data")
.get("receipt_id").getAsString()).distinct().count(),
"identically named heads from separate native encounters retain distinct receipts");
assertEquals(1,earned.stream().map(event->event.getAsJsonObject("data")
.get("trophy_name").getAsString()).distinct().count());
assertTrue(earned.stream().anyMatch(event->!event.getAsJsonObject("data")
.get("carrier_is_beneficiary").getAsBoolean()));
assertTrue(earned.stream().allMatch(event->event.getAsJsonObject("data")
.get("receipt_revision").getAsString().equals("2")),
"prepared revision 1 becomes durable earned revision 2 exactly once");
var partyEarned=earned.stream().map(event->event.getAsJsonObject("data"))
.filter(value->!value.get("carrier_is_beneficiary").getAsBoolean()).findFirst().orElseThrow();
assertEquals("party",partyEarned.get("unit_kind").getAsString());
assertEquals("150",partyEarned.get("reward_xp_pool").getAsString());
assertEquals(1,partyEarned.getAsJsonArray("beneficiaries").size());
assertEquals(2,partyEarned.getAsJsonArray("accepted_roster").size());
assertTrue(partyEarned.getAsJsonObject("defeat_location").has("world_id"));
assertEquals(partyEarned.get("receipt_id").getAsString(),redemptions.getFirst()
.getAsJsonObject("data").get("receipt_id").getAsString());
assertEquals(redemptionX,redemptions.getFirst().getAsJsonObject("data")
.getAsJsonObject("redeemer_location").get("x").getAsDouble(),
"redemption reports the native click position rather than a later callback location");
var suppressed=exported.stream().filter(event->event.get("type").getAsString()
.endsWith("combat.reward_resolved")&&event.getAsJsonObject("data").has("resolution")
&&event.getAsJsonObject("data").get("resolution").getAsString()
.equals("encounter_suppressed")).toList();
assertEquals(2,suppressed.size(),"registered champion enemies suppress per-kill payout without history updates");
var unrelatedReward=exported.stream().filter(event->event.get("type").getAsString()
.endsWith("combat.reward_resolved")&&event.getAsJsonObject("data").has("target_id")
&&event.getAsJsonObject("data").get("target_id").getAsString()
.equals(unrelatedOrdinaryId.toString())).findFirst().orElseThrow();
assertEquals("ordinary_spacing_eligible",unrelatedReward.getAsJsonObject("data")
.get("resolution").getAsString());
assertEquals("1",unrelatedReward.getAsJsonObject("data").get("applied_experience").getAsString());
assertFalse(unrelatedReward.getAsJsonObject("data").has("managed_origin_kind"));
assertTrue(suppressed.stream().allMatch(event->event.getAsJsonObject("data")
.get("managed_origin_kind").getAsString().equals("champion")));
var claim=exported.stream().filter(event->event.get("type").getAsString()
.endsWith("adventure.reward_claimed")).findFirst().orElseThrow();
assertEquals("champion_trophy",claim.getAsJsonObject("data").getAsJsonArray("sources")
.get(0).getAsJsonObject().get("source_kind").getAsString());
assertEquals("entered_via_registered_giver",claim.getAsJsonObject("data")
.get("giver_status").getAsString());
}
@Test void preparedChampionRecoveryPublishesOnlyTheCapturedReceiptAfterRestart() throws Exception {
UUID trophyId=UUID.randomUUID(),championId=UUID.randomUUID();
UUID carrier;
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);f.activate(actor);
doReturn(true).when(actor.player).hasPermission("fantasystick.admin");
f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","place","champion","20","60","Restart Peak"});
drainOne(f);
var quests=(games.dmg.fantasystick.adventure.AdventureQuestDirectory)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
quests.ensureToday().get(5,java.util.concurrent.TimeUnit.SECONDS);
var catalog=quests.snapshot().catalog();var site=catalog.sites().getFirst();
carrier=actor.core.record.id();
var accepted=quests.accept(catalog.revision(),site,
games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,
carrier,Set.of(carrier)).get(5,java.util.concurrent.TimeUnit.SECONDS);
assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Acceptance.APPLIED,accepted.status());
var quest=quests.activeFor(carrier,null).orElseThrow();
var plan=new games.dmg.fantasystick.adventure.FishingState.Trophy(trophyId,
games.dmg.fantasystick.adventure.FishingState.TrophyType.CHAMPION,carrier,
quest.catalog(),quest.site(),quest.id(),UUID.randomUUID(),"Daybreak Champion Head",
"Restart Peak",Map.of(carrier,new games.dmg.fantasystick.adventure.FishingState.Reward(
java.math.BigInteger.valueOf(150),java.math.BigInteger.valueOf(40))),
games.dmg.fantasystick.adventure.FishingState.TrophyPhase.PREPARED,
Map.of("champion_id",championId.toString(),"defeat_status","unavailable",
"defeated_at","unavailable","unit_kind","solo","unit_id",carrier.toString(),
"accepted_roster",carrier.toString()));
var fishing=(games.dmg.fantasystick.adventure.FishingDirectory)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");
assertEquals(games.dmg.fantasystick.adventure.FishingDirectory.TrophyPrepareStatus.APPLIED,
fishing.prepareTrophy(plan).get(5,java.util.concurrent.TimeUnit.SECONDS));
assertEquals(0,exportedCount(data,"adventure.trophy_earned"),
"a prepared receipt is not an earned event");
var file=data.resolve("fishing.yml");var retained=java.nio.file.Files.readAllBytes(file);
java.nio.file.Files.delete(file);java.nio.file.Files.createDirectory(file);
var controller=(games.dmg.fantasystick.adventure.FishingController)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");
assertThrows(java.util.concurrent.ExecutionException.class,()->controller
.earnTrophyWithEvidence(plan).toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS));
assertEquals(0,exportedCount(data,"adventure.trophy_earned"),
"failed commit does not attest earned trophy");
java.nio.file.Files.delete(file);java.nio.file.Files.write(file,retained);
f.plugin.setEnabled(false);
}
try(var restarted=new FantasyStickLifecycleNativeTest.Fixture(data)){
restarted.plugin.setEnabled(true);
var fishing=(games.dmg.fantasystick.adventure.FishingDirectory)
FantasyStickLifecycleNativeTest.Fixture.field(restarted.plugin,"fishing");
for(int attempt=0;attempt<100&&(fishing.trophies(carrier).isEmpty()
||exportedCount(data,"adventure.trophy_earned")<1);attempt++){
var task=restarted.tasks.poll(20,java.util.concurrent.TimeUnit.MILLISECONDS);
if(task!=null)task.run();Thread.sleep(5);
}
assertEquals(1,fishing.trophies(carrier).size());
var earned=exported(data).stream().filter(event->event.get("type").getAsString()
.endsWith("adventure.trophy_earned")).toList();
assertEquals(1,earned.size(),"prepared recovery exports one durable earned event");
assertEquals(trophyId.toString(),earned.getFirst().getAsJsonObject("data")
.get("receipt_id").getAsString());
assertEquals(championId.toString(),earned.getFirst().getAsJsonObject("data")
.get("champion_id").getAsString());
restarted.plugin.setEnabled(false);
}
try(var again=new FantasyStickLifecycleNativeTest.Fixture(data)){
again.plugin.setEnabled(true);
assertEquals(1,exportedCount(data,"adventure.trophy_earned"),
"already earned receipts do not publish an invented second event");
again.plugin.setEnabled(false);
}
}
private static long exportedCount(Path root,String suffix){
try{return exported(root).stream().filter(value->value.get("type").getAsString().endsWith(suffix)).count();}
catch(Exception unavailable){return 0;}
}
private static List<com.google.gson.JsonObject> exported(Path root)throws Exception{
try(var paths=java.nio.file.Files.walk(root.resolve("events"))){
return paths.filter(path->path.toString().endsWith(".jsonl"))
.flatMap(path->{try{return java.nio.file.Files.lines(path);}catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}})
.map(line->com.google.gson.JsonParser.parseString(line).getAsJsonObject()).toList();
}
}
private static org.bukkit.inventory.ItemStack menuItem(org.bukkit.inventory.Inventory menu,String name){var plain=net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText();return java.util.stream.IntStream.range(0,menu.getSize()).mapToObj(menu::getItem).filter(java.util.Objects::nonNull).filter(item->item.getItemMeta().displayName()!=null&&plain.serialize(item.getItemMeta().displayName()).equals(name)).findFirst().orElseThrow();}
@@ -301,10 +301,12 @@ class CombatExperienceNativeTest {
assertEquals(beforePassiveCoins, record(f, a).coins().add(record(f, c).coins()));
var passiveA = record(f, a).experience();
var passiveC = record(f, c).experience();
assertEquals(java.math.BigInteger.valueOf(11), passiveA.add(passiveC),
"The one-XP passive pool is conserved across the two solo units");
assertEquals(1, java.util.stream.Stream.of(passiveA.subtract(java.math.BigInteger.TEN), passiveC)
.filter(java.math.BigInteger.ONE::equals).count(), "Exactly one random unit receives the remainder");
assertEquals(java.math.BigInteger.TEN, passiveA,
"A's passive share is blocked at the prior neutral kill position, even if randomly allocated");
assertTrue(passiveC.equals(java.math.BigInteger.ZERO)||passiveC.equals(java.math.BigInteger.ONE),
"C can earn only the single randomly assigned passive XP, never passive coins");
assertEquals(new java.math.BigDecimal("2.0"),record(f,a).restrictedKills()
.get(world.getUID()).x(),"blocked passive attempts update the ordinary reference");
assertEquals(java.math.BigInteger.ZERO, record(f, b).experience());
var exact = mob(EntityType.ZOMBIE, new Location(world, 5, 64, 0));
@@ -312,15 +314,175 @@ class CombatExperienceNativeTest {
hit(f, a.player, exact, true); // Cancelled repeats do not create another contributor.
die(f, exact); settle(f);
assertEquals(passiveA.add(java.math.BigInteger.TEN), record(f, a).experience(),
"Passive kill did not reset history; exact three-block distance from blocked kill is eligible");
"The exact three-block distance from a blocked passive attempt is eligible");
assertEquals(new java.math.BigDecimal("5.0"), record(f, a).restrictedKills().values().iterator().next().x());
var repeatedPassive = mob(EntityType.SHEEP, new Location(world, 2, 64, 0));
hit(f, a.player, repeatedPassive, false); die(f, repeatedPassive); settle(f);
assertEquals(passiveA.add(java.math.BigInteger.valueOf(11)), record(f, a).experience(), "Same-location passive kills remain eligible");
assertEquals(passiveA.add(java.math.BigInteger.valueOf(11)), record(f, a).experience(),
"A passive three blocks from the intervening hostile kill is eligible");
}
}
@Test void ordinaryPassiveKillsSharePerWorldSpacingHistoryWithOtherMobCategories() throws Exception {
UUID character,worldId,otherWorldId;
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);f.activate(actor);
var world=mock(org.bukkit.World.class);var id=UUID.randomUUID();when(world.getUID()).thenReturn(id);
character=actor.core.record.id();worldId=id;
var initial=record(f,actor);var first=mob(EntityType.COW,new Location(world,0,64,0));
hit(f,actor.player,first,false);die(f,first);settle(f);
assertEquals(initial.experience().add(java.math.BigInteger.ONE),record(f,actor).experience());
assertEquals(new java.math.BigDecimal("0.0"),record(f,actor).restrictedKills().get(id).x());
var blocked=mob(EntityType.SHEEP,new Location(world,2,64,0));
hit(f,actor.player,blocked,false);die(f,blocked);settle(f);
assertEquals(initial.experience().add(java.math.BigInteger.ONE),record(f,actor).experience());
assertEquals(new java.math.BigDecimal("2.0"),record(f,actor).restrictedKills().get(id).x(),
"a blocked passive advances ordinary spacing history");
var eligible=mob(EntityType.ZOMBIE,new Location(world,5,64,0));
hit(f,actor.player,eligible,false);die(f,eligible);settle(f);
assertEquals(initial.experience().add(java.math.BigInteger.valueOf(11)),record(f,actor).experience());
assertEquals(new java.math.BigDecimal("5.0"),record(f,actor).restrictedKills().get(id).x());
assertTrue(actor.messages.stream().anyMatch(message->message.contains("too close")));
assertTrue(actor.messages.stream().anyMatch(message->message.contains("1")&&message.contains("XP")));
actor.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,actor);
assertTrue(PartyBookNativeTest.text(actor).contains("XP: 11/100"));
var afterHostileCoins=record(f,actor).coins();
var otherWorld=mock(org.bukkit.World.class);otherWorldId=UUID.randomUUID();
when(otherWorld.getUID()).thenReturn(otherWorldId);
var acrossWorld=mob(EntityType.SHEEP,new Location(otherWorld,0,64,0));
hit(f,actor.player,acrossWorld,false);die(f,acrossWorld);settle(f);
assertEquals(initial.experience().add(java.math.BigInteger.valueOf(12)),
record(f,actor).experience(),"first passive in a new world has independent spacing");
var another=new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(another.core.id,another.player);f.activate(another);
var samePlace=mob(EntityType.COW,new Location(world,5,64,0));
hit(f,another.player,samePlace,false);die(f,samePlace);settle(f);
assertEquals(java.math.BigInteger.ONE,record(f,another).experience(),
"another character has independent ordinary history at the same position");
assertEquals(afterHostileCoins,record(f,actor).coins(),"passive kills mint no coins");
}
var saved=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data)
.load(character).orElseThrow();
assertEquals(java.math.BigInteger.valueOf(12),saved.experience());
assertEquals(new java.math.BigDecimal("5.0"),saved.restrictedKills().get(worldId).x());
assertEquals(new java.math.BigDecimal("0.0"),saved.restrictedKills().get(otherWorldId).x());
}
@Test void cappedPassiveCharacterStillConsumesOrdinaryHistoryWithoutCoinOrXp() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),"maximum-level: 1\n");
UUID character,worldId;
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);f.activate(actor);
character=actor.core.record.id();
var world=mock(org.bukkit.World.class);worldId=UUID.randomUUID();
when(world.getUID()).thenReturn(worldId);
var before=record(f,actor);
var first=mob(EntityType.COW,new Location(world,0,64,0));
hit(f,actor.player,first,false);die(f,first);settle(f);
var capped=record(f,actor);
assertEquals(before.experience(),capped.experience());
assertEquals(before.coins(),capped.coins());
assertEquals(new java.math.BigDecimal("0.0"),capped.restrictedKills().get(worldId).x());
var blocked=mob(EntityType.SHEEP,new Location(world,1,64,0));
hit(f,actor.player,blocked,false);die(f,blocked);settle(f);
var after=record(f,actor);
assertEquals(before.experience(),after.experience());
assertEquals(before.coins(),after.coins());
assertEquals(new java.math.BigDecimal("1.0"),after.restrictedKills().get(worldId).x());
}
var reloaded=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data)
.load(character).orElseThrow();
assertEquals(new java.math.BigDecimal("1.0"),reloaded.restrictedKills().get(worldId).x());
var evidence=events(data).stream().filter(event->event.get("type").getAsString()
.endsWith("combat.reward_resolved")).map(event->event.getAsJsonObject("data")).toList();
assertEquals(2,evidence.size());
assertEquals("at_cap",evidence.getFirst().get("storage_status").getAsString());
assertEquals("ordinary_spacing_blocked",evidence.getLast().get("resolution").getAsString());
assertTrue(evidence.stream().allMatch(value->value.get("applied_experience").getAsString()
.equals("0")&&value.get("applied_coins").getAsString().equals("0")));
}
@Test void eachCapturedManagedPassiveOriginBypassesWithoutMovingOrdinaryHistory() throws Exception {
for(var kind:games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.values()){
var directory=data.resolve(kind.name());java.nio.file.Files.createDirectories(directory);
try(var f=new FantasyStickLifecycleNativeTest.Fixture(directory)){
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(directory);
f.players.put(actor.core.id,actor.player);f.activate(actor);
var combat=(games.dmg.fantasystick.party.CombatExperience)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"combatExperience");
var managed=new java.util.HashSet<UUID>();
combat.attachManagedOrigins(entity->managed.contains(entity.getUniqueId())
?java.util.Optional.of(new games.dmg.fantasystick.progression.ManagedEncounterOrigin(
kind,entity.getUniqueId())):java.util.Optional.empty());
var world=mock(org.bukkit.World.class);var worldId=UUID.randomUUID();when(world.getUID()).thenReturn(worldId);
var initial=record(f,actor);var ordinary=mob(EntityType.COW,new Location(world,0,64,0));
hit(f,actor.player,ordinary,false);die(f,ordinary);settle(f);
assertEquals(initial.experience().add(java.math.BigInteger.ONE),record(f,actor).experience());
for(int index=0;index<2;index++){
var target=mob(EntityType.COW,new Location(world,1,64,0));managed.add(target.getUniqueId());
hit(f,actor.player,target,false);die(f,target);settle(f);
}
assertEquals(initial.experience().add(java.math.BigInteger.valueOf(3)),
record(f,actor).experience(),kind+" keeps both independently receipted managed passive rewards");
assertEquals(new java.math.BigDecimal("0.0"),record(f,actor).restrictedKills().get(worldId).x(),
"managed kills cannot inspect or advance the ordinary history");
var unrelated=mob(EntityType.COW,new Location(world,1,64,0));
hit(f,actor.player,unrelated,false);die(f,unrelated);settle(f);
assertEquals(initial.experience().add(java.math.BigInteger.valueOf(3)),record(f,actor).experience());
assertEquals(new java.math.BigDecimal("1.0"),record(f,actor).restrictedKills().get(worldId).x());
assertEquals(initial.coins(),record(f,actor).coins(),"passives award no coins");
f.plugin.setEnabled(false);
}
var records=events(directory).stream().filter(event->event.get("type").getAsString()
.endsWith("combat.reward_resolved")).map(event->event.getAsJsonObject("data")).toList();
assertEquals(4,records.size(),kind.toString());
assertEquals(2,records.stream().filter(value->value.get("resolution").getAsString()
.equals("managed_origin_exempt")&&value.get("managed_origin_kind").getAsString()
.equals(kind.name().toLowerCase(java.util.Locale.ROOT))).count());
assertEquals(1,records.stream().filter(value->value.get("resolution").getAsString()
.equals("ordinary_spacing_blocked")).count());
}
}
@Test void capturedDungeonChampionAndCampOriginsKeepEveryMobCategoryOutOfOrdinaryHistory() throws Exception {
var categories=java.util.List.of(EntityType.COW,EntityType.BEE,EntityType.ZOMBIE,
EntityType.WITCH,EntityType.WARDEN,EntityType.WITHER);
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);
var combat=(games.dmg.fantasystick.party.CombatExperience)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"combatExperience");
var origins=new java.util.HashMap<UUID,games.dmg.fantasystick.progression.ManagedEncounterOrigin>();
combat.attachManagedOrigins(entity->java.util.Optional.ofNullable(origins.get(entity.getUniqueId())));
for(var kind:games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.values())
for(var category:categories){
var actor=new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(actor.core.id,actor.player);f.activate(actor);
var world=mock(org.bukkit.World.class);when(world.getUID()).thenReturn(UUID.randomUUID());
var target=mob(category,new Location(world,0,64,0));
origins.put(target.getUniqueId(),new games.dmg.fantasystick.progression.ManagedEncounterOrigin(
kind,target.getUniqueId()));
var before=record(f,actor);hit(f,actor.player,target,false);die(f,target);settle(f);
var after=record(f,actor);
assertTrue(after.experience().compareTo(before.experience())>0,kind+" "+category);
assertEquals(before.restrictedKills(),after.restrictedKills(),kind+" "+category);
if(category==EntityType.COW)assertEquals(before.coins(),after.coins());
}
}
var evidence=events(data).stream().filter(event->event.get("type").getAsString()
.endsWith("combat.reward_resolved")).map(event->event.getAsJsonObject("data")).toList();
assertEquals(18,evidence.size());
for(var kind:games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.values())
for(var category:categories)assertEquals(1,evidence.stream().filter(event->event
.get("managed_origin_kind").getAsString().equals(kind.name().toLowerCase(java.util.Locale.ROOT))
&&event.get("target_type").getAsString().equals(category.name().toLowerCase(java.util.Locale.ROOT))
&&event.get("resolution").getAsString().equals("managed_origin_exempt")
&&!event.has("ordinary_history_before")&&!event.has("ordinary_history_after")).count(),
kind+" "+category);
}
@Test void representedPartyIsOneUnitAndSharesWithActiveNonAttackingMembers() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
@@ -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 com.google.gson.JsonParser;
import games.dmg.fantasystick.adventure.AdventureQuestState;
import games.dmg.fantasystick.dungeon.DungeonDirectory;
import games.dmg.fantasystick.dungeon.DungeonState;
import games.dmg.fantasystick.events.DungeonEventBridge;
import games.dmg.fantasystick.events.FishingEventBridge;
import games.dmg.fantasystick.events.LocalEventLog;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Clock;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class DungeonEventBridgePartsNativeTest {
@TempDir Path data;
@Test void rejectedCommittedRewardAndSingleBossClosureReplaySameProofIntoExportedJsonl() throws Exception {
UUID dungeonId=UUID.randomUUID(),roomId=UUID.randomUUID(),generation=UUID.randomUUID();
UUID boss=UUID.randomUUID(),receipt=UUID.randomUUID();
var empty=new DungeonState.Reward(BigInteger.ZERO,BigInteger.ZERO);
var payout=new DungeonState.Reward(BigInteger.valueOf(30),BigInteger.valueOf(5));
var entrance=new DungeonState.Position(1,64,1,0,0);
var before=new DungeonState.Dungeon(dungeonId,"Frozen Dungeon",UUID.randomUUID(),
entrance,false,UUID.randomUUID(),List.of(),AdventureQuestState.ParticipationMode.PARTY_AVAILABLE,
empty,false);
var after=new DungeonState.Dungeon(dungeonId,before.name(),before.world(),entrance,false,
UUID.randomUUID(),List.of(),before.participationMode(),payout,true);
var room=new DungeonState.Room(roomId,"Boss Room",
new DungeonState.Bounds(0,63,0,3,68,3),List.of(),List.of(),empty,null);
var unit=UUID.randomUUID();
var audit=new LinkedHashMap<String,String>();
audit.put("dungeon_name",after.name());audit.put("room_id",roomId.toString());
audit.put("boss_id",boss.toString());
audit.put("defeated_at",Clock.systemUTC().instant().truncatedTo(
java.time.temporal.ChronoUnit.SECONDS).toString());
audit.put("defeat_world_id",before.world().toString());
audit.put("defeat_world_name","world");
audit.put("defeat_x","2.0");audit.put("defeat_y","64.0");audit.put("defeat_z","2.0");
audit.put("represented_count","1");audit.put("represented_0_quest_id",UUID.randomUUID().toString());
audit.put("represented_0_unit_kind","solo");audit.put("represented_0_unit_id",unit.toString());
audit.put("represented_0_accepted_roster",unit.toString());
audit.put("represented_0_receipt_id",receipt.toString());
var run=new DungeonState.Run(dungeonId,generation,Set.of(),Set.of(roomId),Set.of(),Set.of(),true,audit);
var operation=UUID.randomUUID();
var rejected=new FishingEventBridge(event->CompletableFuture.completedFuture(
LocalEventLog.Delivery.REJECTED),Clock.systemUTC(),data,mock(EquipmentSessions.class),ignored->{});
var bridge=new DungeonEventBridge(rejected);
bridge.rewardChanged(operation,null,
new DungeonDirectory.CompletionRewardChange(before,after));
bridge.completed(run);
bridge.completed(new DungeonState.Run(dungeonId,UUID.randomUUID(),Set.of(),Set.of(roomId),
Set.of(),Set.of(),true)); // Legacy defeated runs have no captured native proof.
rejected.close();rejected.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
var log=LocalEventLog.open(data,Clock.systemUTC(),
new LocalEventLog.RuntimeInfo("test","test","test"),128,1_000_000,ignored->{});
log.ready().toCompletableFuture().get(5,TimeUnit.SECONDS);
var recovered=new FishingEventBridge(log::emit,Clock.systemUTC(),data,
mock(EquipmentSessions.class),ignored->{});
recovered.replay();recovered.close();recovered.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
log.close();
var rows=new ArrayList<JsonObject>();
try(var files=Files.walk(data.resolve("events"))){
for(var path:files.filter(value->value.toString().endsWith(".jsonl")).toList())
for(var line:Files.readAllLines(path))rows.add(JsonParser.parseString(line).getAsJsonObject());
}
var changes=rows.stream().filter(row->row.get("type").getAsString()
.endsWith("dungeon.reward_changed")).toList();
var completions=rows.stream().filter(row->row.get("type").getAsString()
.endsWith("dungeon.completed")).toList();
assertEquals(1,changes.size());assertEquals(1,completions.size());
assertEquals("system",changes.getFirst().getAsJsonObject("data")
.get("actor_kind").getAsString());
assertEquals("unavailable",changes.getFirst().getAsJsonObject("data")
.get("actor_identity_status").getAsString(),
"missing actor identity must not erase a committed change or invent a console");
assertEquals(before.revision().toString(),changes.getFirst().getAsJsonObject("data")
.get("before_revision").getAsString());
assertEquals(after.revision().toString(),changes.getFirst().getAsJsonObject("data")
.get("after_revision").getAsString());
assertEquals(boss.toString(),completions.getFirst().getAsJsonObject("data")
.get("boss_id").getAsString());
assertEquals(receipt.toString(),completions.getFirst().getAsJsonObject("data")
.getAsJsonArray("receipt_ids").get(0).getAsString());
}
}
@@ -11,6 +11,7 @@ import games.dmg.fantasystick.dungeon.DungeonDirectory;
import games.dmg.fantasystick.dungeon.DungeonState;
import games.dmg.fantasystick.persistence.CharacterStorage;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.TimeUnit;
@@ -67,7 +68,7 @@ class DungeonNativeTest {
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 actorEffects=new HashMap<org.bukkit.potion.PotionEffectType,org.bukkit.potion.PotionEffect>();doAnswer(call->Set.copyOf(actorEffects.values())).when(actor.player).getActivePotionEffects();doAnswer(call->{var effect=(org.bukkit.potion.PotionEffect)call.getArgument(0);actorEffects.put(effect.getType(),effect);return true;}).when(actor.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());doAnswer(call->{actorEffects.remove((org.bukkit.potion.PotionEffectType)call.getArgument(0));return null;}).when(actor.player).removePotionEffect(any(org.bukkit.potion.PotionEffectType.class));UUID worldId=UUID.randomUUID();when(actor.world.getUID()).thenReturn(worldId);when(actor.world.getName()).thenReturn("Dungeon Realm");when(actor.world.isChunkLoaded(anyInt(),anyInt())).thenReturn(true);
var spawned=new ArrayList<LivingEntity>();
doAnswer(call->{Class<?> type=call.getArgument(1);LivingEntity entity=mock(type.asSubclass(LivingEntity.class));var pdc=new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());when(entity.getPersistentDataContainer()).thenReturn(pdc);when(entity.getUniqueId()).thenReturn(UUID.randomUUID());when(entity.getType()).thenReturn(type==Husk.class?EntityType.HUSK:EntityType.VINDICATOR);when(entity.getWorld()).thenReturn(actor.world);when(entity.getLocation()).thenReturn(((Location)call.getArgument(0)).clone());((java.util.function.Consumer<LivingEntity>)call.getArgument(2)).accept(entity);spawned.add(entity);return entity;}).when(actor.world).spawn(any(Location.class),any(Class.class),any(java.util.function.Consumer.class));
doAnswer(call->{Class<?> type=call.getArgument(1);LivingEntity entity=mock(type.asSubclass(LivingEntity.class));var pdc=new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());when(entity.getPersistentDataContainer()).thenReturn(pdc);when(entity.getUniqueId()).thenReturn(UUID.randomUUID());when(entity.getType()).thenReturn(type==Husk.class?EntityType.HUSK:type==Villager.class?EntityType.VILLAGER:EntityType.VINDICATOR);when(entity.getWorld()).thenReturn(actor.world);when(entity.getLocation()).thenReturn(((Location)call.getArgument(0)).clone());((java.util.function.Consumer<LivingEntity>)call.getArgument(2)).accept(entity);spawned.add(entity);return entity;}).when(actor.world).spawn(any(Location.class),any(Class.class),any(java.util.function.Consumer.class));
when(actor.world.getEntities()).thenAnswer(call->new ArrayList<Entity>(spawned));
assertEquals(List.of("dungeon"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"dung"}));var denied=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(denied.core.id,denied.player);doReturn(false).when(denied.player).hasPermission("fantasystick.admin");f.command.execute(denied.player,"fantasystick",new String[]{"dungeon","create","Forbidden"});assertTrue(denied.messages.getLast().contains("Permission denied"));assertTrue(f.command.tabComplete(denied.player,"fantasystick",new String[]{"dungeon",""}).isEmpty());
command(f,actor,"dungeon","create","Sunken","Keep");var dungeons=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var dungeon=dungeons.snapshot().dungeons().getFirst();command(f,actor,"dungeon","reward",dungeon.id().toString());assertTrue(actor.messages.getLast().contains("not configured"));
@@ -82,12 +83,124 @@ class DungeonNativeTest {
var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");assertTrue(adventures.snapshot().catalog().sites().contains(dungeon.id()));assertEquals(new AdventureQuestDirectory.Reward(BigInteger.valueOf(30),BigInteger.valueOf(5)),adventures.reward(adventures.findSite(dungeon.id()).orElseThrow()));
f.activate(actor);var questController=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");questController.open(actor.player);drain(f);var menu=actor.inventories.getLast();assertEquals(Material.PAPER,menu.getItem(0).getType());var offerLore=menu.getItem(0).getItemMeta().lore().toString();assertTrue(offerLore.contains("Completion trophy pool: 30 XP, 5 coins"));assertTrue(offerLore.contains("additional variable XP, coins and native loot"));var click=mock(InventoryClickEvent.class);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getInventory()).thenReturn(menu);when(click.getCurrentItem()).thenReturn(menu.getItem(0));questController.click(click);drain(f);assertEquals(dungeon.id(),adventures.active().getFirst().site());var partyA=new FantasyStickLifecycleNativeTest.Actor(data);var partyB=new FantasyStickLifecycleNativeTest.Actor(data);for(var member:List.of(partyA,partyB)){member.core.location.setWorld(actor.world);member.core.location.setX(100);f.players.put(member.core.id,member.player);doReturn(java.util.Set.of()).when(member.player).getActivePotionEffects();doReturn(true).when(member.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());f.activate(member);}var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(partyA.core.id).orElseThrow();assertTrue(parties.accept(partyB.core.id,parties.invite(partyA.core.id,party.id(),partyB.core.id).orElseThrow().id()));var catalog=adventures.snapshot().catalog();assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.PARTY,party.id(),Set.of(partyA.core.record.id(),partyB.core.record.id())).get(5,TimeUnit.SECONDS).status());
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var before=storage.find(actor.core.record.id()).get().orElseThrow();var partyABefore=storage.find(partyA.core.record.id()).get().orElseThrow();var partyBBefore=storage.find(partyB.core.record.id()).get().orElseThrow();for(var member:List.of(actor,partyA)){member.core.location.setX(first.bounds().minX()+.5);member.core.location.setY(first.bounds().minY()+1);member.core.location.setZ(first.bounds().minZ()+.5);}
pulse(f);drain(f);pulse(f);drain(f);var initialMobs=spawned.stream().filter(Husk.class::isInstance).toList();assertEquals(2,initialMobs.size());var ordinary=initialMobs.getFirst();assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,dungeonController.encounterOrigin(ordinary).orElseThrow().kind());var survivor=initialMobs.getLast();assertNotEquals(dungeonController.encounterOrigin(ordinary),dungeonController.encounterOrigin(survivor),"each slot has independent kill authority");var survivorOrigin=dungeonController.encounterOrigin(survivor);var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);var partySource=mock(org.bukkit.damage.DamageSource.class);when(partySource.getCausingEntity()).thenReturn(partyA.player);when(partySource.getDirectEntity()).thenReturn(partyA.player);f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,ordinary,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,ordinary,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));var drops=new ArrayList<ItemStack>();drops.add(new ItemStack(Material.ROTTEN_FLESH));var ordinaryDeath=new EntityDeathEvent(ordinary,source,drops,7);f.dispatcher.accept(ordinaryDeath);await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).map(run->run.defeatedSlots().size()==1).orElse(false),actor.messages);assertEquals(7,ordinaryDeath.getDroppedExp());assertFalse(ordinaryDeath.getDrops().isEmpty());var partial=dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow();assertEquals(1,partial.defeatedSlots().size());assertFalse(partial.clearedRooms().contains(first.id()));for(var member:List.of(actor,partyA))member.core.location.setX(100);pulse(f);verify(survivor,atLeastOnce()).remove();assertFalse(actorEffects.containsKey(org.bukkit.potion.PotionEffectType.SPEED));for(var member:List.of(actor,partyA)){member.core.location.setX(first.bounds().minX()+.5);member.core.location.setY(first.bounds().minY()+1);member.core.location.setZ(first.bounds().minZ()+.5);}pulse(f);drain(f);var respawned=spawned.stream().filter(Husk.class::isInstance).toList().getLast();assertNotSame(survivor,respawned);assertEquals(survivorOrigin,dungeonController.encounterOrigin(respawned),"respawned slot retains duplicate-safe kill authority");f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,respawned,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,respawned,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));f.dispatcher.accept(new EntityDeathEvent(respawned,source,new ArrayList<>(),0));await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow().clearedRooms().contains(first.id()),actor.messages);assertTrue(storage.find(partyB.core.record.id()).get().orElseThrow().experience().compareTo(partyBBefore.experience())>0,"ordinary party combat sharing remains active");
pulse(f);drain(f);pulse(f);drain(f);var initialMobs=spawned.stream().filter(Husk.class::isInstance).toList();assertEquals(2,initialMobs.size());var ordinary=initialMobs.getFirst();assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,dungeonController.encounterOrigin(ordinary).orElseThrow().kind());var copiedSlot=mock(Husk.class);when(copiedSlot.getUniqueId()).thenReturn(UUID.randomUUID());var copiedProof=ordinary.getPersistentDataContainer();when(copiedSlot.getPersistentDataContainer()).thenReturn(copiedProof);assertTrue(dungeonController.encounterOrigin(copiedSlot).isEmpty(),"a copied valid dungeon slot tag is not an authenticated registered spawn");var nativeDrop=new ItemStack(Material.ROTTEN_FLESH);var forgedDeath=new EntityDeathEvent(copiedSlot,mock(org.bukkit.damage.DamageSource.class),new ArrayList<>(List.of(nativeDrop)),7);f.dispatcher.accept(forgedDeath);assertEquals(7,forgedDeath.getDroppedExp());assertEquals(List.of(nativeDrop),forgedDeath.getDrops());assertTrue(dungeonController.encounterOrigin(ordinary).isPresent(),"copied callback did not consume the real dungeon slot");var survivor=initialMobs.getLast();assertNotEquals(dungeonController.encounterOrigin(ordinary),dungeonController.encounterOrigin(survivor),"each slot has independent kill authority");var survivorOrigin=dungeonController.encounterOrigin(survivor);var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);var partySource=mock(org.bukkit.damage.DamageSource.class);when(partySource.getCausingEntity()).thenReturn(partyA.player);when(partySource.getDirectEntity()).thenReturn(partyA.player);f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,ordinary,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,ordinary,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));var drops=new ArrayList<ItemStack>();drops.add(new ItemStack(Material.ROTTEN_FLESH));var ordinaryDeath=new EntityDeathEvent(ordinary,source,drops,7);f.dispatcher.accept(ordinaryDeath);await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).map(run->run.defeatedSlots().size()==1).orElse(false),actor.messages);assertEquals(7,ordinaryDeath.getDroppedExp());assertFalse(ordinaryDeath.getDrops().isEmpty());var partial=dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow();assertEquals(1,partial.defeatedSlots().size());assertFalse(partial.clearedRooms().contains(first.id()));for(var member:List.of(actor,partyA))member.core.location.setX(100);pulse(f);verify(survivor,atLeastOnce()).remove();assertFalse(actorEffects.containsKey(org.bukkit.potion.PotionEffectType.SPEED));for(var member:List.of(actor,partyA)){member.core.location.setX(first.bounds().minX()+.5);member.core.location.setY(first.bounds().minY()+1);member.core.location.setZ(first.bounds().minZ()+.5);}pulse(f);drain(f);var respawned=spawned.stream().filter(Husk.class::isInstance).toList().getLast();assertNotSame(survivor,respawned);assertEquals(survivorOrigin,dungeonController.encounterOrigin(respawned),"respawned slot retains duplicate-safe kill authority");f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,respawned,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,respawned,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));f.dispatcher.accept(new EntityDeathEvent(respawned,source,new ArrayList<>(),0));await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow().clearedRooms().contains(first.id()),actor.messages);assertTrue(storage.find(partyB.core.record.id()).get().orElseThrow().experience().compareTo(partyBBefore.experience())>0,"ordinary party combat sharing remains active");
var actorAfterKills=storage.find(actor.core.record.id()).get().orElseThrow();var partyAAfterKills=storage.find(partyA.core.record.id()).get().orElseThrow();var partyBAfterKills=storage.find(partyB.core.record.id()).get().orElseThrow();for(var member:List.of(actor,partyA,partyB)){member.core.location.setX(bossRoom.bounds().minX()+.5);member.core.location.setY(bossRoom.bounds().minY()+1);member.core.location.setZ(bossRoom.bounds().minZ()+.5);}pulse(f);drain(f);var firstBoss=spawned.stream().filter(Vindicator.class::isInstance).findFirst().orElseThrow();for(var member:List.of(actor,partyA,partyB))member.core.location.setX(100);pulse(f);verify(firstBoss,atLeastOnce()).remove();for(var member:List.of(actor,partyA,partyB)){member.core.location.setX(bossRoom.bounds().minX()+.5);member.core.location.setY(bossRoom.bounds().minY()+1);member.core.location.setZ(bossRoom.bounds().minZ()+.5);}pulse(f);drain(f);var boss=spawned.stream().filter(Vindicator.class::isInstance).toList().getLast();assertNotSame(firstBoss,boss);f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,boss,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,boss,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDeathEvent(boss,source,new ArrayList<>(),0));assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,dungeonController.encounterOrigin(boss).orElseThrow().kind(),"boss cleanup retains immutable managed origin authority");
await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).map(DungeonState.Run::defeated).orElse(false)&&adventures.active().isEmpty()&&storage.find(actor.core.record.id()).join().orElseThrow().experience().compareTo(actorAfterKills.experience())>0,actor.messages);var run=dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow();assertTrue(run.defeated(),"dungeon run not defeated");assertFalse(run.clearedRooms().contains(skipped.id()),"boss death completes without clearing every room");assertTrue(adventures.active().isEmpty(),"boss did not close all dungeon quests");assertTrue(adventures.characterClaimed(catalog.revision(),dungeon.id(),actor.core.record.id()),"solo completion claim missing");assertTrue(adventures.characterClaimed(catalog.revision(),dungeon.id(),partyA.core.record.id()),"represented party member claim missing");assertTrue(adventures.characterClaimed(catalog.revision(),dungeon.id(),partyB.core.record.id()),"present snapshotted party member claim missing");var after=storage.find(actor.core.record.id()).get().orElseThrow();assertTrue(after.experience().compareTo(before.experience())>0,"ordinary dungeon kills award standard combat XP");assertTrue(after.coins().compareTo(before.coins())>0,"ordinary dungeon kills award standard character coins");assertTrue(after.experience().compareTo(actorAfterKills.experience())>0,"genuinely killed dungeon boss grants native-classified per-kill XP");assertTrue(after.coins().compareTo(actorAfterKills.coins())>0,"genuinely killed dungeon boss grants native-classified per-kill coins");assertEquals(AdventureQuestDirectory.Reward.empty(),adventures.unclaimed(catalog.revision(),dungeon.id(),actor.core.record.id()));var partyAAfter=storage.find(partyA.core.record.id()).get().orElseThrow();var partyBAfter=storage.find(partyB.core.record.id()).get().orElseThrow();assertTrue(partyAAfter.experience().compareTo(partyABefore.experience())>0,"party contributor receives normal kill XP");assertTrue(partyBAfter.experience().compareTo(partyBBefore.experience())>0);assertTrue(partyAAfter.experience().compareTo(partyAAfterKills.experience())>0);assertTrue(partyBAfter.experience().compareTo(partyBAfterKills.experience())>0);var trophies=(games.dmg.fantasystick.adventure.FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");var soloTrophy=trophies.trophies(actor.core.record.id()).getFirst();var partyTrophy=trophies.trophies(partyA.core.record.id()).getFirst();assertEquals(games.dmg.fantasystick.adventure.FishingState.TrophyType.DUNGEON,soloTrophy.type());assertEquals("Trophy of Sunken Keep",soloTrophy.name());assertEquals(new games.dmg.fantasystick.adventure.FishingState.Reward(BigInteger.valueOf(30),BigInteger.valueOf(5)),soloTrophy.total());assertEquals(new games.dmg.fantasystick.adventure.FishingState.Reward(BigInteger.valueOf(30),BigInteger.valueOf(5)),partyTrophy.total());assertEquals(Set.of(partyA.core.record.id(),partyB.core.record.id()),partyTrophy.rewards().keySet());assertEquals(AdventureQuestDirectory.Reward.empty(),adventures.unclaimed(catalog.revision(),dungeon.id(),partyA.core.record.id()));var defeatedGeneration=adventures.snapshot().catalog().revision();command(f,actor,"dungeon","close",dungeon.id().toString(),"confirm");command(f,actor,"dungeon","open",dungeon.id().toString());assertTrue(actor.messages.getLast().contains("open refused"),actor.messages.toString());assertTrue(dungeons.runView(dungeon.id(),defeatedGeneration).orElseThrow().defeated(),"defeated generation changed");command(f,actor,"adventure","reroll","confirm");await(f,()->adventures.snapshot().reset()==null&&!adventures.snapshot().catalog().revision().equals(defeatedGeneration),actor.messages);assertEquals(1,trophies.trophies(actor.core.record.id()).size(),"reroll preserves earned dungeon trophy");var claimBase=storage.find(actor.core.record.id()).get().orElseThrow();var turn=(games.dmg.fantasystick.adventure.FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");turn.open(actor.player);var trophyMenu=actor.inventories.getLast();var plain=net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText();var redeem=java.util.stream.IntStream.range(0,trophyMenu.getSize()).mapToObj(trophyMenu::getItem).filter(Objects::nonNull).filter(item->item.getItemMeta().displayName()!=null&&plain.serialize(item.getItemMeta().displayName()).equals("Redeem Trophy of Sunken Keep")).findFirst().orElseThrow();var redeemClick=mock(InventoryClickEvent.class);when(redeemClick.getWhoClicked()).thenReturn(actor.player);when(redeemClick.getInventory()).thenReturn(trophyMenu);when(redeemClick.getCurrentItem()).thenReturn(redeem);turn.click(redeemClick);await(f,()->trophies.trophies(actor.core.record.id()).isEmpty()&&trophies.unclaimed(actor.core.record.id()).equals(new games.dmg.fantasystick.adventure.FishingState.Reward(BigInteger.valueOf(30),BigInteger.valueOf(5))),actor.meLine truncated
await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).map(DungeonState.Run::defeated).orElse(false)&&adventures.active().isEmpty()&&storage.find(actor.core.record.id()).join().orElseThrow().experience().compareTo(actorAfterKills.experience())>0,actor.messages);var run=dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow();assertTrue(run.defeated(),"dungeon run not defeated");assertEquals(boss.getUniqueId().toString(),run.completionAudit().get("boss_id"),"global completion must retain immutable native boss proof for recovery");assertFalse(run.clearedRooms().contains(skipped.id()),"boss death completes without clearing every room");assertTrue(adventures.active().isEmpty(),"boss did not close all dungeon quests");assertTrue(adventures.characterClaimed(catalog.revision(),dungeon.id(),actor.core.record.id()),"solo completion claim missing");assertTrue(adventures.characterClaimed(catalog.revision(),dungeon.id(),partyA.core.record.id()),"represented party member claim missing");assertTrue(adventures.characterClaimed(catalog.revision(),dungeon.id(),partyB.core.record.id()),"present snapshotted party member claim missing");var after=storage.find(actor.core.record.id()).get().orElseThrow();assertTrue(after.experience().compareTo(before.experience())>0,"ordinary dungeon kills award standard combat XP");assertTrue(after.coins().compareTo(before.coins())>0,"ordinary dungeon kills award standard character coins");assertTrue(after.experience().compareTo(actorAfterKills.experience())>0,"genuinely killed dungeon boss grants native-classified per-kill XP");assertTrue(after.coins().compareTo(actorAfterKills.coins())>0,"genuinely killed dungeon boss grants native-classified per-kill coins");assertEquals(AdventureQuestDirectory.Reward.empty(),adventures.unclaimed(catalog.revision(),dungeon.id(),actor.core.record.id()));var partyAAfter=storage.find(partyA.core.record.id()).get().orElseThrow();var partyBAfter=storage.find(partyB.core.record.id()).get().orElseThrow();assertTrue(partyAAfter.experience().compareTo(partyABefore.experience())>0,"party contributor receives normal kill XP");assertTrue(partyBAfter.experience().compareTo(partyBBefore.experience())>0);assertTrue(partyAAfter.experience().compareTo(partyAAfterKills.experience())>0);assertTrue(partyBAfter.experience().compareTo(partyBAfterKills.experience())>0);var trophies=(games.dmg.fantasystick.adventure.FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");var soloTrophy=trophies.trophies(actor.core.record.id()).getFirst();var partyTrophy=trophies.trophies(partyA.core.record.id()).getFirst();assertEquals(games.dmg.fantasystick.adventure.FishingState.TrophyType.DUNGEON,soloTrophy.type());assertEquals("Trophy of Sunken Keep",soloTrophy.name());assertEquals(new games.dmg.fantasystick.adventure.FishingState.Reward(BigInteger.valueOf(30),BigInteger.valueOf(5)),soloTrophy.total());assertEquals(new games.dmg.fantasystick.adventure.FishingState.Reward(BigInteger.valueOf(30),BigInteger.valueOf(5)),partyTrophy.total());assertEquals(Set.of(partyA.core.record.id(),partyB.core.record.id()),partyTrophy.rewards().keySet());assertEquals(AdventureQuestDirectory.Reward.empty(),adventures.unclaimed(catalog.revision(),dungeon.id(),partyA.core.record.id()));var defeatedGeneration=adventures.snapshot().catalog().revision();command(f,actor,"dungeon","close",dungeon.id().toString(),"confirm");command(f,actor,"dungeon","open",dungeon.id().toString());assertTrue(actor.messages.getLast().contains("open refused"),actor.messages.toString());assertTrue(dungeons.runView(dungeon.id(),defeatedGeneration).orElseThrow().defeated(),"defeated generation changed");command(f,actor,"adventure","reroll","confirm");await(f,()->adventures.snapshot().reset()==null&&!adventures.snapshot().catalog().revision().equals(defeatedGeneration),actor.messages);assertEquals(1,trophies.trophies(actor.core.record.id()).size(),"reroll preserves earned dungeon trophy");var claimBase=storage.find(actor.core.record.id()).get().orElseThrow();var turn=(games.dmg.fantasystick.adventure.FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");var npcChunk=mock(Chunk.class);when(npcChunk.getWorld()).thenReturn(actor.world);when(npcChunk.getX()).thenReturn(actor.core.location.getBlockX()>>4);when(npcChunk.getZ()).thenReturn(actor.core.location.getBlockZ()>>4);when(npcChunk.getEntities()).thenReturn(new Entity[0]);when(actor.world.getChunkAt(anyInt(),anyInt())).thenReturn(npcChunk);when(actor.world.getLoadedChunks()).thenReturn(new Chunk[]{npcChunk});command(f,actor,"quest-giver","place","Dungeon Steward");var giverLocation=adventures.snapshot().givers().getFirst();actor.core.location.setX(giverLocation.x());actor.core.location.setZ(giverLocation.z());questController.reconcile(npcChunk);var giver=spawned.stream().filter(Villager.class::isInstance).map(Villager.class::cast).findFirst().orElseThrow(Line truncated
await(f,()->exportedDungeonJourneyReady(data),actor.messages);
}
var exported=exportedDungeonEvents(data);
var changes=exported.stream().filter(event->event.get("type").getAsString()
.endsWith("dungeon.reward_changed")).toList();
assertEquals(1,changes.size(),
"the registered persisted dungeon-wide reward change needs one exported event");
var changed=changes.getFirst().getAsJsonObject("data");
assertFalse(changed.get("before_configured").getAsBoolean());
assertEquals("0",changed.get("before_xp").getAsString());
assertEquals("30",changed.get("after_xp").getAsString());
assertEquals("5",changed.get("after_coins").getAsString());
assertNotEquals(changed.get("before_revision").getAsString(),
changed.get("after_revision").getAsString());
assertEquals("future_acceptances",changed.get("application_scope").getAsString());
assertEquals(2,exported.stream().filter(event->event.get("type").getAsString()
.endsWith("adventure.trophy_earned")&&event.getAsJsonObject("data")
.get("trophy_type").getAsString().equals("dungeon_trophy")).count(),
"the two represented units need distinct exported earned receipts");
assertEquals(1,exported.stream().filter(event->event.get("type").getAsString()
.endsWith("dungeon.completed")).count(),
"boss death closes the whole dungeon once, not once per trophy");
var kills=exported.stream().filter(event->event.get("type").getAsString()
.endsWith("combat.reward_resolved")&&event.getAsJsonObject("data")
.get("managed_origin_kind").getAsString().equals("dungeon")).toList();
assertTrue(kills.size()>=3,"ordinary room and boss kills each keep their per-recipient result");
assertTrue(kills.stream().allMatch(event->event.getAsJsonObject("data").has("dungeon_id")
&&event.getAsJsonObject("data").has("room_id")
&&event.getAsJsonObject("data").has("generation_id")
&&event.getAsJsonObject("data").has("spawn_slot_or_boss")),
"managed kill awards must preserve authenticated room and spawn evidence");
var earned=exported.stream().filter(event->event.get("type").getAsString()
.endsWith("adventure.trophy_earned")&&event.getAsJsonObject("data")
.get("trophy_type").getAsString().equals("dungeon_trophy")).toList();
assertTrue(earned.stream().allMatch(event->event.getAsJsonObject("data")
.getAsJsonObject("boss_death_location").has("world_id")));
var completed=exported.stream().filter(event->event.get("type").getAsString()
.endsWith("dungeon.completed")).findFirst().orElseThrow().getAsJsonObject("data");
assertEquals(2,completed.getAsJsonArray("receipt_ids").size());
assertEquals(2,completed.getAsJsonArray("represented_units").size());
assertTrue(completed.getAsJsonObject("death_location").has("world_id"));
assertTrue(earned.stream().allMatch(event->event.getAsJsonObject("data")
.get("boss_id").getAsString().equals(completed.get("boss_id").getAsString())));
var redemptions=exported.stream().filter(event->event.get("type").getAsString()
.endsWith("adventure.trophy_redeemed")&&event.getAsJsonObject("data")
.get("trophy_type").getAsString().equals("dungeon_trophy")).toList();
assertEquals(1,redemptions.size(),
"the exact consumed dungeon head must have one independent redemption event");
assertEquals("entered_via_registered_giver",redemptions.getFirst().getAsJsonObject("data")
.get("giver_status").getAsString());
assertTrue(exported.stream().anyMatch(event->event.get("type").getAsString()
.endsWith("adventure.reward_claimed")&&event.getAsJsonObject("data")
.getAsJsonArray("sources").asList().stream().anyMatch(source->source.getAsJsonObject()
.get("source_kind").getAsString().equals("dungeon_trophy"))
&&event.getAsJsonObject("data").get("giver_status").getAsString()
.equals("entered_via_registered_giver")),
"the later individual claim must retain dungeon receipt provenance and registered Giver origin");
}
private static boolean exportedDungeonJourneyReady(Path root){
try{var events=exportedDungeonEvents(root);
return events.stream().filter(value->value.get("type").getAsString()
.endsWith("dungeon.completed")).count()==1
&&events.stream().filter(value->value.get("type").getAsString()
.endsWith("adventure.trophy_earned")&&value.getAsJsonObject("data")
.get("trophy_type").getAsString().equals("dungeon_trophy")).count()==2
&&events.stream().filter(value->value.get("type").getAsString()
.endsWith("adventure.trophy_redeemed")&&value.getAsJsonObject("data")
.get("trophy_type").getAsString().equals("dungeon_trophy")).count()==1
&&events.stream().anyMatch(value->value.get("type").getAsString()
.endsWith("adventure.reward_claimed")&&value.getAsJsonObject("data")
.getAsJsonArray("sources").asList().stream().anyMatch(source->source.getAsJsonObject()
.get("source_kind").getAsString().equals("dungeon_trophy")));
}catch(Exception transientExport){return false;}
}
private static List<com.google.gson.JsonObject> exportedDungeonEvents(Path root)throws Exception{
try(var files=java.nio.file.Files.walk(root.resolve("events"))){
return files.filter(path->path.toString().endsWith(".jsonl"))
.flatMap(path->{try{return java.nio.file.Files.lines(path);}
catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}})
.map(line->com.google.gson.JsonParser.parseString(line).getAsJsonObject()).toList();
}
}
@Test void failedDungeonRewardSaveAndReadOnlyAdminCommandsCannotInventChangeEvent() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
UUID dungeonId;
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");
when(actor.world.getUID()).thenReturn(UUID.randomUUID());
command(f,actor,"dungeon","create","Acknowledged Vault");
var dungeons=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");
dungeonId=dungeons.snapshot().dungeons().getFirst().id();
command(f,actor,"dungeon","reward",dungeonId.toString());
f.command.execute(actor.player,"fantasystick",
new String[]{"dungeon","reward",dungeonId.toString(),"-1","4"});
assertFalse(dungeons.find(dungeonId).orElseThrow().rewardConfigured());
var file=data.resolve("dungeons.yml");var prior=java.nio.file.Files.readAllBytes(file);
java.nio.file.Files.delete(file);java.nio.file.Files.createDirectory(file);
command(f,actor,"dungeon","reward",dungeonId.toString(),"6","4");
assertFalse(dungeons.find(dungeonId).orElseThrow().rewardConfigured());
java.nio.file.Files.delete(file);java.nio.file.Files.write(file,prior);
command(f,actor,"dungeon","reward",dungeonId.toString(),"6","4");
assertEquals(BigInteger.valueOf(6),dungeons.find(dungeonId).orElseThrow()
.completionReward().xp());
}
var changes=exportedDungeonEvents(data).stream().filter(event->event.get("type")
.getAsString().endsWith("dungeon.reward_changed")).toList();
assertEquals(1,changes.size(),"only a saved administrator change can publish");
var evidence=changes.getFirst().getAsJsonObject("data");
assertEquals(dungeonId.toString(),evidence.get("dungeon_id").getAsString());
assertEquals("6",evidence.get("after_xp").getAsString());
assertEquals("4",evidence.get("after_coins").getAsString());
}
@Test void roomCreationReportsOverlapDistanceLimitsAndPersistenceSeparately() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\ndungeon-room-link-distance: 10\n");
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
@@ -107,21 +220,202 @@ class DungeonNativeTest {
try(var f=new FantasyStickLifecycleNativeTest.Fixture(root)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(root);f.players.put(actor.core.id,actor.player);doReturn(true).when(actor.player).hasPermission("fantasystick.admin");UUID world=UUID.randomUUID();when(actor.world.getUID()).thenReturn(world);var dungeons=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var dungeon=dungeons.create("Custody",world,0,64,0,0,0).get(5,TimeUnit.SECONDS);var room=dungeons.addRadiusRoom(dungeon.id(),"Boss",0,64,0,3,3).get(5,TimeUnit.SECONDS);dungeons.setBoss(dungeon.id(),room.id(),EntityType.HUSK,"Temporary",0,65,0,0,0).get(5,TimeUnit.SECONDS);command(f,actor,"dungeon","edit","start",dungeon.id().toString());var controller=(games.dmg.fantasystick.dungeon.DungeonController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeonController");await(f,()->controller.editing(actor.core.id).isPresent(),actor.messages);actor.core.location.setX(.5);actor.core.location.setY(65);actor.core.location.setZ(.5);assertEquals(List.of("assign-sheet"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"dungeon","room","boss","ass"}));assertEquals(List.of("0.1","1","1.5","10","100"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"dungeon","room","boss","assign-sheet",""}));var sheet=actor.core.inventory.getItemInOffHand();actor.core.inventory.setItemInOffHand(null);actor.core.inventory.setItemInMainHand(sheet);actor.core.inventory.setItem(5,sheet.clone());command(f,actor,"dungeon","room","boss","assign-sheet","1.125");assertTrue(dungeons.custody(actor.core.record.id()).isEmpty());actor.core.inventory.setItem(5,null);var partyDirectory=(games.dmg.fantasystick.party.CharacterPartyDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterParties");var forbiddenParty=partyDirectory.create(actor.core.record.id(),"Before Custody").get(5,TimeUnit.SECONDS).party().orElseThrow();command(f,actor,"dungeon","room","boss","assign-sheet","1.125");assertTrue(dungeons.custody(actor.core.record.id()).isEmpty());assertFalse(actor.core.inventory.getItemInMainHand().isEmpty());partyDirectory.disband(actor.core.record.id(),forbiddenParty.id()).get(5,TimeUnit.SECONDS);command(f,actor,"dungeon","room","boss","assign-sheet","1.125");await(f,()->dungeons.custody(actor.core.record.id()).isPresent(),actor.messages);assertTrue(actor.core.inventory.getItemInMainHand().isEmpty());assertEquals(actor.core.record,dungeons.custody(actor.core.record.id()).orElseThrow().character());actor.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(actor.core.record));var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");sessions.enter(actor.player);assertTrue(sessions.active(actor.core.id).isEmpty());assertTrue(actor.messages.stream().anyMatch(message->message.contains("dungeon boss")));var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertThrows(java.util.concurrent.ExecutionException.class,()->storage.awardExperience(actor.core.record.id(),actor.core.record.revision(),BigInteger.ONE,games.dmg.fantasystick.progression.ProgressionRules.defaults()).get(5,TimeUnit.SECONDS));assertEquals(games.dmg.fantasystick.party.CharacterPartyDirectory.Status.INVALID,partyDirectory.create(actor.core.record.id(),"Forbidden").get(5,TimeUnit.SECONDS).status());actor.core.inventory.setItemInOffHand(null);actor.core.location.setX(100);command(f,actor,"dungeon","edit","done");assertEquals(List.of(dungeon.id().toString()),f.command.tabComplete(actor.player,"fantasystick",new String[]{"dungeon","boss","recover-sheet",""}));command(f,actor,"dungeon","boss","recover-sheet",dungeon.id().toString());await(f,()->dungeons.custody(actor.core.record.id()).isEmpty(),actor.messages);var returned=Arrays.stream(actor.core.inventory.getContents()).filter(Objects::nonNull).filter(item->new games.dmg.fantasystick.items.CharacterSheets().identify(item).filter(actor.core.record.id()::equals).isPresent()).toList();assertEquals(1,returned.size());assertEquals(actor.core.record,storage.find(actor.core.record.id()).get(5,TimeUnit.SECONDS).orElseThrow());assertNull(dungeons.room(dungeon.id(),room.id()).orElseThrow().boss());}
}
@SuppressWarnings({"unchecked","rawtypes"})
@Test void committedNativeBossDefeatReplaysCompletionAfterMissingLivePublication() throws Exception {
var root=data.resolve("completion-replay");Files.createDirectories(root);
Files.writeString(root.resolve("config.yml"),
"adventure-daily-site-count: 0\nmaximum-level: 1\nhostile-coin-drop-min: 1\nhostile-coin-drop-max: 1\n");
var defeatedBoss=new UUID[1];var absentLeader=new UUID[1];var presentMember=new UUID[1];
var completionOperation=new UUID[1];
try(var f=new FantasyStickLifecycleNativeTest.Fixture(root)){
f.plugin.setEnabled(true);
var actor=new FantasyStickLifecycleNativeTest.Actor(root);
f.players.put(actor.core.id,actor.player);
doReturn(true).when(actor.player).hasPermission("fantasystick.admin");
var world=UUID.randomUUID();when(actor.world.getUID()).thenReturn(world);
when(actor.world.isChunkLoaded(anyInt(),anyInt())).thenReturn(true);
actor.core.location.setX(.5);actor.core.location.setY(65);actor.core.location.setZ(.5);
var spawned=new ArrayList<Vindicator>();
doAnswer(call->{var mob=mock(Vindicator.class);
when(mob.getPersistentDataContainer()).thenReturn(
new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
when(mob.getUniqueId()).thenReturn(UUID.randomUUID());
when(mob.getType()).thenReturn(EntityType.VINDICATOR);
when(mob.getWorld()).thenReturn(actor.world);
when(mob.getLocation()).thenReturn(((Location)call.getArgument(0)).clone());
((java.util.function.Consumer<Vindicator>)call.getArgument(2)).accept(mob);
spawned.add(mob);return mob;
}).when(actor.world).spawn(any(Location.class),eq(Vindicator.class),any(java.util.function.Consumer.class));
when(actor.world.getEntities()).thenAnswer(ignored->new ArrayList<Entity>(spawned));
var dungeons=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");
var dungeon=dungeons.create("Replay Vault",world,0,64,0,0,0).get(5,TimeUnit.SECONDS);
var room=dungeons.addRadiusRoom(dungeon.id(),"Boss",0,64,0,3,3).get(5,TimeUnit.SECONDS);
dungeons.setBoss(dungeon.id(),room.id(),EntityType.VINDICATOR,"Keeper",0,65,0,0,0)
.get(5,TimeUnit.SECONDS);
dungeons.setCompletionReward(dungeon.id(),BigInteger.ONE,BigInteger.ZERO)
.get(5,TimeUnit.SECONDS);
command(f,actor,"dungeon","enable",dungeon.id().toString());
command(f,actor,"dungeon","open",dungeon.id().toString());
var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin,"adventureQuests");
f.activate(actor);
var catalog=adventures.snapshot().catalog();
completionOperation[0]=UUID.nameUUIDFromBytes(("dungeon:completed:"+dungeon.id()+":"+
catalog.revision()).getBytes(java.nio.charset.StandardCharsets.UTF_8));
assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(
catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.SOLO,
actor.core.record.id(),Set.of(actor.core.record.id())).get(5,TimeUnit.SECONDS).status());
var leader=new FantasyStickLifecycleNativeTest.Actor(root);
var member=new FantasyStickLifecycleNativeTest.Actor(root);
absentLeader[0]=leader.core.record.id();presentMember[0]=member.core.record.id();
for(var participant:List.of(leader,member)){
participant.core.location.setWorld(actor.world);
participant.core.location.setY(65);
participant.core.location.setZ(.5);
f.players.put(participant.core.id,participant.player);
f.activate(participant);
}
leader.core.location.setX(100);member.core.location.setX(1.5);
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()));
assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(
catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.PARTY,party.id(),
Set.of(leader.core.record.id(),member.core.record.id()))
.get(5,TimeUnit.SECONDS).status());
f.players.remove(leader.core.id);
assertNull(f.plugin.getServer().getPlayer(leader.core.id),
"the trophy carrier is offline when the boss dies");
pulse(f);drain(f);pulse(f);drain(f);
var boss=spawned.getLast();defeatedBoss[0]=boss.getUniqueId();
var source=mock(org.bukkit.damage.DamageSource.class);
when(source.getCausingEntity()).thenReturn(actor.player);
when(source.getDirectEntity()).thenReturn(actor.player);
f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,boss,
EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,2));
var memberSource=mock(org.bukkit.damage.DamageSource.class);
when(memberSource.getCausingEntity()).thenReturn(member.player);
when(memberSource.getDirectEntity()).thenReturn(member.player);
f.dispatcher.accept(new EntityDamageByEntityEvent(member.player,boss,
EntityDamageEvent.DamageCause.ENTITY_ATTACK,memberSource,2));
var owner=(games.dmg.fantasystick.dungeon.DungeonController)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeonController");
var eventField=owner.getClass().getDeclaredField("auditEvents");
eventField.setAccessible(true);eventField.set(owner,null);
f.dispatcher.accept(new EntityDeathEvent(boss,source,new ArrayList<>(),0));
await(f,()->dungeons.runView(dungeon.id(),catalog.revision())
.map(DungeonState.Run::defeated).orElse(false),actor.messages);
assertEquals(defeatedBoss[0].toString(),dungeons.runView(dungeon.id(),catalog.revision())
.orElseThrow().completionAudit().get("boss_id"));
assertEquals(0,exportedDungeonEvents(root).stream().filter(event->event.get("type")
.getAsString().endsWith("dungeon.completed")).count());
}
assertTrue(new games.dmg.fantasystick.persistence.YamlDungeonRepository(root).load().runs()
.stream().anyMatch(run->run.defeated()&&defeatedBoss[0].toString()
.equals(run.completionAudit().get("boss_id"))));
try(var restarted=new FantasyStickLifecycleNativeTest.Fixture(root)){
restarted.plugin.setEnabled(true);
await(restarted,()->{try{return exportedDungeonEvents(root).stream().anyMatch(event->
event.get("type").getAsString().endsWith("dungeon.completed"));}
catch(Exception transientExport){return false;}},List.of());
var runs=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(restarted.plugin,"dungeons");
await(restarted,()->runs.snapshot().runs().stream().anyMatch(run->run.defeated()
&&defeatedBoss[0].toString().equals(run.completionAudit().get("boss_id"))
&&run.completionPublished()),List.of());
}
var repository=new games.dmg.fantasystick.persistence.YamlDungeonRepository(root);
var exportedOnce=repository.load();
assertTrue(exportedOnce.runs().stream().anyMatch(run->run.defeated()
&&defeatedBoss[0].toString().equals(run.completionAudit().get("boss_id"))
&&run.completionPublished()),
"the first exported recovery must durably acknowledge this exact boss");
repository.save(new DungeonState(exportedOnce.dungeons(),exportedOnce.runs().stream()
.map(run->new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),
run.clearedRooms(),run.unitClaims(),run.characterClaims(),run.defeated(),
run.completionAudit(),false)).toList()));
// Simulate a crash after JSONL append but before the separate acknowledgement save.
try(var replayAgain=new FantasyStickLifecycleNativeTest.Fixture(root)){
replayAgain.plugin.setEnabled(true);
var owner=(games.dmg.fantasystick.dungeon.DungeonController)
FantasyStickLifecycleNativeTest.Fixture.field(replayAgain.plugin,"dungeonController");
owner.replayCompletions(); // Force the normal startup replay even on fast test machines.
var events=(games.dmg.fantasystick.events.FishingEventBridge)
FantasyStickLifecycleNativeTest.Fixture.field(replayAgain.plugin,"fishingAuditEvents");
replayAgain.plugin.setEnabled(false);
events.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
}
var exportedId=UUID.nameUUIDFromBytes(("fantasystick:fishing:"+
completionOperation[0]+":dungeon.completed")
.getBytes(java.nio.charset.StandardCharsets.UTF_8));
assertFalse(Files.exists(root.resolve("fishing-event-intents").resolve(exportedId+".pending")),
"a previously exported completion must not queue a fresh intent on a later restart");
assertTrue(repository.load().runs().stream().anyMatch(run->run.defeated()
&&defeatedBoss[0].toString().equals(run.completionAudit().get("boss_id"))
&&run.completionPublished()),
"the exported record repairs a lost acknowledgement before any new publication");
var completed=exportedDungeonEvents(root).stream().filter(event->event.get("type")
.getAsString().endsWith("dungeon.completed")).toList();
assertEquals(1,completed.size(),"a later restart must not repeat an acknowledged completion");
assertEquals(defeatedBoss[0].toString(),completed.getFirst().getAsJsonObject("data")
.get("boss_id").getAsString());
var earned=exportedDungeonEvents(root).stream().filter(event->event.get("type")
.getAsString().endsWith("adventure.trophy_earned")&&event.getAsJsonObject("data")
.get("trophy_type").getAsString().equals("dungeon_trophy")).toList();
assertEquals(2,earned.size(),"both represented solo and party units earn a receipt");
var partyEarned=earned.stream().filter(event->event.getAsJsonObject("data")
.get("unit_kind").getAsString().equals("party")).findFirst().orElseThrow()
.getAsJsonObject("data");
assertEquals(absentLeader[0].toString(),partyEarned.get("carrier_character_id").getAsString());
assertEquals(presentMember[0].toString(),partyEarned.getAsJsonArray("beneficiaries")
.get(0).getAsJsonObject().get("character_id").getAsString());
assertEquals(1,partyEarned.getAsJsonArray("beneficiaries").size());
var bossAwards=exportedDungeonEvents(root).stream().filter(event->event.get("type")
.getAsString().endsWith("combat.reward_resolved")&&event.getAsJsonObject("data")
.get("target_id").getAsString().equals(defeatedBoss[0].toString())).toList();
assertTrue(bossAwards.stream().anyMatch(event->{var award=event.getAsJsonObject("data");
return award.get("allocated_experience").getAsBigInteger().signum()>0
&&award.get("applied_experience").getAsString().equals("0")
&&award.get("loss_reason").getAsString().equals("maximum_level");}),
"native boss kill at the configured maximum must distinguish allocated from applied XP");
}
@SuppressWarnings({"unchecked","rawtypes"})
@Test void characterBossMaterializesScaledVindicatorAndFixatesOnAcceptedThreat() throws Exception {
var root=data.resolve("character-runtime");java.nio.file.Files.createDirectories(root);
java.nio.file.Files.writeString(root.resolve("config.yml"),"adventure-daily-site-count: 0\n");
var killedBossId=new UUID[1];var savedBossCharacter=new UUID[1];
try(var f=new FantasyStickLifecycleNativeTest.Fixture(root)){
f.plugin.setEnabled(true);
var admin=new FantasyStickLifecycleNativeTest.Actor(root);f.players.put(admin.core.id,admin.player);doReturn(true).when(admin.player).hasPermission("fantasystick.admin");var bossStats=new java.util.EnumMap<games.dmg.fantasystick.progression.Stat,BigInteger>(games.dmg.fantasystick.progression.Stat.class);for(var stat:games.dmg.fantasystick.progression.Stat.values())bossStats.put(stat,BigInteger.valueOf(30));var abilities=List.of("fireball","frost_nova","hex","second_wind","purify","stone_skin","guardian_bond");var configuredBoss=new games.dmg.fantasystick.persistence.CharacterRecord(admin.core.record.id(),admin.core.record.name(),BigInteger.ONE,bossStats,admin.core.record.level(),admin.core.record.experience(),admin.core.record.unspentPoints(),java.math.BigDecimal.valueOf(40),Set.copyOf(abilities),abilities,List.of("speed_i","low_light_vision","quicker_regeneration_i"),Map.of("fireball",java.time.Instant.parse("2099-01-01T00:00:00Z")),Map.of(),false,null,null,admin.core.record.coins(),admin.core.record.coinsInitialized(),admin.core.record.rewardReceipts(),admin.core.record.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));new games.dmg.fantasystick.persistence.YamlCharacterRepository(root).replace(configuredBoss,BigInteger.ZERO);
var admin=new FantasyStickLifecycleNativeTest.Actor(root);f.players.put(admin.core.id,admin.player);doReturn(true).when(admin.player).hasPermission("fantasystick.admin");var bossStats=new java.util.EnumMap<games.dmg.fantasystick.progression.Stat,BigInteger>(games.dmg.fantasystick.progression.Stat.class);for(var stat:games.dmg.fantasystick.progression.Stat.values())bossStats.put(stat,BigInteger.valueOf(30));var abilities=List.of("fireball","frost_nova","hex","second_wind","purify","stone_skin","guardian_bond");var configuredBoss=new games.dmg.fantasystick.persistence.CharacterRecord(admin.core.record.id(),admin.core.record.name(),BigInteger.ONE,bossStats,admin.core.record.level(),admin.core.record.experience(),admin.core.record.unspentPoints(),java.math.BigDecimal.valueOf(40),Set.copyOf(abilities),abilities,List.of("speed_i","low_light_vision","quicker_regeneration_i"),Map.of("fireball",java.time.Instant.parse("2099-01-01T00:00:00Z")),Map.of(),false,null,null,admin.core.record.coins(),admin.core.record.coinsInitialized(),admin.core.record.rewardReceipts(),admin.core.record.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));new games.dmg.fantasystick.persistence.YamlCharacterRepository(root).replace(configuredBoss,BigInteger.ZERO);savedBossCharacter[0]=configuredBoss.id();
UUID worldId=UUID.randomUUID();when(admin.world.getUID()).thenReturn(worldId);when(admin.world.isChunkLoaded(anyInt(),anyInt())).thenReturn(true);
var spawned=new ArrayList<Vindicator>();var maximumBases=new IdentityHashMap<Vindicator,double[]>();var attackBases=new IdentityHashMap<Vindicator,double[]>();var bossEffects=new IdentityHashMap<Vindicator,Set<org.bukkit.potion.PotionEffect>>();var bossEquipment=new IdentityHashMap<Vindicator,org.bukkit.inventory.EntityEquipment>();
doAnswer(call->{var mob=mock(Vindicator.class);var pdc=new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());var maximum=mock(org.bukkit.attribute.AttributeInstance.class);var damage=mock(org.bukkit.attribute.AttributeInstance.class);var equipment=mock(org.bukkit.inventory.EntityEquipment.class);var maximumBase=new double[]{20};var attackBase=new double[]{1};var health=new double[]{20};var effects=new LinkedHashSet<org.bukkit.potion.PotionEffect>();effects.add(new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.WEAKNESS,200,0));maximumBases.put(mob,maximumBase);attackBases.put(mob,attackBase);bossEffects.put(mob,effects);bossEquipment.put(mob,equipment);when(mob.getPersistentDataContainer()).thenReturn(pdc);when(mob.getUniqueId()).thenReturn(UUID.randomUUID());when(mob.getType()).thenReturn(EntityType.VINDICATOR);when(mob.getWorld()).thenReturn(admin.world);when(mob.getLocation()).thenReturn(((Location)call.getArgument(0)).clone());when(mob.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).thenReturn(maximum);when(mob.getAttribute(org.bukkit.attribute.Attribute.ATTACK_DAMAGE)).thenReturn(damage);when(maximum.getValue()).thenAnswer(ignored->maximumBase[0]);doAnswer(invocation->{maximumBase[0]=(Double)invocation.getArgument(0);return null;}).when(maximum).setBaseValue(anyDouble());doAnswer(invocation->{attackBase[0]=(Double)invocation.getArgument(0);return null;}).when(damage).setBaseValue(anyDouble());when(mob.getHealth()).thenAnswer(ignored->health[0]);doAnswer(invocation->{health[0]=(Double)invocation.getArgument(0);return null;}).when(mob).setHealth(anyDouble());when(mob.getEquipment()).thenReturn(equipment);when(mob.hasLineOfSight(any(Entity.class))).thenReturn(true);when(mob.getActivePotionEffects()).thenAnswer(ignored->Set.copyOf(effects));doAnswer(invocation->{effects.add(invocation.getArgument(0));return true;}).when(mob).addPotionEffect(any(org.bukkit.potion.PotionEffect.class));doAnswer(invocation->{effects.add(invocation.getArgument(0));return true;}).when(mob).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());doAnswer(invocation->{effects.removeIf(effect->effect.getType().equals(invocation.getArgument(0)));return null;}).when(mob).removePotionEffect(any(org.bukkit.potion.PotionEffectType.class));((java.util.function.Consumer<Vindicator>)call.getArgument(2)).accept(mob);spawned.add(mob);return mob;}).when(admin.world).spawn(any(Location.class),eq(Vindicator.class),any(java.util.function.Consumer.class));
when(admin.world.getEntities()).thenAnswer(ignored->new ArrayList<Entity>(spawned));
var dungeons=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var dungeon=dungeons.create("Mirror",worldId,0,64,0,0,0).get(5,TimeUnit.SECONDS);var room=dungeons.addRadiusRoom(dungeon.id(),"Boss",0,64,0,3,3).get(5,TimeUnit.SECONDS);dungeons.setBoss(dungeon.id(),room.id(),EntityType.HUSK,"Temporary",0,65,0,0,0).get(5,TimeUnit.SECONDS);dungeons.setCompletionReward(dungeon.id(),BigInteger.ZERO,BigInteger.ZERO).get(5,TimeUnit.SECONDS);command(f,admin,"dungeon","edit","start",dungeon.id().toString());var controller=(games.dmg.fantasystick.dungeon.DungeonController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeonController");await(f,()->controller.editing(admin.core.id).isPresent(),admin.messages);admin.core.location.setX(.5);admin.core.location.setY(65);admin.core.location.setZ(.5);var sheet=admin.core.inventory.getItemInOffHand();admin.core.inventory.setItemInOffHand(null);admin.core.inventory.setItemInMainHand(sheet);command(f,admin,"dungeon","room","boss","assign-sheet","2");await(f,()->dungeons.custody(admin.core.record.id()).isPresent(),admin.messages);command(f,admin,"dungeon","edit","done");command(f,admin,"dungeon","enable",dungeon.id().toString());command(f,admin,"dungeon","open",dungeon.id().toString());var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");await(f,()->adventures.snapshot().catalog()!=null&&adventures.snapshot().catalog().sites().contains(dungeon.id()),admin.messages);
var fighter=new FantasyStickLifecycleNativeTest.Actor(root);fighter.core.location.setWorld(admin.world);fighter.core.location.setX(.5);fighter.core.location.setY(65);fighter.core.location.setZ(.5);f.players.put(fighter.core.id,fighter.player);doReturn(false).when(fighter.player).isDead();doReturn(true).when(fighter.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());var abilityHits=new ArrayList<Double>();fighter.damage=(amount,source)->abilityHits.add(amount);f.activate(fighter);var catalog=adventures.snapshot().catalog();assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.SOLO,fighter.core.record.id(),Set.of(fighter.core.record.id())).get(5,TimeUnit.SECONDS).status());var challenger=new FantasyStickLifecycleNativeTest.Actor(root);challenger.core.location.setWorld(admin.world);challenger.core.location.setX(1.5);challenger.core.location.setY(65);challenger.core.location.setZ(.5);f.players.put(challenger.core.id,challenger.player);doReturn(false).when(challenger.player).isDead();doReturn(true).when(challenger.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());challenger.damage=(amount,sourceEntity)->{};f.activate(challenger);assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.SOLO,challenger.core.record.id(),Set.of(challenger.core.record.id())).get(5,TimeUnit.SECONDS).status());pulse(f);drain(f);pulse(f);drain(f);assertFalse(spawned.isEmpty());var boss=spawned.getLast();assertEquals(70d,maximumBases.get(boss)[0]);assertEquals(26d,attackBases.get(boss)[0]);var equipment=bossEquipment.get(boss);verify(equipment).setChestplate(argThat(item->item!=null&&item.getType()==Material.COPPER_CHESTPLATE));verify(equipment).setLeggings(argThat(item->item!=null&&item.getType()==Material.GOLDEN_LEGGINGS));verify(equipment).setBoots(argThat(item->item!=null&&item.getType()==Material.IRON_BOOTS));assertTrue(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.SPEED)),bossEffects.get(boss)::toString);assertTrue(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.NIGHT_VISION)),bossEffects.get(boss)::toString);boss.setHealth(50);var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(fighter.player);when(source.getDirectEntity()).thenReturn(fighter.player);f.dispatcher.accept(new EntityDamageByEntityEvent(fighter.player,boss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3));pulse(f);verify(boss,atLeastOnce()).setTarget(fighter.player);assertTrue(abilityHits.size()>=2,"Every ready offensive slot casts in the same pulse");int firstCasts=abilityHits.size();pulse(f);assertEquals(firstCasts,abilityHits.size(),"Boss-only cooldowns suppress immediate recasts");assertFalse(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.WEAKNESS)),"Purify removes a real harmful effect");var challengerSource=mock(org.bukkit.damage.DamageSource.class);when(challengerSource.getCausingEntity()).thenReturn(challenger.player);when(challengerSource.getDirectEntity()).thenReturn(challenger.player);f.dispatcher.accept(new EntityDamageByEntityEvent(challenger.player,boss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,challengerSource,10));pulse(f);var targets=org.mockito.ArgumentCaptor.forClass(LivingEntity.class);verify(boss,atLeastOnce()).setTarget(targets.capture());assertSame(fighter.player,targets.getAllValues().getLast(),"Higher later threat cannot break fixation");fighter.core.location.setX(100);pulse(f);targets=org.mockito.ArgumentCaptor.forClass(LivingEntity.class);verify(boss,atLeastOnce()).setTarget(targets.capture());assertSame(challenger.player,targets.getAllValues().getLast(),"Ineligible fixation retargets highest remaining threat");
challenger.core.location.setX(100);pulse(f);verify(boss,atLeastOnce()).remove();challenger.core.location.setX(.5);pulse(f);drain(f);var freshBoss=spawned.getLast();assertNotSame(boss,freshBoss);assertEquals(70d,maximumBases.get(freshBoss)[0]);f.dispatcher.accept(new EntityDamageByEntityEvent(challenger.player,freshBoss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,challengerSource,2));f.dispatcher.accept(new EntityDeathEvent(freshBoss,challengerSource,new ArrayList<>(),0));await(f,()->dungeons.runView(dungeon.id(),catalog.revision()).map(DungeonState.Run::defeated).orElse(false),admin.messages);assertTrue(dungeons.custody(admin.core.record.id()).isPresent(),"Boss defeat keeps character custody for later days");challenger.core.location.setX(100);command(f,admin,"dungeon","disable",dungeon.id().toString(),"confirm");admin.core.location.setX(100);command(f,admin,"dungeon","boss","recover-sheet",dungeon.id().toString());await(f,()->dungeons.custody(admin.core.record.id()).isEmpty(),admin.messages);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(configuredBoss,storage.find(configuredBoss.id()).get(5,TimeUnit.SECONDS).orElseThrow());assertTrue(Arrays.stream(admin.core.inventory.getContents()).filter(Objects::nonNull).anyMatch(item->new games.dmg.fantasystick.items.CharacterSheets().identify(item).filter(configuredBoss.id()::equals).isPresent()));
challenger.core.location.setX(100);pulse(f);verify(boss,atLeastOnce()).remove();challenger.core.location.setX(.5);pulse(f);drain(f);var freshBoss=spawned.getLast();killedBossId[0]=freshBoss.getUniqueId();assertNotSame(boss,freshBoss);assertEquals(70d,maximumBases.get(freshBoss)[0]);f.dispatcher.accept(new EntityDamageByEntityEvent(challenger.player,freshBoss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,challengerSource,2));f.dispatcher.accept(new EntityDeathEvent(freshBoss,challengerSource,new ArrayList<>(),0));await(f,()->dungeons.runView(dungeon.id(),catalog.revision()).map(DungeonState.Run::defeated).orElse(false),admin.messages);assertTrue(dungeons.custody(admin.core.record.id()).isPresent(),"Boss defeat keeps character custody for later days");await(f,()->{try{var published=exportedDungeonEvents(root);return published.stream().anyMatch(event->event.get("type").getAsString().endsWith("dungeon.completed"))&&published.stream().anyMatch(event->event.get("type").getAsString().endsWith("adventure.trophy_earned")&&event.getAsJsonObject("data").get("trophy_type").getAsString().equals("dungeon_trophy"));}catch(Exception transientExport){return false;}},admin.messages);challenger.core.location.setX(100);command(f,admin,"dungeon","disable",dungeon.id().toString(),"confirm");admin.core.location.setX(100);command(f,admin,"dungeon","boss","recover-sheet",dungeon.id().toString());await(f,()->dungeons.custody(admin.core.record.id()).isEmpty(),admin.messages);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(configuredBoss,storage.find(configuredBoss.id()).get(5,TimeUnit.SECONDS).orElseThrow());assertTrue(Arrays.stream(admin.core.inventory.getContents()).filter(Objects::nonNull).anyMatch(item->new games.dmg.fantasystick.items.CharacterSheets().identify(item).filter(configuredBoss.id()::equals).isPresent()));
await(f,()->{try{var published=exportedDungeonEvents(root);return published.stream().anyMatch(event->event.get("type").getAsString().endsWith("dungeon.completed"))&&published.stream().anyMatch(event->event.get("type").getAsString().endsWith("adventure.trophy_earned")&&event.getAsJsonObject("data").get("trophy_type").getAsString().equals("dungeon_trophy"));}catch(Exception transientExport){return false;}},admin.messages);
}
var exported=exportedDungeonEvents(root);
var bossAwards=exported.stream().filter(event->event.get("type").getAsString()
.endsWith("combat.reward_resolved")&&event.getAsJsonObject("data")
.get("target_id").getAsString().equals(killedBossId[0].toString())).toList();
assertFalse(bossAwards.isEmpty(),"native character-sheet boss keeps its ordinary mob kill reward");
assertTrue(bossAwards.stream().allMatch(event->event.getAsJsonObject("data")
.get("managed_origin_kind").getAsString().equals("dungeon")
&&event.getAsJsonObject("data").get("target_type").getAsString().equals("vindicator")));
var earned=exported.stream().filter(event->event.get("type").getAsString()
.endsWith("adventure.trophy_earned")&&event.getAsJsonObject("data")
.get("trophy_type").getAsString().equals("dungeon_trophy")).toList();
assertEquals(1,earned.size(),"only the present represented quest unit receives a head");
assertEquals(killedBossId[0].toString(),earned.getFirst().getAsJsonObject("data")
.get("boss_id").getAsString());
assertFalse(earned.getFirst().toString().contains(savedBossCharacter[0].toString()),
"the boss head must not export the character held in dungeon custody");
assertEquals(1,exported.stream().filter(event->event.get("type").getAsString()
.endsWith("dungeon.completed")).count());
}
@Test void questGiverPaginatesManualDungeonOffersBeyondAutomaticCount() throws Exception {
@@ -1615,6 +1615,7 @@ class FantasyStickLifecycleNativeTest {
final List<org.bukkit.Sound> sounds = new ArrayList<>();
final List<net.kyori.adventure.title.Title> titles = new ArrayList<>();
final List<Object[]> resourcePacks = new ArrayList<>();
final List<java.util.UUID> removedResourcePacks = new ArrayList<>();
final java.util.Set<org.bukkit.NamespacedKey> knownRecipes = new java.util.LinkedHashSet<>();
final List<List<org.bukkit.NamespacedKey>> recipeAwards = new ArrayList<>();
final net.minecraft.world.entity.ai.attributes.AttributeMap healthAttributes = new net.minecraft.world.entity.ai.attributes.AttributeMap(
@@ -1720,6 +1721,7 @@ class FantasyStickLifecycleNativeTest {
case "playSound" -> { if (args.length > 1 && args[1] instanceof org.bukkit.Sound sound) sounds.add(sound); yield null; }
case "showTitle" -> { titles.add((net.kyori.adventure.title.Title)args[0]); yield null; }
case "addResourcePack" -> { resourcePacks.add(args.clone()); yield null; }
case "removeResourcePack" -> { removedResourcePacks.add((java.util.UUID)args[0]); yield null; }
case "hasDiscoveredRecipe" -> knownRecipes.contains((org.bukkit.NamespacedKey) args[0]);
case "discoverRecipes" -> {
var requested = ((java.util.Collection<?>) args[0]).stream()
@@ -0,0 +1,237 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.events.EventInput;
import games.dmg.fantasystick.events.FishingEventBridge;
import games.dmg.fantasystick.events.LocalEventLog;
import games.dmg.fantasystick.session.EquipmentSessions;
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.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class FishingEventBridgePartsNativeTest {
@org.junit.jupiter.api.BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@TempDir Path data;
@Test void campStartupReplayDoesNotRepublishAnAlreadyExportedUnacknowledgedIntent() throws Exception {
var character=UUID.randomUUID();var receiptId=UUID.randomUUID();
var receipt=new games.dmg.fantasystick.adventure.FishingState.Trophy(receiptId,
games.dmg.fantasystick.adventure.FishingState.TrophyType.CAMP,character,
UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),
"Spoils of Restart Camp","Restart Camp",Map.of(character,
new games.dmg.fantasystick.adventure.FishingState.Reward(
java.math.BigInteger.ONE,java.math.BigInteger.ZERO)),
games.dmg.fantasystick.adventure.FishingState.TrophyPhase.EARNED,
Map.of("final_enemy_id",UUID.randomUUID().toString(),"unit_kind","solo",
"unit_id",character.toString(),"accepted_roster",character.toString(),
"arena_shape","circle","arena_x_blocks","10","arena_z_blocks","10"));
var log=LocalEventLog.open(data,Clock.systemUTC(),
new LocalEventLog.RuntimeInfo("test","test","test"),128,1_000_000,ignored->{});
log.ready().toCompletableFuture().get(5,TimeUnit.SECONDS);
var allowFirstAcknowledgement=new CompletableFuture<Void>();
var firstExported=new CompletableFuture<Void>();
var deliveries=new java.util.concurrent.atomic.AtomicInteger();
var bridge=new FishingEventBridge(event->{
var result=log.emit(event);
if(deliveries.incrementAndGet()==1)return result.thenCompose(status->{
firstExported.complete(null);
return allowFirstAcknowledgement.thenApply(ignored->status);
});
return result;
},Clock.systemUTC(),data,mock(EquipmentSessions.class),ignored->{});
bridge.trophyEarned(receipt);
firstExported.get(5,TimeUnit.SECONDS);
bridge.replay();
bridge.close();bridge.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
allowFirstAcknowledgement.complete(null);
log.close();
var earned=new ArrayList<com.google.gson.JsonObject>();
try(var files=java.nio.file.Files.walk(data.resolve("events"))){
for(var path:files.filter(value->value.toString().endsWith(".jsonl")).toList())
for(var line:java.nio.file.Files.readAllLines(path)){
var event=com.google.gson.JsonParser.parseString(line).getAsJsonObject();
if(event.get("type").getAsString().endsWith("adventure.trophy_earned"))earned.add(event);
}
}
assertEquals(1,earned.size(),()->"camp receipt="+receiptId+" exported IDs="
+earned.stream().map(event->event.get("id").getAsString()).toList());
assertEquals(receiptId.toString(),earned.getFirst().getAsJsonObject("data")
.get("receipt_id").getAsString());
}
@Test void rejectedChampionIntentReplaysFromFrozenReceiptIntoExportedJsonl() throws Exception {
var carrier=UUID.randomUUID();var zero=UUID.randomUUID();var receiptId=UUID.randomUUID();
var receipt=new games.dmg.fantasystick.adventure.FishingState.Trophy(receiptId,
games.dmg.fantasystick.adventure.FishingState.TrophyType.CHAMPION,carrier,
UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),
"Daybreak Champion Head","Peak",Map.of(carrier,
new games.dmg.fantasystick.adventure.FishingState.Reward(
java.math.BigInteger.ONE,java.math.BigInteger.ZERO),zero,
games.dmg.fantasystick.adventure.FishingState.Reward.empty()),
games.dmg.fantasystick.adventure.FishingState.TrophyPhase.EARNED,
Map.of("champion_id",UUID.randomUUID().toString(),"accepted_roster",
carrier+","+zero,"unit_kind","party","unit_id",UUID.randomUUID().toString()));
var refused=new FishingEventBridge(event->CompletableFuture.completedFuture(
LocalEventLog.Delivery.REJECTED),Clock.systemUTC(),data,mock(EquipmentSessions.class),ignored->{});
refused.trophyEarned(receipt);refused.close();refused.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
var log=LocalEventLog.open(data,Clock.systemUTC(),
new LocalEventLog.RuntimeInfo("test","test","test"),128,1_000_000,ignored->{});
assertEquals(LocalEventLog.Delivery.EXPORTED,log.ready().toCompletableFuture().get(5,TimeUnit.SECONDS));
var recovered=new FishingEventBridge(log::emit,Clock.systemUTC(),data,
mock(EquipmentSessions.class),ignored->{});
recovered.replay();recovered.close();recovered.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
log.close();
var jsonl=new ArrayList<com.google.gson.JsonObject>();
try(var files=java.nio.file.Files.walk(data.resolve("events"))){
for(var path:files.filter(value->value.toString().endsWith(".jsonl")).toList())
for(var line:java.nio.file.Files.readAllLines(path))
jsonl.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
var earned=jsonl.stream().filter(value->value.get("type").getAsString()
.endsWith("adventure.trophy_earned")).toList();
assertEquals(1,earned.size());
var details=earned.getFirst().getAsJsonObject("data");
assertEquals(receiptId.toString(),details.get("receipt_id").getAsString());
assertEquals(2,details.getAsJsonArray("accepted_roster").size());
assertEquals(2,details.getAsJsonArray("beneficiaries").size());
assertEquals(1,details.getAsJsonArray("beneficiaries").asList().stream()
.filter(value->value.getAsJsonObject().get("allocation_status").getAsString()
.equals("zero_share_completion")).count());
}
@Test void rejectedCampIntentReplaysSavedArenaAndZeroShareIntoExportedJsonl() throws Exception {
var zero=UUID.randomUUID();var positive=UUID.randomUUID();var receiptId=UUID.randomUUID();
var receipt=new games.dmg.fantasystick.adventure.FishingState.Trophy(receiptId,
games.dmg.fantasystick.adventure.FishingState.TrophyType.CAMP,zero,
UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),
"Spoils of Twin Camp","Twin Camp",Map.of(zero,
games.dmg.fantasystick.adventure.FishingState.Reward.empty(),positive,
new games.dmg.fantasystick.adventure.FishingState.Reward(
java.math.BigInteger.ONE,java.math.BigInteger.ZERO)),
games.dmg.fantasystick.adventure.FishingState.TrophyPhase.EARNED,
Map.of("final_enemy_id",UUID.randomUUID().toString(),"accepted_roster",
zero+","+positive,"unit_kind","party","unit_id",UUID.randomUUID().toString(),
"arena_shape","circle","arena_x_blocks","10","arena_z_blocks","10"));
var refused=new FishingEventBridge(event->CompletableFuture.completedFuture(
LocalEventLog.Delivery.REJECTED),Clock.systemUTC(),data,mock(EquipmentSessions.class),ignored->{});
var other=new games.dmg.fantasystick.adventure.FishingState.Trophy(UUID.randomUUID(),
receipt.type(),receipt.carrier(),UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),
UUID.randomUUID(),receipt.name(),receipt.origin(),receipt.rewards(),receipt.phase(),receipt.audit());
refused.trophyEarned(receipt);refused.trophyEarned(other);
refused.close();refused.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
var log=LocalEventLog.open(data,Clock.systemUTC(),
new LocalEventLog.RuntimeInfo("test","test","test"),128,1_000_000,ignored->{});
log.ready().toCompletableFuture().get(5,TimeUnit.SECONDS);
var recovered=new FishingEventBridge(log::emit,Clock.systemUTC(),data,
mock(EquipmentSessions.class),ignored->{});
recovered.replay();recovered.close();recovered.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
log.close();
var rows=new ArrayList<com.google.gson.JsonObject>();
try(var files=java.nio.file.Files.walk(data.resolve("events"))){
for(var path:files.filter(value->value.toString().endsWith(".jsonl")).toList())
for(var line:java.nio.file.Files.readAllLines(path))
rows.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
var earned=rows.stream().filter(value->value.get("type").getAsString()
.endsWith("adventure.trophy_earned")).toList();
assertEquals(2,earned.size(),"same-name sites retain independent exported receipts");
assertEquals(2,earned.stream().map(event->event.getAsJsonObject("data")
.get("site_id").getAsString()).distinct().count());
assertEquals(2,earned.stream().map(event->event.getAsJsonObject("data")
.get("receipt_id").getAsString()).distinct().count());
assertEquals(1,earned.stream().map(event->event.getAsJsonObject("data")
.get("trophy_name").getAsString()).distinct().count());
var evidence=earned.stream().map(value->value.getAsJsonObject("data"))
.filter(value->value.get("receipt_id").getAsString().equals(receiptId.toString()))
.findFirst().orElseThrow();
assertEquals("camp_trophy",evidence.get("trophy_type").getAsString());
assertEquals("circle",evidence.getAsJsonObject("arena").get("shape").getAsString());
assertEquals(2,evidence.getAsJsonArray("beneficiaries").size());
assertEquals(1,evidence.getAsJsonArray("beneficiaries").asList().stream()
.filter(value->value.getAsJsonObject().get("allocation_status").getAsString()
.equals("zero_share_completion")).count());
}
@Test void largeChampionRosterAndFrozenBeneficiariesKeepSeparateLinkedProof() throws Exception {
var published=new ArrayList<EventInput>();var failures=new ArrayList<Throwable>();
var bridge=new FishingEventBridge(event->{published.add(event);
return CompletableFuture.completedFuture(LocalEventLog.Delivery.EXPORTED);
},Clock.systemUTC(),data,mock(EquipmentSessions.class),failures::add);
var roster=new ArrayList<String>();var beneficiaries=new ArrayList<Map<String,String>>();
for(int index=0;index<600;index++){
var id=UUID.randomUUID().toString();roster.add(id);
if(index<90)beneficiaries.add(Map.of("character_id",id,"allocated_xp","0",
"allocated_coins","0","allocation_status","zero_share_completion"));
}
var dataMap=new LinkedHashMap<String,Object>();dataMap.put("accepted_roster",roster);
dataMap.put("beneficiaries",beneficiaries);dataMap.put("receipt_id",UUID.randomUUID().toString());
var receipt=UUID.randomUUID();bridge.durable("adventure.trophy_earned",receipt,
new FishingEventBridge.Actor("system",null),dataMap);
bridge.close();bridge.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
assertTrue(failures.isEmpty(),failures.toString());
assertEquals(35,published.size());
assertEquals(600,published.stream().mapToInt(event->((List<?>)event.data()
.get("accepted_roster")).size()).sum());
assertEquals(90,published.stream().mapToInt(event->((List<?>)event.data()
.get("beneficiaries")).size()).sum());
assertEquals(35,published.stream().map(event->event.id()).distinct().count());
assertTrue(published.stream().allMatch(event->event.actionId().equals(receipt)
&&event.data().get("part_count").equals(35)));
}
@Test void unavailablePlayerWorldDoesNotEraseAnAuthorizedConfigRead() throws Exception {
var published=new ArrayList<EventInput>();
var sessions=mock(EquipmentSessions.class);
var player=mock(org.bukkit.entity.Player.class);
var id=UUID.randomUUID();when(player.getUniqueId()).thenReturn(id);
when(player.getName()).thenReturn("Captain");
when(sessions.active(id)).thenReturn(java.util.Optional.empty());
var world=mock(org.bukkit.World.class);when(world.getName()).thenReturn(null);
var location=new org.bukkit.Location(world,1,70,2);when(player.getLocation()).thenReturn(location);
var bridge=new FishingEventBridge(event->{published.add(event);
return CompletableFuture.completedFuture(LocalEventLog.Delivery.EXPORTED);
},Clock.systemUTC(),data,sessions,ignored->{});
var actor=bridge.actor(player);assertNotNull(actor);
bridge.configRead(actor,"fishing-catch-xp-min","2","1");
bridge.close();bridge.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
assertEquals(1,published.size());
assertEquals("unavailable",published.getFirst().data().get("location_status"));
assertEquals(id,published.getFirst().context().playerId());
}
@Test void largeFrozenBeneficiaryRosterUsesBoundedLinkedPartsWithoutDroppingZeroShares() throws Exception {
var published=new ArrayList<EventInput>();var failures=new ArrayList<Throwable>();
var bridge=new FishingEventBridge(event->{published.add(event);
return CompletableFuture.completedFuture(LocalEventLog.Delivery.EXPORTED);
},Clock.systemUTC(),data,mock(EquipmentSessions.class),failures::add);
var recipients=new ArrayList<Map<String,String>>();var roster=new ArrayList<String>();
for(int index=0;index<90;index++){
var id=UUID.randomUUID().toString();roster.add(id);
recipients.add(Map.of("character_id",id,"allocated_xp",index==0?"1":"0",
"allocated_coins","0","allocation_status",index==0?"positive":"zero_share_completion",
"receipt_id",UUID.randomUUID().toString()));
}
var values=new LinkedHashMap<String,Object>();values.put("beneficiaries",recipients);values.put("roster",roster);
values.put("site_id",UUID.randomUUID().toString());
var operation=UUID.randomUUID();
bridge.durable("adventure.items_submitted",operation,new FishingEventBridge.Actor("console",null),values);
bridge.close();bridge.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
assertTrue(failures.isEmpty(),failures.toString());
assertEquals(5,published.size(),"parts must fit the durable per-intent bound");
assertEquals(90,published.stream().mapToInt(event->((List<?>)event.data().get("beneficiaries")).size()).sum());
assertEquals(90,published.stream().mapToInt(event->((List<?>)event.data().get("roster")).size()).sum());
assertEquals(5,published.stream().map(event->event.id()).distinct().count());
assertTrue(published.stream().allMatch(event->event.actionId().equals(operation)
&&event.data().get("part_count").equals(5)));
}
}
@@ -25,28 +25,54 @@ final class FishingQuestNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@SuppressWarnings("unchecked")
@Test void registeredFishingDefinitionCatchInventorySubmissionResetAndClaimUseConnectedPath() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 1\nfishing-catch-xp-min: 2\nfishing-catch-xp-max: 2\n");
UUID giverId=null,giverWorld=null;
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");UUID world=UUID.randomUUID();when(actor.world.getUID()).thenReturn(world);when(actor.world.getName()).thenReturn("Fishing Realm");f.activate(actor);doReturn(null).when(actor.player).getFishHook();
assertTrue(f.feedbackCommand.execute(actor.player,"fantasyfeedback",new String[]{"off"}));
for(int attempt=0;attempt<20&&!actor.messages.contains("Fantasy feedback is now off.");attempt++)f.tick();
assertTrue(actor.messages.contains("Fantasy feedback is now off."));
assertEquals(List.of("fishing"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure-site","place","f"}));
assertTrue(f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure-site","place","fishing",""}).contains("--name"));
f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","place","fishing","--name","\"Moonlit Pond\"","--item","\"Silver Scale\"","--required","1","--chance","100","--xp","10","--coins","4","--radius","16","--mode","individual-only"});await(f,()->actor.messages.stream().anyMatch(message->message.contains("Placed fishing site")&&message.contains("Moonlit Pond")));
var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");var fishing=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");var site=adventures.snapshot().sites().getFirst();assertEquals(AdventureQuestState.SiteType.FISHING,site.type());assertEquals("Moonlit Pond",site.name());assertEquals("Silver Scale",site.fishing().itemName());assertEquals(AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY,site.participationMode());
f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","fishing",site.id().toString()});assertTrue(actor.messages.getLast().contains("Silver Scale"));assertEquals(List.of(site.id().toString()),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure-site","fishing",""}));
var giver=mock(Villager.class);
var giverData=new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());
when(giver.getPersistentDataContainer()).thenReturn(giverData);
when(giver.getUniqueId()).thenReturn(UUID.randomUUID());when(giver.getWorld()).thenReturn(actor.world);
when(giver.getLocation()).thenReturn(actor.core.location.clone());
var chunk=mock(Chunk.class);when(chunk.getWorld()).thenReturn(actor.world);
when(chunk.getX()).thenReturn(actor.core.location.getBlockX()>>4);
when(chunk.getZ()).thenReturn(actor.core.location.getBlockZ()>>4);
when(chunk.getEntities()).thenReturn(new Entity[0]);when(actor.world.getChunkAt(anyInt(),anyInt())).thenReturn(chunk);
when(actor.world.getLoadedChunks()).thenReturn(new Chunk[]{chunk});
when(actor.world.isChunkLoaded(anyInt(),anyInt())).thenReturn(true);
doAnswer(call->{((java.util.function.Consumer<Villager>)call.getArgument(2)).accept(giver);return giver;})
.when(actor.world).spawn(any(Location.class),eq(Villager.class),any(java.util.function.Consumer.class));
f.command.execute(actor.player,"fantasystick",new String[]{"quest-giver","place","Harbor Guide"});
await(f,()->!adventures.snapshot().givers().isEmpty());
var registeredGiver=adventures.snapshot().givers().getFirst();giverId=registeredGiver.id();giverWorld=registeredGiver.world();
var npcController=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");
npcController.reconcile(chunk);
int before=adventures.snapshot().sites().size();f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","place","fishing","--name","Broken","--item","X","--required","0","--chance","101","--xp","1","--coins","1"});assertEquals(before,adventures.snapshot().sites().size());
var oldCatalog=adventures.snapshot().catalog();f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll","confirm"});await(f,()->adventures.snapshot().reset()==null&&adventures.snapshot().catalog()!=null&&(oldCatalog==null||!adventures.snapshot().catalog().revision().equals(oldCatalog.revision())));var catalog=adventures.snapshot().catalog();assertTrue(catalog.sites().contains(site.id()));
var controller=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");controller.open(actor.player);drain(f);var offerMenu=actor.inventories.getLast();var offer=offer(offerMenu,"Moonlit Pond");var lore=plain(offer);assertTrue(lore.stream().anyMatch(line->line.contains("Mode: individual-only")));assertTrue(lore.stream().anyMatch(line->line.contains("Silver Scale × 1")));assertTrue(lore.stream().anyMatch(line->line.contains("Catch chance: 100%")));assertTrue(lore.stream().anyMatch(line->line.contains("10 XP, 4 coins")));click(controller,actor,offerMenu,offer);drain(f);var quest=adventures.active().getFirst();assertNotNull(quest.fishing());UUID stableItem=quest.fishing().itemId();f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","fishing",site.id().toString(),"--item","\"Golden Scale\"","--required","2","--chance","0","--xp","20","--coins","8","--radius","4"});await(f,()->actor.messages.stream().anyMatch(message->message.contains("Updated fishing site")&&message.contains(site.id().toString())));var updatedSite=adventures.findSite(site.id()).orElseThrow();assertEquals(stableItem,updatedSite.fishing().itemId());assertEquals("Golden Scale",updatedSite.fishing().itemName());assertEquals("Silver Scale",quest.fishing().itemName(),"accepted terms remain immutable");assertEquals(16,quest.fishing().radius());actor.core.location.setX(10);
var controller=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");
f.dispatcher.accept(new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.HAND));drain(f);var offerMenu=actor.inventories.getLast();var offer=offer(offerMenu,"Moonlit Pond");var lore=plain(offer);assertTrue(lore.stream().anyMatch(line->line.contains("Mode: individual-only")));assertTrue(lore.stream().anyMatch(line->line.contains("Silver Scale × 1")));assertTrue(lore.stream().anyMatch(line->line.contains("Catch chance: 100%")));assertTrue(lore.stream().anyMatch(line->line.contains("10 XP, 4 coins")));click(controller,actor,offerMenu,offer);drain(f);var quest=adventures.active().getFirst();assertNotNull(quest.fishing());UUID stableItem=quest.fishing().itemId();f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","fishing",site.id().toString(),"--item","\"Golden Scale\"","--required","2","--chance","0","--xp","20","--coins","8","--radius","4"});await(f,()->actor.messages.stream().anyMatch(message->message.contains("Updated fishing site")&&message.contains(site.id().toString())));var updatedSite=adventures.findSite(site.id()).orElseThrow();assertEquals(stableItem,updatedSite.fishing().itemId());assertEquals("Golden Scale",updatedSite.fishing().itemName());assertEquals("Silver Scale",quest.fishing().itemName(),"accepted terms remain immutable");assertEquals(16,quest.fishing().radius());f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","mode",site.id().toString(),"party-required"});await(f,()->actor.messages.stream().anyMatch(message->message.contains("Updated adventure site "+site.id()+" mode=party-required")));actor.core.location.setX(10);
for(int retry=0;retry<20&&actor.core.inventory.getItem(0).getType()!=Material.FISHING_ROD;retry++){pulse(f);drain(f);}var rod=actor.core.inventory.getItem(0);assertEquals(Material.FISHING_ROD,rod.getType());assertEquals(quest.id(),AdventureQuestNavigation.fishingQuest(rod));assertTrue(rod.getEnchantments().isEmpty());
var use=new PlayerInteractEvent(actor.player,Action.RIGHT_CLICK_AIR,rod,null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND);f.dispatcher.accept(use);assertNotEquals(org.bukkit.event.Event.Result.DENY,use.useItemInHand(),"owned quest rod keeps native cast/reel mechanics");
var ordinaryHook=mock(FishHook.class);when(ordinaryHook.getUniqueId()).thenReturn(UUID.randomUUID());var ordinaryEvent=new PlayerFishEvent(actor.player,null,ordinaryHook,EquipmentSlot.HAND,PlayerFishEvent.State.REEL_IN);actor.core.inventory.setItem(0,new ItemStack(Material.FISHING_ROD));f.dispatcher.accept(ordinaryEvent);assertFalse(ordinaryEvent.isCancelled(),"ordinary fishing remains unchanged");actor.core.inventory.setItem(0,rod);
var ordinaryHook=mock(FishHook.class);when(ordinaryHook.getUniqueId()).thenReturn(UUID.randomUUID());var ordinaryEvent=new PlayerFishEvent(actor.player,null,ordinaryHook,EquipmentSlot.HAND,PlayerFishEvent.State.REEL_IN);actor.core.inventory.setItem(0,new ItemStack(Material.FISHING_ROD));f.dispatcher.accept(ordinaryEvent);assertFalse(ordinaryEvent.isCancelled(),"ordinary fishing remains unchanged");
var ordinaryLoot=mock(Item.class);var ordinaryCatch=new PlayerFishEvent(actor.player,ordinaryLoot,ordinaryHook,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH);ordinaryCatch.setExpToDrop(7);f.dispatcher.accept(ordinaryCatch);assertFalse(ordinaryCatch.isCancelled());assertEquals(7,ordinaryCatch.getExpToDrop());verify(ordinaryLoot,never()).remove();actor.core.inventory.setItem(0,rod);
var forgedHook=mock(FishHook.class);when(forgedHook.getUniqueId()).thenReturn(UUID.randomUUID());when(forgedHook.getLocation()).thenReturn(actor.core.location.clone());var forgedCatch=mock(Item.class);var forgedEvent=new PlayerFishEvent(actor.player,forgedCatch,forgedHook,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH);f.dispatcher.accept(forgedEvent);assertTrue(forgedEvent.isCancelled());assertEquals(BigInteger.ZERO,fishing.quantity(actor.core.record.id(),site.fishing().itemId()));assertTrue(actor.messages.getLast().contains("cast is no longer valid"));
var entityHook=mock(FishHook.class);when(entityHook.getUniqueId()).thenReturn(UUID.randomUUID());when(entityHook.getLocation()).thenReturn(actor.core.location.clone());f.dispatcher.accept(new PlayerFishEvent(actor.player,null,entityHook,EquipmentSlot.HAND,PlayerFishEvent.State.FISHING));var hookedEntity=mock(Zombie.class);var entityCatch=new PlayerFishEvent(actor.player,hookedEntity,entityHook,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_ENTITY);f.dispatcher.accept(entityCatch);assertTrue(entityCatch.isCancelled());verify(entityHook).remove();
var hook=mock(FishHook.class);when(hook.getUniqueId()).thenReturn(UUID.randomUUID());when(hook.getLocation()).thenReturn(actor.core.location.clone());var caught=mock(Item.class);var caughtStack=new ItemStack(Material.COD);when(caught.getItemStack()).thenReturn(caughtStack);when(caught.getLocation()).thenReturn(actor.core.location.clone());
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,hook,EquipmentSlot.HAND,PlayerFishEvent.State.FISHING));
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,hook,EquipmentSlot.HAND,PlayerFishEvent.State.LURED));
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,hook,EquipmentSlot.HAND,PlayerFishEvent.State.BITE));
var caughtEvent=new PlayerFishEvent(actor.player,caught,hook,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH);caughtEvent.setExpToDrop(7);f.dispatcher.accept(caughtEvent);assertTrue(caughtEvent.isCancelled());assertEquals(0,caughtEvent.getExpToDrop());verify(caught).remove();verify(hook).remove();await(f,()->fishing.quantity(actor.core.record.id(),site.fishing().itemId()).equals(BigInteger.ONE));var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");await(f,()->storage.find(actor.core.record.id()).join().orElseThrow().experience().equals(BigInteger.TWO));assertTrue(actor.messages.stream().anyMatch(message->message.contains("Caught Silver Scale")));
var caughtEvent=new PlayerFishEvent(actor.player,caught,hook,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH);caughtEvent.setExpToDrop(7);f.dispatcher.accept(caughtEvent);assertTrue(caughtEvent.isCancelled());assertEquals(0,caughtEvent.getExpToDrop());verify(caught).remove();verify(hook).remove();await(f,()->fishing.quantity(actor.core.record.id(),site.fishing().itemId()).equals(BigInteger.ONE));var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");await(f,()->storage.find(actor.core.record.id()).join().orElseThrow().experience().equals(BigInteger.TWO));await(f,()->actor.messages.stream().anyMatch(message->message.contains("Caught Silver Scale")));
var missedHook=mock(FishHook.class);when(missedHook.getUniqueId()).thenReturn(UUID.randomUUID());when(missedHook.getLocation()).thenReturn(actor.core.location.clone());
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,missedHook,EquipmentSlot.HAND,PlayerFishEvent.State.FISHING));
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,missedHook,EquipmentSlot.HAND,PlayerFishEvent.State.FAILED_ATTEMPT));
@@ -71,9 +97,100 @@ final class FishingQuestNativeTest {
assertTrue(emptyCatch.isCancelled());assertTrue(actor.messages.getLast().contains("no native fish item"));
assertEquals(BigInteger.ONE,fishing.quantity(actor.core.record.id(),site.fishing().itemId()),"refused catches award no item");
doReturn(true).when(actor.player).isSneaking();int books=actor.books.size();var sneak=new PlayerInteractEvent(actor.player,Action.RIGHT_CLICK_AIR,actor.core.inventory.getItem(0),null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND);f.dispatcher.accept(sneak);await(f,()->actor.books.size()>books);var pages=((org.bukkit.inventory.meta.BookMeta)actor.books.getLast().getItemMeta()).pages().stream().map(PlainTextComponentSerializer.plainText()::serialize).toList();assertTrue(String.join("\n",pages).contains("Inventory"),pages.toString());assertTrue(String.join("\n",pages).contains("Golden Scale: 1"),pages.toString());assertTrue(String.join("",pages).replace("\n","").contains(site.id().toString()),pages.toString());doReturn(false).when(actor.player).isSneaking();
controller.open(actor.player);drain(f);var offers=actor.inventories.getLast();assertTrue(plain(offer(offers,"Moonlit Pond")).stream().anyMatch(line->line.contains("Held: 1 / 1")),plain(offer(offers,"Moonlit Pond")).toString());var turnIn=java.util.stream.IntStream.range(0,offers.getSize()).mapToObj(offers::getItem).filter(Objects::nonNull).filter(item->item.getType()==Material.ENDER_CHEST).findFirst().orElseThrow();click(controller,actor,offers,turnIn);var turnMenu=actor.inventories.getLast();var submit=offer(turnMenu,"Submit Silver Scale");assertTrue(plain(submit).stream().anyMatch(line->line.contains("Beneficiaries: 1")));var fishingController=(FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");clickFishing(fishingController,actor,turnMenu,submit);clickFishing(fishingController,actor,turnMenu,submit);assertTrue(actor.messages.getLast().contains("menu, character or session changed"));await(f,()->adventures.active().isEmpty()&&fishing.quantity(actor.core.record.id(),site.fishing().itemId()).signum()==0);assertEquals(new FishingState.Reward(BigInteger.TEN,BigInteger.valueOf(4)),fishing.unclaimed(actor.core.record.id()));
f.dispatcher.accept(new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.HAND));drain(f);var offers=actor.inventories.getLast();assertTrue(plain(offer(offers,"Moonlit Pond")).stream().anyMatch(line->line.contains("Held: 1 / 1")),plain(offer(offers,"Moonlit Pond")).toString());var turnIn=java.util.stream.IntStream.range(0,offers.getSize()).mapToObj(offers::getItem).filter(Objects::nonNull).filter(item->item.getType()==Material.ENDER_CHEST).findFirst().orElseThrow();click(controller,actor,offers,turnIn);var turnMenu=actor.inventories.getLast();var submit=offer(turnMenu,"Submit Silver Scale");assertTrue(plain(submit).stream().anyMatch(line->line.contains("Beneficiaries: 1")));var fishingController=(FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");clickFishing(fishingController,actor,turnMenu,submit);clickFishing(fishingController,actor,turnMenu,submit);assertTrue(actor.messages.getLast().contains("menu, character or session changed"));await(f,()->adventures.active().isEmpty()&&fishing.quantity(actor.core.record.id(),site.fishing().itemId()).signum()==0);assertEquals(new FishingState.Reward(BigInteger.TEN,BigInteger.valueOf(4)),fishing.unclaimed(actor.core.record.id()));
var completedGeneration=catalog.revision();f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll","confirm"});await(f,()->adventures.snapshot().reset()==null&&!adventures.snapshot().catalog().revision().equals(completedGeneration));assertEquals(new FishingState.Reward(BigInteger.TEN,BigInteger.valueOf(4)),fishing.unclaimed(actor.core.record.id()),"full reroll preserves earned shares");
controller.open(actor.player);drain(f);var emptyOffers=actor.inventories.getLast();turnIn=java.util.stream.IntStream.range(0,emptyOffers.getSize()).mapToObj(emptyOffers::getItem).filter(Objects::nonNull).filter(item->item.getType()==Material.ENDER_CHEST).findFirst().orElseThrow();click(controller,actor,emptyOffers,turnIn);turnMenu=actor.inventories.getLast();var claim=offer(turnMenu,"Claim earned quest rewards");clickFishing((FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController"),actor,turnMenu,claim);await(f,()->fishing.unclaimed(actor.core.record.id()).equals(FishingState.Reward.empty()));var saved=storage.find(actor.core.record.id()).join().orElseThrow();assertEquals(BigInteger.valueOf(12),saved.experience());assertEquals(actor.core.record.coins().add(BigInteger.valueOf(4)),saved.coins());
assertTrue(adventures.removeSite(site.id()).get(5,TimeUnit.SECONDS).isPresent(),"source registration may be removed before the frozen personal claim");
int beforeStaleMenu=actor.inventories.size();click(controller,actor,offers,turnIn);
assertEquals(beforeStaleMenu,actor.inventories.size(),"expired Quest Giver menu cannot reopen a new claim authority");
f.dispatcher.accept(new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.HAND));drain(f);var emptyOffers=actor.inventories.getLast();turnIn=java.util.stream.IntStream.range(0,emptyOffers.getSize()).mapToObj(emptyOffers::getItem).filter(Objects::nonNull).filter(item->item.getType()==Material.ENDER_CHEST).findFirst().orElseThrow();click(controller,actor,emptyOffers,turnIn);turnMenu=actor.inventories.getLast();var claim=offer(turnMenu,"Claim earned quest rewards");clickFishing((FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController"),actor,turnMenu,claim);await(f,()->fishing.unclaimed(actor.core.record.id()).equals(FishingState.Reward.empty()));var saved=storage.find(actor.core.record.id()).join().orElseThrow();assertEquals(BigInteger.valueOf(12),saved.experience());assertEquals(actor.core.record.coins().add(BigInteger.valueOf(4)),saved.coins());
f.command.execute(actor.player,"fantasystick",new String[]{"config","fishing-catch-xp-min"});
f.command.execute(actor.player,"fantasystick",new String[]{"config","fishing-catch-xp-max"});
f.command.execute(actor.player,"fantasystick",new String[]{"config","fishing-catch-xp-max","3"});
await(f,()->actor.messages.stream().anyMatch(message->message.contains("Saved fishing-catch-xp-max=3")));
f.command.execute(actor.player,"fantasystick",new String[]{"config","fishing-catch-xp-min","4"});
assertTrue(actor.messages.getLast().contains("invalid"));
await(f,()->{try{return exportedClaims().size()==1&&exportedTypes().stream()
.anyMatch(type->type.endsWith("config.changed"));}
catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}});
f.plugin.setEnabled(false);
}
List<String> types;
try(var paths=java.nio.file.Files.walk(data.resolve("events"))){
types=paths.filter(path->path.toString().endsWith(".jsonl"))
.flatMap(path->{try{return java.nio.file.Files.lines(path);}catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}})
.map(line->com.google.gson.JsonParser.parseString(line).getAsJsonObject().get("type").getAsString()).toList();
}
for(var required:List.of("adventure.site.registered","adventure.site.updated","adventure.site.mode_changed","adventure.quest.accepted",
"fishing.catch_resolved","adventure.items_submitted","adventure.reward_claimed","config.read","config.changed"))
assertTrue(types.stream().anyMatch(type->type.endsWith(required)),required+" missing from exported JSONL: "+types);
try(var paths=java.nio.file.Files.walk(data.resolve("events"))){
var exported=paths.filter(path->path.toString().endsWith(".jsonl")).flatMap(path->{
try{return java.nio.file.Files.lines(path);}catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}
}).map(line->com.google.gson.JsonParser.parseString(line).getAsJsonObject()).toList();
var fishingActions=exported.stream().filter(event->event.get("type").getAsString().endsWith("action.resolved"))
.filter(event->event.getAsJsonObject("data").has("action_kind")
&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("fishing")).toList();
assertTrue(fishingActions.stream().anyMatch(event->event.getAsJsonObject("data")
.get("state").getAsString().equals("CAUGHT_FISH")),"native reel decision missing from JSONL");
assertTrue(fishingActions.stream().anyMatch(event->event.getAsJsonObject("data")
.get("outcome").getAsString().equals("prior_cancelled")),"cancelled native cast must be observable");
var catchEvents=exported.stream().filter(event->event.get("type").getAsString().endsWith("fishing.catch_resolved")).toList();
assertEquals(1,catchEvents.size(),"only the acknowledged native item catch resolves");
var catchData=catchEvents.getFirst().getAsJsonObject("data");
assertTrue(catchData.get("item_awarded").getAsBoolean());
assertEquals("suppressed",catchData.get("native_item_disposition").getAsString());
assertEquals("suppressed",catchData.get("native_xp_disposition").getAsString());
assertEquals("2",catchData.get("sampled_xp").getAsString());
assertEquals("2",catchData.get("allocated_xp").getAsString());
assertTrue(fishingActions.stream().anyMatch(event->event.getAsJsonObject("data")
.has("candidate_catch_id")&&event.getAsJsonObject("data").get("candidate_catch_id").getAsString()
.equals(catchData.get("catch_id").getAsString())),"native reel links to the acknowledged operation");
assertEquals("2",catchData.get("applied_xp").getAsString());
assertEquals("0",catchData.get("stock_before").getAsString());
assertEquals("1",catchData.get("stock_after").getAsString());
assertEquals("100",catchData.get("chance_percent").getAsString());
assertEquals("both_inside_same_world_circle",catchData.get("geometry_decision").getAsString());
assertEquals(catchData.getAsJsonObject("fisher_location").get("world_id").getAsString(),
catchData.getAsJsonObject("bobber_location").get("world_id").getAsString());
assertEquals(10d,catchData.getAsJsonObject("fisher_location").get("x").getAsDouble());
assertEquals(16,catchData.get("radius").getAsInt());
assertTrue(catchData.get("sample_percent").getAsDouble()<100d);
var submitted=exported.stream().filter(event->event.get("type").getAsString().endsWith("adventure.items_submitted")).toList();
assertEquals(1,submitted.size(),"forged/repeated menu cannot submit twice");
var submission=submitted.getFirst().getAsJsonObject("data");
assertEquals("1",submission.get("consumed").getAsString());
assertEquals("1",submission.get("stock_before").getAsString());
assertEquals("0",submission.get("stock_after").getAsString());
assertEquals(giverId.toString(),submission.get("giver_id").getAsString());
assertEquals(giverWorld.toString(),submission.getAsJsonObject("giver_location")
.get("world_id").getAsString());
assertEquals("10",submission.getAsJsonArray("beneficiaries").get(0).getAsJsonObject().get("allocated_xp").getAsString());
assertEquals("4",submission.getAsJsonArray("beneficiaries").get(0).getAsJsonObject().get("allocated_coins").getAsString());
var claimed=exported.stream().filter(event->event.get("type").getAsString().endsWith("adventure.reward_claimed")).toList();
assertEquals(1,claimed.size());
var claimData=claimed.getFirst().getAsJsonObject("data");
assertEquals("10",claimData.get("applied_xp").getAsString());
assertEquals("4",claimData.get("applied_coins").getAsString());
assertEquals(giverId.toString(),claimData.get("giver_id").getAsString());
assertEquals(giverWorld.toString(),claimData.getAsJsonObject("giver_location")
.get("world_id").getAsString());
assertEquals(submission.get("submission_id").getAsString(),claimData.getAsJsonArray("sources").get(0)
.getAsJsonObject().get("source_receipt_id").getAsString());
assertEquals(submission.get("catalog_id").getAsString(),claimData.getAsJsonArray("sources").get(0)
.getAsJsonObject().get("catalog_id").getAsString());
assertEquals(submission.get("site_id").getAsString(),claimData.getAsJsonArray("sources").get(0)
.getAsJsonObject().get("site_id").getAsString());
var configReads=exported.stream().filter(event->event.get("type").getAsString().endsWith("config.read"))
.filter(event->event.getAsJsonObject("data").get("key").getAsString().startsWith("fishing-catch-xp-")).toList();
assertEquals(List.of("fishing-catch-xp-min","fishing-catch-xp-max"),configReads.stream()
.map(event->event.getAsJsonObject("data").get("key").getAsString()).toList());
var configChanges=exported.stream().filter(event->event.get("type").getAsString().endsWith("config.changed"))
.filter(event->event.getAsJsonObject("data").get("key").getAsString().startsWith("fishing-catch-xp-")).toList();
assertEquals(1,configChanges.size(),"invalid/unchanged changes cannot claim publication");
assertEquals("2",configChanges.getFirst().getAsJsonObject("data").get("before").getAsString());
assertEquals("3",configChanges.getFirst().getAsJsonObject("data").get("effective").getAsString());
assertTrue(exported.stream().noneMatch(event->event.get("type").getAsString().endsWith("combat.reward_resolved")),
"fishing's shared award helper must not impersonate combat reward ownership");
}
}
@@ -88,24 +205,86 @@ final class FishingQuestNativeTest {
for(int retry=0;retry<20&&actor.core.inventory.getItem(0).getType()!=Material.FISHING_ROD;retry++){pulse(f);drain(f);}
assertEquals(Material.FISHING_ROD,actor.core.inventory.getItem(0).getType());
var first=mock(FishHook.class);when(first.getUniqueId()).thenReturn(UUID.randomUUID());when(first.getLocation()).thenReturn(actor.core.location.clone());var caught=mock(Item.class);
var nativeLoot=new ItemStack(Material.COD,3);var nativeMeta=nativeLoot.getItemMeta();nativeMeta.displayName(net.kyori.adventure.text.Component.text("Native catch with components"));nativeLoot.setItemMeta(nativeMeta);when(caught.getItemStack()).thenReturn(nativeLoot);
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,first,EquipmentSlot.HAND,PlayerFishEvent.State.FISHING));
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,first,EquipmentSlot.HAND,PlayerFishEvent.State.LURED));
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,first,EquipmentSlot.HAND,PlayerFishEvent.State.BITE));
var vetoed=new PlayerFishEvent(actor.player,caught,first,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH);vetoed.setCancelled(true);f.dispatcher.accept(vetoed);verify(first,never()).remove();
var firstCatch=new PlayerFishEvent(actor.player,caught,first,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH);firstCatch.setExpToDrop(6);f.dispatcher.accept(firstCatch);
assertTrue(firstCatch.isCancelled());assertEquals(0,firstCatch.getExpToDrop());verify(caught).remove();verify(first).remove();
assertFalse(firstCatch.isCancelled(),"A valid 0%-chance quest catch retains its actual native loot");assertEquals(0,firstCatch.getExpToDrop());verify(caught,never()).remove();assertSame(nativeLoot,caught.getItemStack());assertEquals(3,caught.getItemStack().getAmount());assertEquals(nativeMeta.displayName(),caught.getItemStack().getItemMeta().displayName());verify(first).remove();
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");await(f,()->storage.find(actor.core.record.id()).join().orElseThrow().experience().equals(BigInteger.TWO));
assertEquals(BigInteger.ZERO,fishing.quantity(actor.core.record.id(),site.fishing().itemId()));await(f,()->actor.messages.stream().anyMatch(message->message.contains("no quest item")));assertTrue(actor.messages.stream().anyMatch(message->message.contains("no quest item")));
var next=mock(FishHook.class);when(next.getUniqueId()).thenReturn(UUID.randomUUID());when(next.getLocation()).thenReturn(actor.core.location.clone());
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,next,EquipmentSlot.HAND,PlayerFishEvent.State.FISHING));
f.dispatcher.accept(new PlayerFishEvent(actor.player,caught,first,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH));
verify(next,never()).remove();
var stale=new PlayerFishEvent(actor.player,caught,first,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH);f.dispatcher.accept(stale);
assertTrue(stale.isCancelled());verify(caught,never()).remove();verify(next,never()).remove();
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,next,EquipmentSlot.HAND,PlayerFishEvent.State.LURED));
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,next,EquipmentSlot.HAND,PlayerFishEvent.State.BITE));
f.dispatcher.accept(new PlayerFishEvent(actor.player,caught,next,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH));
verify(next).remove();await(f,()->storage.find(actor.core.record.id()).join().orElseThrow().experience().equals(BigInteger.valueOf(4)));
verify(next).remove();verify(caught,never()).remove();await(f,()->storage.find(actor.core.record.id()).join().orElseThrow().experience().equals(BigInteger.valueOf(4)));
assertEquals(BigInteger.ZERO,fishing.quantity(actor.core.record.id(),site.fishing().itemId()));
await(f,()->fishing.snapshot().catchReceipts().size()==2);
await(f,()->{try{return exportedCatches().size()==2;}
catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}});
f.plugin.setEnabled(false);
}
try(var paths=java.nio.file.Files.walk(data.resolve("events"))){
var resolved=paths.filter(path->path.toString().endsWith(".jsonl"))
.flatMap(path->{try{return java.nio.file.Files.lines(path);}catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}})
.map(line->com.google.gson.JsonParser.parseString(line).getAsJsonObject())
.filter(event->event.get("type").getAsString().endsWith("fishing.catch_resolved")).toList();
assertEquals(2,resolved.size(),"cancelled, stale or forged callbacks are not acknowledged catches");
assertTrue(resolved.stream().allMatch(event->{var result=event.getAsJsonObject("data");
return !result.get("item_awarded").getAsBoolean()
&&result.get("native_item_disposition").getAsString().equals("retained")
&&result.get("native_xp_disposition").getAsString().equals("suppressed")
&&result.get("chance_percent").getAsString().equals("0")
&&result.get("stock_after").getAsString().equals("0");
}));
}
}
@Test void registeredMidrangeRollRetainsOnlyLosingNativeLootAndNeverRerolls() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 1\nfishing-catch-xp-min: 2\nfishing-catch-xp-max: 2\n");
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);var world=UUID.randomUUID();when(actor.world.getUID()).thenReturn(world);f.activate(actor);doReturn(null).when(actor.player).getFishHook();
var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");var fishing=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");
var site=adventures.placeFishingSite("Twin Shoal","Quest Pearl",world,0,64,0,16,java.math.BigDecimal.valueOf(50),BigInteger.ONE,BigInteger.TEN,BigInteger.ZERO,AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY).get(5,TimeUnit.SECONDS);
var before=adventures.snapshot().catalog();var roll=adventures.rerollToday(before==null?null:before.revision()).get(5,TimeUnit.SECONDS);if(adventures.snapshot().reset()!=null)adventures.completeReset(adventures.snapshot().reset().operation()).get(5,TimeUnit.SECONDS);
adventures.accept(roll.catalog().orElseThrow().revision(),site.id(),AdventureQuestState.UnitType.SOLO,actor.core.record.id(),Set.of(actor.core.record.id())).get(5,TimeUnit.SECONDS);
for(int retry=0;retry<20&&actor.core.inventory.getItem(0).getType()!=Material.FISHING_ROD;retry++){pulse(f);drain(f);}
assertEquals(Material.FISHING_ROD,actor.core.inventory.getItem(0).getType());
var controller=(FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");
var random=mock(java.util.random.RandomGenerator.class);when(random.nextDouble(100d)).thenReturn(75d,25d);
var randomField=FishingController.class.getDeclaredField("random");randomField.setAccessible(true);randomField.set(controller,random);
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");
for(int attempt=0;attempt<2;attempt++){
var hook=mock(FishHook.class);when(hook.getUniqueId()).thenReturn(UUID.randomUUID());when(hook.getLocation()).thenReturn(actor.core.location.clone());
var caught=mock(Item.class);var stack=new ItemStack(attempt==0?Material.NAME_TAG:Material.COD,2);when(caught.getItemStack()).thenReturn(stack);
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,hook,EquipmentSlot.HAND,PlayerFishEvent.State.FISHING));
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,hook,EquipmentSlot.HAND,PlayerFishEvent.State.BITE));
var event=new PlayerFishEvent(actor.player,caught,hook,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH);event.setExpToDrop(11);f.dispatcher.accept(event);
assertEquals(0,event.getExpToDrop());verify(hook).remove();
if(attempt==0){assertFalse(event.isCancelled());verify(caught,never()).remove();assertSame(stack,caught.getItemStack());}
else{assertTrue(event.isCancelled());verify(caught).remove();}
var expectedXp=BigInteger.valueOf((attempt+1)*2L);await(f,()->storage.find(actor.core.record.id()).join().orElseThrow().experience().equals(expectedXp));
var expectedStock=attempt==0?BigInteger.ZERO:BigInteger.ONE;await(f,()->fishing.quantity(actor.core.record.id(),site.fishing().itemId()).equals(expectedStock));
}
verify(random,times(2)).nextDouble(100d);assertEquals(BigInteger.ONE,fishing.quantity(actor.core.record.id(),site.fishing().itemId()));
await(f,()->{try{return exportedCatches().size()==2;}
catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}});
f.plugin.setEnabled(false);
}
var catches=exportedCatches();assertEquals(2,catches.size());
var outcomes=catches.stream().map(event->event.getAsJsonObject("data")).toList();
assertEquals(Set.of("75.0","25.0"),outcomes.stream()
.map(value->value.get("sample_percent").getAsString()).collect(java.util.stream.Collectors.toSet()));
assertTrue(outcomes.stream().anyMatch(value->!value.get("item_awarded").getAsBoolean()
&&value.get("native_item_disposition").getAsString().equals("retained")
&&value.get("native_item_type").getAsString().equals("NAME_TAG")));
assertTrue(outcomes.stream().anyMatch(value->value.get("item_awarded").getAsBoolean()
&&value.get("native_item_disposition").getAsString().equals("suppressed")
&&value.get("native_item_type").getAsString().equals("COD")));
}
@Test void nonleaderMemberSuppliesWholeRequirementAndOfflinePartySharePersists() throws Exception {
@@ -114,6 +293,449 @@ final class FishingQuestNativeTest {
f.plugin.setEnabled(true);var leader=new FantasyStickLifecycleNativeTest.Actor(data);var member=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(leader.core.id,leader.player);f.players.put(member.core.id,member.player);UUID world=UUID.randomUUID();when(leader.world.getUID()).thenReturn(world);member.core.location.setWorld(leader.world);f.activate(leader);f.activate(member);doReturn(null).when(leader.player).getFishHook();doReturn(null).when(member.player).getFishHook();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()));
var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");var fishing=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");var site=adventures.placeFishingSite("Party Pond","Party Pearl",world,0,64,0,16,java.math.BigDecimal.valueOf(100),BigInteger.ONE,BigInteger.valueOf(100),BigInteger.valueOf(20),AdventureQuestState.ParticipationMode.PARTY_REQUIRED).get(5,TimeUnit.SECONDS);var old=adventures.snapshot().catalog();var result=adventures.rerollToday(old==null?null:old.revision()).get(5,TimeUnit.SECONDS);var reset=adventures.snapshot().reset();if(reset!=null)adventures.completeReset(reset.operation()).get(5,TimeUnit.SECONDS);var catalog=result.catalog().orElseThrow();var accepted=adventures.accept(catalog.revision(),site.id(),AdventureQuestState.UnitType.PARTY,party.id(),Set.of(leader.core.record.id(),member.core.record.id())).get(5,TimeUnit.SECONDS).quest().orElseThrow();var catchPlan=new FishingState.Catch(UUID.randomUUID(),member.core.record.id(),accepted.id(),site.id(),site.fishing().itemId(),site.fishing().itemName(),BigInteger.ZERO,true);fishing.prepareCatch(catchPlan).get();fishing.completeCatch(catchPlan.id()).get();doReturn(false).when(leader.player).isOnline();
var controller=(FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");controller.open(member.player);var menu=member.inventories.getLast();var submit=offer(menu,"Submit Party Pearl");clickFishing(controller,member,menu,submit);await(f,()->adventures.findQuest(accepted.id()).isEmpty());var leaderReward=fishing.unclaimed(leader.core.record.id());var memberReward=fishing.unclaimed(member.core.record.id());assertEquals(BigInteger.valueOf(100),leaderReward.xp().add(memberReward.xp()));assertEquals(BigInteger.valueOf(20),leaderReward.coins().add(memberReward.coins()));assertTrue(leaderReward.xp().signum()>0&&memberReward.xp().signum()>0,"offline accepted leader retains a frozen share");assertEquals(BigInteger.ZERO,fishing.quantity(member.core.record.id(),site.fishing().itemId()));
await(f,()->{try{return exportedTypes().stream().anyMatch(type->type.endsWith("adventure.items_submitted"));}
catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}});
f.plugin.setEnabled(false);
}
try(var paths=java.nio.file.Files.walk(data.resolve("events"))){
var submissions=paths.filter(path->path.toString().endsWith(".jsonl"))
.flatMap(path->{try{return java.nio.file.Files.lines(path);}catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}})
.map(line->com.google.gson.JsonParser.parseString(line).getAsJsonObject())
.filter(event->event.get("type").getAsString().endsWith("adventure.items_submitted")).toList();
assertEquals(1,submissions.size());
var submission=submissions.getFirst().getAsJsonObject("data");
assertEquals("party",submission.get("unit_kind").getAsString());
assertEquals(2,submission.getAsJsonArray("roster").size());
assertEquals(2,submission.getAsJsonArray("beneficiaries").size());
int totalXp=0,totalCoins=0;boolean offline=false;
for(var value:submission.getAsJsonArray("beneficiaries")){
var share=value.getAsJsonObject();
totalXp+=share.get("allocated_xp").getAsInt();totalCoins+=share.get("allocated_coins").getAsInt();
offline|=!share.get("online_at_submission").getAsBoolean();
}
assertEquals(100,totalXp);assertEquals(20,totalCoins);assertTrue(offline);
}
}
@Test void transferredCharacterClaimsItsFrozenFishingShareFromTheNewHolderSession() throws Exception {
var identities=new UUID[2];
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id,a.player);f.activate(a);
var character=a.core.record.id();identities[0]=character;
var fishing=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");
var quest=UUID.randomUUID();var site=UUID.randomUUID();var catalog=UUID.randomUUID();var item=UUID.randomUUID();
var catchReceipt=new FishingState.Catch(UUID.randomUUID(),character,quest,site,item,"Transferred Scale",BigInteger.ZERO,true);
fishing.prepareCatch(catchReceipt).get(5,TimeUnit.SECONDS);
fishing.completeCatch(catchReceipt.id()).get(5,TimeUnit.SECONDS);
var submission=new FishingState.Submission(UUID.randomUUID(),quest,catalog,site,character,item,
BigInteger.ONE,Map.of(character,new FishingState.Reward(BigInteger.valueOf(7),BigInteger.valueOf(5))),
FishingState.SubmissionPhase.PREPARED);
fishing.prepareSubmission(submission).get(5,TimeUnit.SECONDS);
fishing.commitSubmission(submission.id()).get(5,TimeUnit.SECONDS);
fishing.finishSubmission(submission.id()).get(5,TimeUnit.SECONDS);
var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.RESTORED,sessions.exit(a.player));
f.awaitCharacterAndReceipt(a);
var b=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(b.core.id,b.player);
identities[1]=b.core.id;
b.core.inventory.setItemInOffHand(a.core.inventory.getItemInOffHand().clone());
f.activate(b);var holder=sessions.active(b.core.id).orElseThrow();assertEquals(character,holder.character());
var controller=(FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");
controller.open(b.player);var menu=b.inventories.getLast();
clickFishing(controller,b,menu,offer(menu,"Claim earned quest rewards"));
await(f,()->fishing.unclaimed(character).equals(FishingState.Reward.empty()));
var saved=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");
await(f,()->saved.find(character).join().orElseThrow().experience().equals(BigInteger.valueOf(7)));
assertEquals(a.core.record.coins().add(BigInteger.valueOf(5)),
saved.find(character).get(5,TimeUnit.SECONDS).orElseThrow().coins());
await(f,()->{try{return exportedClaims().size()==1;}
catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}});
f.plugin.setEnabled(false);
}
{
var claims=exportedClaims();
assertEquals(1,claims.size());var value=claims.getFirst();
assertEquals("7",value.getAsJsonObject("data").get("applied_xp").getAsString());
assertEquals("5",value.getAsJsonObject("data").get("applied_coins").getAsString());
assertEquals(identities[0].toString(),value.getAsJsonObject("data").get("character_id").getAsString());
assertEquals(identities[1].toString(),value.getAsJsonObject("data").get("player_id").getAsString());
assertNotEquals(identities[0],identities[1]);
}
}
@Test void transferredCampTrophyShareIsClaimedOnlyByItsCharacterAtNewHolder() throws Exception {
var identities=new UUID[2];var receiptId=UUID.randomUUID();
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var original=new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(original.core.id,original.player);f.activate(original);
var character=original.core.record.id();identities[0]=character;
var fishing=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");
var trophy=new FishingState.Trophy(receiptId,FishingState.TrophyType.CAMP,
character,UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),
"Spoils of Transfer Camp","Transfer Camp",
Map.of(character,new FishingState.Reward(BigInteger.valueOf(7),BigInteger.valueOf(5))));
assertEquals(FishingDirectory.TrophyPrepareStatus.APPLIED,
fishing.prepareTrophy(trophy).get(5,TimeUnit.SECONDS));
assertEquals(FishingDirectory.TrophyCommitStatus.APPLIED,
fishing.commitTrophy(receiptId).get(5,TimeUnit.SECONDS));
var controller=(FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");
controller.open(original.player);var stale=original.inventories.getLast();
controller.open(original.player);var menu=original.inventories.getLast();
clickFishing(controller,original,stale,offer(stale,"Redeem Spoils of Transfer Camp"));
assertEquals(1,fishing.trophies(character).size(),"stale menu cannot consume camp receipt");
var file=data.resolve("fishing.yml");var savedFishing=java.nio.file.Files.readAllBytes(file);
java.nio.file.Files.delete(file);java.nio.file.Files.createDirectory(file);
clickFishing(controller,original,menu,offer(menu,"Redeem Spoils of Transfer Camp"));
await(f,()->original.messages.stream().anyMatch(message->message.contains("Trophy redemption failed")));
assertEquals(1,fishing.trophies(character).size(),"failed write preserves the exact camp receipt");
assertEquals(FishingState.Reward.empty(),fishing.unclaimed(character));
java.nio.file.Files.delete(file);java.nio.file.Files.write(file,savedFishing);
controller.open(original.player);var retry=original.inventories.getLast();
clickFishing(controller,original,retry,offer(retry,"Redeem Spoils of Transfer Camp"));
await(f,()->fishing.trophies(character).isEmpty()
&&fishing.unclaimed(character).xp().equals(BigInteger.valueOf(7)));
var sessions=(games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.RESTORED,
sessions.exit(original.player));f.awaitCharacterAndReceipt(original);
var newHolder=new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(newHolder.core.id,newHolder.player);identities[1]=newHolder.core.id;
newHolder.core.inventory.setItemInOffHand(original.core.inventory.getItemInOffHand().clone());
f.activate(newHolder);
assertEquals(character,sessions.active(newHolder.core.id).orElseThrow().character());
controller.open(newHolder.player);var claims=newHolder.inventories.getLast();
clickFishing(controller,newHolder,claims,offer(claims,"Claim earned quest rewards"));
await(f,()->fishing.unclaimed(character).equals(FishingState.Reward.empty()));
var saved=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");
await(f,()->saved.find(character).join().orElseThrow().experience()
.equals(BigInteger.valueOf(7)));
assertEquals(original.core.record.coins().add(BigInteger.valueOf(5)),
saved.find(character).get(5,TimeUnit.SECONDS).orElseThrow().coins());
await(f,()->{try{return exportedClaims().size()==1;}
catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}});
f.plugin.setEnabled(false);
}
var claims=exportedClaims();assertEquals(1,claims.size());
var claim=claims.getFirst().getAsJsonObject("data");
assertEquals(identities[0].toString(),claim.get("character_id").getAsString());
assertEquals(identities[1].toString(),claim.get("player_id").getAsString());
assertEquals("camp_trophy",claim.getAsJsonArray("sources").get(0)
.getAsJsonObject().get("source_kind").getAsString());
assertEquals(receiptId.toString(),claim.getAsJsonArray("sources").get(0)
.getAsJsonObject().get("source_receipt_id").getAsString());
}
@Test void exactHorizontalRadiusCreditsOnlyTheValidNativeBobberAndExportsItsGeometry() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 1\nfishing-catch-xp-min: 2\nfishing-catch-xp-max: 2\n");
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);var world=UUID.randomUUID();when(actor.world.getUID()).thenReturn(world);
f.activate(actor);doReturn(null).when(actor.player).getFishHook();
var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
var fishing=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");
var site=adventures.placeFishingSite("Border Pond","Border Scale",world,0,70,0,16,
java.math.BigDecimal.valueOf(100),BigInteger.ONE,BigInteger.TEN,BigInteger.ZERO,
AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY).get(5,TimeUnit.SECONDS);
var prior=adventures.snapshot().catalog();var catalog=adventures.rerollToday(prior==null?null:prior.revision()).get(5,TimeUnit.SECONDS);
if(adventures.snapshot().reset()!=null)adventures.completeReset(adventures.snapshot().reset().operation()).get(5,TimeUnit.SECONDS);
adventures.accept(catalog.catalog().orElseThrow().revision(),site.id(),AdventureQuestState.UnitType.SOLO,
actor.core.record.id(),Set.of(actor.core.record.id())).get(5,TimeUnit.SECONDS);
actor.core.location.setX(16);actor.core.location.setZ(0);
for(int retry=0;retry<20&&actor.core.inventory.getItem(0).getType()!=Material.FISHING_ROD;retry++){pulse(f);drain(f);}
assertEquals(Material.FISHING_ROD,actor.core.inventory.getItem(0).getType());
var hook=mock(FishHook.class);when(hook.getUniqueId()).thenReturn(UUID.randomUUID());
when(hook.getLocation()).thenReturn(actor.core.location.clone());var caught=mock(Item.class);
when(caught.getItemStack()).thenReturn(new ItemStack(Material.COD));
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,hook,EquipmentSlot.HAND,PlayerFishEvent.State.FISHING));
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,hook,EquipmentSlot.HAND,PlayerFishEvent.State.BITE));
var exact=new PlayerFishEvent(actor.player,caught,hook,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH);
f.dispatcher.accept(exact);assertTrue(exact.isCancelled());
await(f,()->fishing.quantity(actor.core.record.id(),site.fishing().itemId()).equals(BigInteger.ONE));
var outside=mock(FishHook.class);when(outside.getUniqueId()).thenReturn(UUID.randomUUID());
var outPosition=actor.core.location.clone();outPosition.setX(16.001);
when(outside.getLocation()).thenReturn(outPosition);
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,outside,EquipmentSlot.HAND,PlayerFishEvent.State.FISHING));
var refused=new PlayerFishEvent(actor.player,caught,outside,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH);
f.dispatcher.accept(refused);assertTrue(refused.isCancelled());
assertEquals(BigInteger.ONE,fishing.quantity(actor.core.record.id(),site.fishing().itemId()));
await(f,()->{try{return exportedCatches().size()==1;}
catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}});
f.plugin.setEnabled(false);
}
var catches=exportedCatches();assertEquals(1,catches.size());
var captured=catches.getFirst().getAsJsonObject("data");
assertEquals("both_inside_same_world_circle",captured.get("geometry_decision").getAsString());
assertEquals(16d,captured.getAsJsonObject("fisher_location").get("x").getAsDouble());
assertEquals(16d,captured.getAsJsonObject("bobber_location").get("x").getAsDouble());
assertEquals("16",captured.get("radius").getAsString());
}
@Test void mixedFishingAndTrophySharesKeepTheirOwnSourcesThroughOnePersonalClaim() 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);f.activate(actor);
var fishing=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");
var character=actor.core.record.id();var fishSite=UUID.randomUUID();var fishCatalog=UUID.randomUUID();
var fishQuest=UUID.randomUUID();var fishItem=UUID.randomUUID();var submissionId=UUID.randomUUID();
var caught=new FishingState.Catch(UUID.randomUUID(),character,fishQuest,fishSite,fishItem,"Scale",BigInteger.ZERO,true);
fishing.prepareCatch(caught).get(5,TimeUnit.SECONDS);fishing.completeCatch(caught.id()).get(5,TimeUnit.SECONDS);
var submission=new FishingState.Submission(submissionId,fishQuest,fishCatalog,fishSite,character,fishItem,
BigInteger.ONE,Map.of(character,new FishingState.Reward(BigInteger.ONE,BigInteger.ONE)),
FishingState.SubmissionPhase.PREPARED);
fishing.prepareSubmission(submission).get(5,TimeUnit.SECONDS);
fishing.commitSubmission(submissionId).get(5,TimeUnit.SECONDS);
fishing.finishSubmission(submissionId).get(5,TimeUnit.SECONDS);
var trophyId=UUID.randomUUID();var trophySite=UUID.randomUUID();var trophyCatalog=UUID.randomUUID();
var trophy=new FishingState.Trophy(trophyId,FishingState.TrophyType.CHAMPION,character,trophyCatalog,
trophySite,UUID.randomUUID(),UUID.randomUUID(),"Champion Head","Champion Grounds",
Map.of(character,new FishingState.Reward(BigInteger.TWO,BigInteger.valueOf(3))));
fishing.prepareTrophy(trophy).get(5,TimeUnit.SECONDS);
fishing.commitTrophy(trophyId).get(5,TimeUnit.SECONDS);
fishing.redeemTrophy(trophyId,character).get(5,TimeUnit.SECONDS);
var controller=(FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");
controller.open(actor.player);var menu=actor.inventories.getLast();
clickFishing(controller,actor,menu,offer(menu,"Claim earned quest rewards"));
await(f,()->fishing.unclaimed(character).equals(FishingState.Reward.empty()));
var saved=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");
await(f,()->saved.find(character).join().orElseThrow().experience().equals(BigInteger.valueOf(3)));
await(f,()->{try{return exportedClaims().size()==1;}
catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}});
f.plugin.setEnabled(false);
}
try(var paths=java.nio.file.Files.walk(data.resolve("events"))){
var claims=paths.filter(path->path.toString().endsWith(".jsonl"))
.flatMap(path->{try{return java.nio.file.Files.lines(path);}catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}})
.map(line->com.google.gson.JsonParser.parseString(line).getAsJsonObject())
.filter(value->value.get("type").getAsString().endsWith("adventure.reward_claimed")).toList();
assertEquals(1,claims.size());var claim=claims.getFirst().getAsJsonObject("data");
assertEquals("3",claim.get("requested_xp").getAsString());
assertEquals("4",claim.get("requested_coins").getAsString());
assertEquals(2,claim.getAsJsonArray("sources").size());
assertEquals(Set.of("fishing","champion_trophy"),java.util.stream.StreamSupport
.stream(claim.getAsJsonArray("sources").spliterator(),false)
.map(value->value.getAsJsonObject().get("source_kind").getAsString())
.collect(java.util.stream.Collectors.toSet()));
}
}
@Test void rejectedWriterReplaysAcknowledgedCatchWithoutRefishingOrAwardingXpAgain() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 1\nfishing-catch-xp-min: 2\nfishing-catch-xp-max: 2\n");
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);var world=UUID.randomUUID();when(actor.world.getUID()).thenReturn(world);
f.activate(actor);doReturn(null).when(actor.player).getFishHook();
var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
var fishing=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");
var site=adventures.placeFishingSite("Restart Pond","Restart Scale",world,0,64,0,16,
java.math.BigDecimal.valueOf(100),BigInteger.ONE,BigInteger.TEN,BigInteger.ZERO,
AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY).get(5,TimeUnit.SECONDS);
var before=adventures.snapshot().catalog();var offered=adventures.rerollToday(before==null?null:before.revision()).get(5,TimeUnit.SECONDS);
if(adventures.snapshot().reset()!=null)adventures.completeReset(adventures.snapshot().reset().operation()).get(5,TimeUnit.SECONDS);
adventures.accept(offered.catalog().orElseThrow().revision(),site.id(),AdventureQuestState.UnitType.SOLO,
actor.core.record.id(),Set.of(actor.core.record.id())).get(5,TimeUnit.SECONDS);
for(int retry=0;retry<20&&actor.core.inventory.getItem(0).getType()!=Material.FISHING_ROD;retry++){pulse(f);drain(f);}
((games.dmg.fantasystick.events.LocalEventLog)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"eventLog")).close();
var hook=mock(FishHook.class);when(hook.getUniqueId()).thenReturn(UUID.randomUUID());
when(hook.getLocation()).thenReturn(actor.core.location.clone());var caught=mock(Item.class);
when(caught.getItemStack()).thenReturn(new ItemStack(Material.COD));
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,hook,EquipmentSlot.HAND,PlayerFishEvent.State.FISHING));
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,hook,EquipmentSlot.HAND,PlayerFishEvent.State.BITE));
f.dispatcher.accept(new PlayerFishEvent(actor.player,caught,hook,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH));
await(f,()->fishing.quantity(actor.core.record.id(),site.fishing().itemId()).equals(BigInteger.ONE));
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");
assertEquals(BigInteger.TWO,storage.find(actor.core.record.id()).get(5,TimeUnit.SECONDS).orElseThrow().experience());
f.plugin.setEnabled(false);
assertEquals(0,exportedCatches().size(),"pending intents are not exported JSONL");
f.plugin.setEnabled(true);
for(int attempt=0;attempt<200&&exportedCatches().isEmpty();attempt++)Thread.sleep(10);
assertEquals(BigInteger.ONE,((FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing"))
.quantity(actor.core.record.id(),site.fishing().itemId()));
assertEquals(BigInteger.TWO,((CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters"))
.find(actor.core.record.id()).get(5,TimeUnit.SECONDS).orElseThrow().experience());
f.plugin.setEnabled(false);
}
assertEquals(1,exportedCatches().size(),"writer replay cannot duplicate a native catch");
}
private List<String> exportedTypes() throws java.io.IOException {
try(var paths=java.nio.file.Files.walk(data.resolve("events"))){
return paths.filter(path->path.toString().endsWith(".jsonl"))
.flatMap(path->{try{return java.nio.file.Files.lines(path);}catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}})
.map(line->com.google.gson.JsonParser.parseString(line).getAsJsonObject().get("type").getAsString()).toList();
}
}
private List<com.google.gson.JsonObject> exportedClaims() throws java.io.IOException {
try(var paths=java.nio.file.Files.walk(data.resolve("events"))){
return paths.filter(path->path.toString().endsWith(".jsonl"))
.flatMap(path->{try{return java.nio.file.Files.lines(path);}catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}})
.map(line->com.google.gson.JsonParser.parseString(line).getAsJsonObject())
.filter(value->value.get("type").getAsString().endsWith("adventure.reward_claimed")).toList();
}
}
private List<com.google.gson.JsonObject> exportedCatches() throws java.io.IOException {
try(var paths=java.nio.file.Files.walk(data.resolve("events"))){
return paths.filter(path->path.toString().endsWith(".jsonl"))
.flatMap(path->{try{return java.nio.file.Files.lines(path);}catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}})
.map(line->com.google.gson.JsonParser.parseString(line).getAsJsonObject())
.filter(value->value.get("type").getAsString().endsWith("fishing.catch_resolved")).toList();
}
}
@Test void failedNativeCatchPreparationProducesNoItemXpOrResolvedSuccess() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 1\nfishing-catch-xp-min: 2\nfishing-catch-xp-max: 2\n");
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);var world=UUID.randomUUID();when(actor.world.getUID()).thenReturn(world);
f.activate(actor);doReturn(null).when(actor.player).getFishHook();
var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
var fishing=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");
var site=adventures.placeFishingSite("Unavailable Pond","Quest Scale",world,0,64,0,16,
java.math.BigDecimal.valueOf(100),BigInteger.ONE,BigInteger.TEN,BigInteger.ZERO,
AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY).get(5,TimeUnit.SECONDS);
var before=adventures.snapshot().catalog();var offered=adventures.rerollToday(before==null?null:before.revision()).get(5,TimeUnit.SECONDS);
if(adventures.snapshot().reset()!=null)adventures.completeReset(adventures.snapshot().reset().operation()).get(5,TimeUnit.SECONDS);
adventures.accept(offered.catalog().orElseThrow().revision(),site.id(),AdventureQuestState.UnitType.SOLO,
actor.core.record.id(),Set.of(actor.core.record.id())).get(5,TimeUnit.SECONDS);
for(int retry=0;retry<20&&actor.core.inventory.getItem(0).getType()!=Material.FISHING_ROD;retry++){pulse(f);drain(f);}
java.nio.file.Files.createDirectory(data.resolve("fishing.yml"));
var hook=mock(FishHook.class);when(hook.getUniqueId()).thenReturn(UUID.randomUUID());
when(hook.getLocation()).thenReturn(actor.core.location.clone());var caught=mock(Item.class);
when(caught.getItemStack()).thenReturn(new ItemStack(Material.COD));
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,hook,EquipmentSlot.HAND,PlayerFishEvent.State.FISHING));
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,hook,EquipmentSlot.HAND,PlayerFishEvent.State.BITE));
var event=new PlayerFishEvent(actor.player,caught,hook,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH);
f.dispatcher.accept(event);assertTrue(event.isCancelled());verify(caught).remove();
var tasks=f.tasks.poll(5,TimeUnit.SECONDS);if(tasks!=null)tasks.run();
assertEquals(BigInteger.ZERO,fishing.quantity(actor.core.record.id(),site.fishing().itemId()));
var characters=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");
assertEquals(BigInteger.ZERO,characters.find(actor.core.record.id()).get(5,TimeUnit.SECONDS)
.orElseThrow().experience());
java.nio.file.Files.delete(data.resolve("fishing.yml"));
f.plugin.setEnabled(false);
}
try(var paths=java.nio.file.Files.walk(data.resolve("events"))){
var catches=paths.filter(path->path.toString().endsWith(".jsonl"))
.flatMap(path->{try{return java.nio.file.Files.lines(path);}catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}})
.map(line->com.google.gson.JsonParser.parseString(line).getAsJsonObject())
.filter(value->value.get("type").getAsString().endsWith("fishing.catch_resolved")).toList();
assertTrue(catches.isEmpty(),"unacknowledged native catch cannot claim saved stock or Fantasy XP");
}
}
@Test void cappedNativeCatchRecordsSampledFantasyXpLossWithoutInventingAnAward() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 1\nfishing-catch-xp-min: 2\nfishing-catch-xp-max: 2\n");
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);
var repository=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old=repository.load(actor.core.record.id()).orElseThrow();
repository.replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(),old.name(),
old.revision().add(BigInteger.ONE),old.stats(),30,BigInteger.valueOf(2900),old.unspentPoints(),
old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),
old.dead(),old.deathId(),old.diedAt(),old.coins(),old.coinsInitialized(),old.rewardReceipts(),
old.pvpCoinEscrow(),old.gear(),old.sheetGeneration()),old.revision());
f.players.put(actor.core.id,actor.player);var world=UUID.randomUUID();when(actor.world.getUID()).thenReturn(world);
f.activate(actor);doReturn(null).when(actor.player).getFishHook();
var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
var fishing=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");
var site=adventures.placeFishingSite("Capped Pond","Pearl",world,0,64,0,16,
java.math.BigDecimal.valueOf(100),BigInteger.ONE,BigInteger.TEN,BigInteger.ZERO,
AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY).get(5,TimeUnit.SECONDS);
var before=adventures.snapshot().catalog();var offered=adventures.rerollToday(before==null?null:before.revision()).get(5,TimeUnit.SECONDS);
if(adventures.snapshot().reset()!=null)adventures.completeReset(adventures.snapshot().reset().operation()).get(5,TimeUnit.SECONDS);
adventures.accept(offered.catalog().orElseThrow().revision(),site.id(),AdventureQuestState.UnitType.SOLO,
actor.core.record.id(),Set.of(actor.core.record.id())).get(5,TimeUnit.SECONDS);
for(int retry=0;retry<20&&actor.core.inventory.getItem(0).getType()!=Material.FISHING_ROD;retry++){pulse(f);drain(f);}
var hook=mock(FishHook.class);when(hook.getUniqueId()).thenReturn(UUID.randomUUID());
when(hook.getLocation()).thenReturn(actor.core.location.clone());var caught=mock(Item.class);
when(caught.getItemStack()).thenReturn(new ItemStack(Material.COD));
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,hook,EquipmentSlot.HAND,PlayerFishEvent.State.FISHING));
f.dispatcher.accept(new PlayerFishEvent(actor.player,null,hook,EquipmentSlot.HAND,PlayerFishEvent.State.BITE));
f.dispatcher.accept(new PlayerFishEvent(actor.player,caught,hook,EquipmentSlot.HAND,PlayerFishEvent.State.CAUGHT_FISH));
await(f,()->fishing.quantity(actor.core.record.id(),site.fishing().itemId()).equals(BigInteger.ONE));
assertEquals(BigInteger.valueOf(2900),repository.load(old.id()).orElseThrow().experience());
var controller=(FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");
controller.open(actor.player);var submissionMenu=actor.inventories.getLast();
clickFishing(controller,actor,submissionMenu,offer(submissionMenu,"Submit Pearl"));
await(f,()->fishing.unclaimed(actor.core.record.id()).xp().equals(BigInteger.TEN));
controller.open(actor.player);var claimMenu=actor.inventories.getLast();
clickFishing(controller,actor,claimMenu,offer(claimMenu,"Claim earned quest rewards"));
await(f,()->fishing.unclaimed(actor.core.record.id()).equals(FishingState.Reward.empty()));
assertEquals(BigInteger.valueOf(2900),repository.load(old.id()).orElseThrow().experience());
await(f,()->{try{return exportedClaims().size()==1&&exportedCatches().size()==1;}
catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}});
f.plugin.setEnabled(false);
}
try(var paths=java.nio.file.Files.walk(data.resolve("events"))){
var catches=paths.filter(path->path.toString().endsWith(".jsonl"))
.flatMap(path->{try{return java.nio.file.Files.lines(path);}catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}})
.map(line->com.google.gson.JsonParser.parseString(line).getAsJsonObject())
.filter(event->event.get("type").getAsString().endsWith("fishing.catch_resolved")).toList();
assertEquals(1,catches.size());var value=catches.getFirst().getAsJsonObject("data");
assertEquals("at_cap",value.get("award_status").getAsString());
assertEquals("2",value.get("sampled_xp").getAsString());
assertEquals("0",value.get("applied_xp").getAsString());
assertEquals("2",value.get("lost_xp").getAsString());
assertEquals("1",value.get("stock_after").getAsString());
}
try(var paths=java.nio.file.Files.walk(data.resolve("events"))){
var claims=paths.filter(path->path.toString().endsWith(".jsonl"))
.flatMap(path->{try{return java.nio.file.Files.lines(path);}catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}})
.map(line->com.google.gson.JsonParser.parseString(line).getAsJsonObject())
.filter(event->event.get("type").getAsString().endsWith("adventure.reward_claimed")).toList();
assertEquals(1,claims.size());var value=claims.getFirst().getAsJsonObject("data");
assertEquals("at_cap",value.get("award_status").getAsString());
assertEquals("10",value.get("requested_xp").getAsString());
assertEquals("0",value.get("applied_xp").getAsString());
assertEquals("10",value.get("lost_xp").getAsString());
}
}
@Test void zeroShareBeneficiaryHasACompletionReceiptWithoutAnInventedAllocation() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 1\n");
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var leader=new FantasyStickLifecycleNativeTest.Actor(data);
var member=new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(leader.core.id,leader.player);f.players.put(member.core.id,member.player);
var world=UUID.randomUUID();when(leader.world.getUID()).thenReturn(world);
member.core.location.setWorld(leader.world);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()));
var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
var fishing=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");
var site=adventures.placeFishingSite("Sparse Pond","Sparse Scale",world,0,64,0,16,
java.math.BigDecimal.valueOf(100),BigInteger.ONE,BigInteger.ONE,BigInteger.ZERO,
AdventureQuestState.ParticipationMode.PARTY_REQUIRED).get(5,TimeUnit.SECONDS);
var old=adventures.snapshot().catalog();var roll=adventures.rerollToday(old==null?null:old.revision()).get(5,TimeUnit.SECONDS);
if(adventures.snapshot().reset()!=null)adventures.completeReset(adventures.snapshot().reset().operation()).get(5,TimeUnit.SECONDS);
var accepted=adventures.accept(roll.catalog().orElseThrow().revision(),site.id(),
AdventureQuestState.UnitType.PARTY,party.id(),Set.of(leader.core.record.id(),member.core.record.id()))
.get(5,TimeUnit.SECONDS).quest().orElseThrow();
var catchPlan=new FishingState.Catch(UUID.randomUUID(),member.core.record.id(),accepted.id(),site.id(),
site.fishing().itemId(),site.fishing().itemName(),BigInteger.ZERO,true);
fishing.prepareCatch(catchPlan).get(5,TimeUnit.SECONDS);fishing.completeCatch(catchPlan.id()).get(5,TimeUnit.SECONDS);
var controller=(FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");
controller.open(member.player);var menu=member.inventories.getLast();clickFishing(controller,member,menu,offer(menu,"Submit Sparse Scale"));
await(f,()->adventures.findQuest(accepted.id()).isEmpty());
assertTrue(fishing.completed(accepted.catalog(),site.id(),leader.core.record.id()));
assertTrue(fishing.completed(accepted.catalog(),site.id(),member.core.record.id()));
await(f,()->{try{return exportedTypes().stream().anyMatch(type->type.endsWith("adventure.items_submitted"));}
catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}});
f.plugin.setEnabled(false);
}
try(var paths=java.nio.file.Files.walk(data.resolve("events"))){
var submissions=paths.filter(path->path.toString().endsWith(".jsonl"))
.flatMap(path->{try{return java.nio.file.Files.lines(path);}catch(java.io.IOException failure){throw new java.io.UncheckedIOException(failure);}})
.map(line->com.google.gson.JsonParser.parseString(line).getAsJsonObject())
.filter(event->event.get("type").getAsString().endsWith("adventure.items_submitted")).toList();
assertEquals(1,submissions.size());
var shares=submissions.getFirst().getAsJsonObject("data").getAsJsonArray("beneficiaries");
assertEquals(2,shares.size());
assertEquals(1,java.util.stream.StreamSupport.stream(shares.spliterator(),false)
.filter(value->value.getAsJsonObject().get("allocated_xp").getAsInt()==0
&&value.getAsJsonObject().get("allocation_status").getAsString().equals("zero_share_completion")).count());
}
}
@@ -0,0 +1,211 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings;
import games.dmg.fantasystick.session.OutOfCombatRecovery;
import java.math.BigDecimal;
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.concurrent.TimeUnit;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityRegainHealthEvent;
/** Registered recovery and config publication must be observed in exported JSONL. */
class OutOfCombatRecoveryEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredReadAndSavedAmountGovernActualPersistedNativePulseWithFullAuditTiming() throws Exception {
Files.writeString(data.resolve("config.yml"), "out-of-combat-healing-delay-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender();
for (var key : List.of(OutOfCombatRecoverySettings.DELAY,
OutOfCombatRecoverySettings.INTERVAL, OutOfCombatRecoverySettings.AMOUNT))
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", key});
var player = new FantasyStickLifecycleNativeTest.Actor(data);
injured(player, BigDecimal.valueOf(4));
f.players.put(player.core.id, player.player); f.activate(player);
var recovery = (OutOfCombatRecovery) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "outOfCombatRecovery");
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"config", OutOfCombatRecoverySettings.AMOUNT, "2"});
for (int i = 0; i < 30 && !admin.messages.getLast().startsWith("Saved "); i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
}
assertTrue(admin.messages.getLast().startsWith("Saved "));
recovery.sweep(); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(0, BigDecimal.valueOf(6).compareTo(storage.find(player.core.record.id())
.get().orElseThrow().health()));
for (int i = 0; i < 200 && events().stream().noneMatch(e -> e.get("type").getAsString()
.endsWith("combat.healed")); i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var entries = events();
var reads = entries.stream().filter(e -> e.get("type").getAsString().endsWith("config.read")).toList();
assertEquals(3, reads.size());
assertEquals(List.of(OutOfCombatRecoverySettings.DELAY, OutOfCombatRecoverySettings.INTERVAL,
OutOfCombatRecoverySettings.AMOUNT), reads.stream()
.map(e -> e.getAsJsonObject("data").get("key").getAsString()).toList());
for (var read : reads) {
var value = read.getAsJsonObject("data");
assertTrue(value.has("default")); assertTrue(value.has("effective"));
assertEquals("future_recovery_evaluations", value.get("application_scope").getAsString());
assertTrue(value.get("activity_timestamp_retained").getAsBoolean());
assertEquals("console", value.get("actor_kind").getAsString());
}
var changes = entries.stream().filter(e -> e.get("type").getAsString().endsWith("config.changed")).toList();
assertEquals(1, changes.size());
var change = changes.getFirst().getAsJsonObject("data");
assertEquals("1", change.get("before").getAsString());
assertEquals("2", change.get("after").getAsString());
assertEquals("2", change.get("effective").getAsString());
assertEquals("acknowledged_future_evaluations", change.get("outcome").getAsString());
assertTrue(change.has("policy_revision"));
var healed = entries.stream().filter(e -> e.get("type").getAsString().endsWith("combat.healed")).toList();
assertEquals(1, healed.size());
var values = healed.getFirst().getAsJsonObject("data");
assertEquals("out_of_combat_recovery", values.get("healing_source").getAsString());
assertEquals("1", values.get("pulse_ordinal").getAsString());
assertTrue(values.has("last_activity_at")); assertTrue(values.has("eligible_at"));
assertTrue(values.has("maximum_health"));
assertEquals("2", values.get("configured_base").getAsString());
assertEquals("2.0", values.get("accepted_total").getAsString());
}
@Test void playerFeedbackOffStillExportsActualPersistedRecovery() throws Exception {
Files.writeString(data.resolve("config.yml"), "out-of-combat-healing-delay-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var player = new FantasyStickLifecycleNativeTest.Actor(data); injured(player, BigDecimal.valueOf(4));
f.players.put(player.core.id, player.player);
assertTrue(f.playerCommand.getExecutor().onCommand(player.player, f.playerCommand,
"fstick", new String[]{"feedback", "off"}));
f.tick();
f.activate(player);
int componentsBefore = player.messageComponents.size();
((OutOfCombatRecovery) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,
"outOfCombatRecovery")).sweep();
PartyBookNativeTest.drain(f);
assertEquals(5, player.player.getHealth());
assertEquals(componentsBefore, player.messageComponents.size(),
"Opting out of combat presentation does not opt out of healing or its audit");
for (int i = 0; i < 200 && events().stream().noneMatch(e -> e.get("type").getAsString()
.endsWith("combat.healed")); i++) Thread.sleep(10);
}
assertEquals(1, events().stream().filter(e -> e.get("type").getAsString().endsWith("combat.healed")).count());
}
@Test void writerFailureReplaysOneAcceptedPulseWithoutHealingAgainOnRestart() throws Exception {
Files.writeString(data.resolve("config.yml"), "out-of-combat-healing-delay-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var player = new FantasyStickLifecycleNativeTest.Actor(data); injured(player, BigDecimal.valueOf(4));
f.players.put(player.core.id, player.player); f.activate(player);
var log = (games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog");
log.close();
var recovery = (OutOfCombatRecovery) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "outOfCombatRecovery");
recovery.sweep(); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(0, BigDecimal.valueOf(5).compareTo(storage.find(player.core.record.id())
.get().orElseThrow().health()));
f.plugin.setEnabled(false);
assertTrue(events().isEmpty(), "Pending work is not exported evidence");
f.plugin.setEnabled(true);
for (int i = 0; i < 200 && events().stream().noneMatch(e -> e.get("type").getAsString()
.endsWith("combat.healed")); i++) Thread.sleep(10);
assertEquals(0, BigDecimal.valueOf(5).compareTo(((CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"))
.find(player.core.record.id()).get().orElseThrow().health()));
f.plugin.setEnabled(false);
}
var healed = events().stream().filter(e -> e.get("type").getAsString().endsWith("combat.healed")).toList();
assertEquals(1, healed.size(), "Replaying the event must not replay native or persisted healing");
assertEquals("1", healed.getFirst().getAsJsonObject("data").get("pulse_ordinal").getAsString());
}
@Test void cancelledNativePulseAndInvalidOrFailedConfigNeverExportSuccess() throws Exception {
Files.writeString(data.resolve("config.yml"), "out-of-combat-healing-delay-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var player = new FantasyStickLifecycleNativeTest.Actor(data); injured(player, BigDecimal.valueOf(4));
f.players.put(player.core.id, player.player); f.activate(player);
f.dispatcher.register(new Listener() {
@EventHandler public void veto(EntityRegainHealthEvent event) {
if (event.getRegainReason() == EntityRegainHealthEvent.RegainReason.CUSTOM)
event.setCancelled(true);
}
}, f.plugin);
var recovery = (OutOfCombatRecovery) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "outOfCombatRecovery");
recovery.sweep(); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(0, BigDecimal.valueOf(4).compareTo(storage.find(player.core.record.id())
.get().orElseThrow().health()));
var admin = new EquipmentAdminCommandNativeTest.Sender(); admin.allowed = false;
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"config", OutOfCombatRecoverySettings.AMOUNT});
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"config", OutOfCombatRecoverySettings.AMOUNT, "2"});
admin.allowed = true;
for (var invalid : List.of("-1", "1025", "1.5", "oops"))
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"config", OutOfCombatRecoverySettings.AMOUNT, invalid});
Files.delete(data.resolve("config.yml")); Files.createDirectory(data.resolve("config.yml"));
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"config", OutOfCombatRecoverySettings.AMOUNT, "2"});
for (int i = 0; i < 30 && !admin.messages.getLast().contains("save failed"); i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
}
assertTrue(admin.messages.getLast().contains("save failed"));
f.plugin.setEnabled(false);
}
assertTrue(events().isEmpty(), "Cancelled healing and refused configuration cannot claim success");
}
private void injured(FantasyStickLifecycleNativeTest.Actor actor, BigDecimal health) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
old.level(), old.experience(), old.unspentPoints(), health, old.learned(), old.loadout(), old.cooldowns(),
old.restrictedKills(), false, null, null), old.revision());
}
private List<JsonObject> events() throws Exception {
var entries = new ArrayList<JsonObject>(); var root = data.resolve("events");
if (!Files.isDirectory(root)) return entries;
try (var days = Files.list(root)) {
for (var day : days.filter(p -> p.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")
&& Files.isDirectory(p, java.nio.file.LinkOption.NOFOLLOW_LINKS)).toList()) {
try (var segments = Files.list(day)) {
for (var path : segments.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList()) {
var content = Files.readString(path); int end = content.lastIndexOf('\n');
if (end < 0) continue;
for (var line : content.substring(0, end).lines().toList()) if (!line.isBlank()) {
var event = JsonParser.parseString(line).getAsJsonObject();
var type = event.get("type").getAsString();
if (type.endsWith("combat.healed") && event.getAsJsonObject("data").has("recovery_kind")
|| (type.endsWith("config.read") || type.endsWith("config.changed"))
&& event.getAsJsonObject("data").has("key")
&& event.getAsJsonObject("data").get("key").getAsString()
.startsWith("out-of-combat-healing-")) entries.add(event);
}
}
}
}
}
return entries;
}
}
@@ -39,6 +39,9 @@ class OutOfCombatRecoveryNativeTest {
@Test void exactDelayRepeatedIntervalsAndLargeClockJumpsPersistOnePulseBeforeNativePublication() {
var f = new Direct();
var audit = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
f.recovery.attachEvents(new games.dmg.fantasystick.events.RecoveryEventBridge(audit::add, f.clock),
ignored -> BigInteger.ZERO);
f.recovery.sweep();
f.clock.advance(Duration.ofSeconds(9)); f.recovery.sweep();
assertTrue(f.events.isEmpty());
@@ -62,12 +65,18 @@ class OutOfCombatRecoveryNativeTest {
f.clock.advance(Duration.ofSeconds(100)); f.recovery.sweep(); f.runNext();
assertEquals(7, f.health.get(), "A late evaluation applies one pulse rather than replaying missed intervals");
assertEquals(3, f.events.size());
var heals = audit.stream().filter(event -> event.type().equals("combat.healed")).toList();
assertEquals(List.of("1", "2", "3"), heals.stream()
.map(event -> event.data().get("pulse_ordinal")).toList());
assertEquals(f.clock.now.minusSeconds(110).toString(), heals.getFirst().data().get("eligible_at"));
assertEquals(f.clock.now.minusSeconds(120).toString(), heals.getFirst().data().get("last_activity_at"));
f.health.set(10d);
var revision = f.record.get().revision();
f.clock.advance(Duration.ofSeconds(10)); f.recovery.sweep();
assertEquals(3, f.events.size(), "Full health dispatches no native healing event");
assertEquals(revision, f.record.get().revision(), "Full health performs no persistence write");
assertEquals(3, audit.stream().filter(event -> event.type().equals("combat.healed")).count());
}
@Test void acceptedEnvironmentalAndDealtDamageResetWhileCancelledAndZeroAttemptsDoNot() {
@@ -108,11 +117,16 @@ class OutOfCombatRecoveryNativeTest {
failed.clock.advance(Duration.ofSeconds(9)); failed.recovery.sweep();
assertEquals(1, failed.events.size(), "Failed persistence does not immediately replay a missed pulse");
var stale = new Direct(); stale.automatic = false; stale.recovery.sweep(); stale.clock.advance(Duration.ofSeconds(10));
var stale = new Direct(); stale.automatic = false;
var audit = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
stale.recovery.attachEvents(new games.dmg.fantasystick.events.RecoveryEventBridge(audit::add, stale.clock),
ignored -> BigInteger.ZERO);
stale.recovery.sweep(); stale.clock.advance(Duration.ofSeconds(10));
stale.recovery.sweep();
stale.recovery.acceptedDamage(damage(stale.player, 1));
stale.pending.complete(Optional.of(stale.saved(BigDecimal.valueOf(5)))); stale.runNext();
assertEquals(4, stale.health.get(), "Damage during the save invalidates native publication");
assertEquals(0, audit.stream().filter(event -> event.type().equals("combat.healed")).count());
stale.clock.advance(Duration.ofSeconds(9)); stale.recovery.sweep(); assertEquals(1, stale.events.size());
}
@@ -210,9 +224,51 @@ class OutOfCombatRecoveryNativeTest {
@Test void stackedRegenerationBonusProducesOneMaximumCappedNativeAndPersistedPulse() {
var f = new Direct(); f.health.set(9d); f.record.set(f.saved(BigDecimal.valueOf(9)));
f.recovery.attachBonusHealing(ignored -> BigInteger.valueOf(3));
var audit = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
f.recovery.attachEvents(new games.dmg.fantasystick.events.RecoveryEventBridge(audit::add, f.clock),
ignored -> BigInteger.valueOf(3));
f.recovery.sweep(); f.clock.advance(Duration.ofSeconds(10)); f.recovery.sweep();
assertEquals(1, f.events.size()); assertEquals(1, f.events.getFirst().getAmount());
f.runNext(); assertEquals(10, f.health.get()); assertEquals(0, BigDecimal.TEN.compareTo(f.record.get().health()));
var healed = audit.stream().filter(input -> input.type().equals("combat.healed")).toList();
assertEquals(1, healed.size());
assertEquals("1.0", healed.getFirst().data().get("accepted_total"));
assertEquals("1.0", healed.getFirst().data().get("base_contribution"));
assertEquals("3", healed.getFirst().data().get("configured_quicker_regeneration_i"));
assertFalse(healed.getFirst().data().containsKey("passive_contributions"),
"The capped pulse cannot claim a bonus it did not deliver");
var fullBonus = new Direct();
fullBonus.recovery.attachBonusHealing(ignored -> BigInteger.valueOf(3));
var fullAudit = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
fullBonus.recovery.attachEvents(new games.dmg.fantasystick.events.RecoveryEventBridge(
fullAudit::add, fullBonus.clock), ignored -> BigInteger.valueOf(3));
fullBonus.recovery.sweep(); fullBonus.clock.advance(Duration.ofSeconds(10));
fullBonus.recovery.sweep(); fullBonus.runNext();
assertEquals(8, fullBonus.health.get());
var fullHeal = fullAudit.stream().filter(input -> input.type().equals("combat.healed")).toList();
assertEquals(1, fullHeal.size());
assertEquals("4.0", fullHeal.getFirst().data().get("accepted_total"));
assertEquals(java.util.Map.of("quicker_regeneration_i", "3.0"),
fullHeal.getFirst().data().get("passive_contributions"));
}
@Test void namedPassiveContributionsDescribeOnlyActualAcceptedHealing() {
var f = new Direct(); f.health.set(5d); f.record.set(f.saved(BigDecimal.valueOf(5)));
f.recovery.attachBonusHealing(ignored -> BigInteger.valueOf(5));
var audit = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
f.recovery.attachEvents(new games.dmg.fantasystick.events.RecoveryEventBridge(audit::add, f.clock),
ignored -> BigInteger.valueOf(3), ignored -> BigInteger.valueOf(2));
f.recovery.sweep(); f.clock.advance(Duration.ofSeconds(10)); f.recovery.sweep(); f.runNext();
assertEquals(10, f.health.get(), "The cap delivers only one of Quicker II's two configured points");
var healed = audit.stream().filter(event -> event.type().equals("combat.healed")).toList();
assertEquals(1, healed.size());
assertEquals("5.0", healed.getFirst().data().get("accepted_total"));
@SuppressWarnings("unchecked")
var contributions = (java.util.Map<String, String>) healed.getFirst().data().get("passive_contributions");
assertEquals(java.util.Set.of("quicker_regeneration_i", "quicker_regeneration_ii"), contributions.keySet());
assertEquals(0, new BigDecimal(contributions.get("quicker_regeneration_i")).compareTo(BigDecimal.valueOf(3)));
assertEquals(0, new BigDecimal(contributions.get("quicker_regeneration_ii")).compareTo(BigDecimal.ONE));
}
@Test void zeroAmountDisablesPulsesAndReconfigurationRetainsTheActivityTimestamp() {
@@ -259,27 +315,33 @@ class OutOfCombatRecoveryNativeTest {
assertEquals(5, a.player.getHealth());
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(0, BigDecimal.valueOf(5).compareTo(storage.find(characterId).get().orElseThrow().health()));
}
var healed = new java.util.ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(data.resolve("events"))) {
for (var segment : files.filter(file -> file.getFileName().toString().startsWith("events-")
&& file.getFileName().toString().endsWith(".jsonl")).toList()) {
for (var line : java.nio.file.Files.readAllLines(segment)) {
if (line.isBlank()) continue;
var event = com.google.gson.JsonParser.parseString(line).getAsJsonObject();
if (event.get("type").getAsString().endsWith("combat.healed")
&& event.getAsJsonObject("data").has("recovery_kind")) healed.add(event);
var healed = new java.util.ArrayList<com.google.gson.JsonObject>();
for (int attempt = 0; attempt < 200; attempt++) {
healed.clear();
try (var files = java.nio.file.Files.walk(data.resolve("events"))) {
for (var segment : files.filter(file -> file.getFileName().toString().startsWith("events-")
&& file.getFileName().toString().endsWith(".jsonl")).toList()) {
for (var line : java.nio.file.Files.readAllLines(segment)) {
if (line.isBlank()) continue;
var event = com.google.gson.JsonParser.parseString(line).getAsJsonObject();
if (event.get("type").getAsString().endsWith("combat.healed")
&& event.getAsJsonObject("data").has("recovery_kind")) healed.add(event);
}
}
}
if (!healed.isEmpty()) break;
Thread.sleep(10);
}
// Acknowledged recovery intents export asynchronously; retain the exact exported-event assertion.
assertEquals(1, healed.size());
var event = healed.getFirst(); var payload = event.getAsJsonObject("data");
assertEquals(playerId.toString(), payload.get("player_id").getAsString());
assertEquals(characterId.toString(), payload.get("character_id").getAsString());
assertTrue(payload.has("session_id"));
assertEquals("unavailable", payload.get("location_status").getAsString());
assertFalse(payload.has("location"));
assertEquals("5.0", payload.get("health_after").getAsString());
}
assertEquals(1, healed.size());
var event = healed.getFirst(); var payload = event.getAsJsonObject("data");
assertEquals(playerId.toString(), payload.get("player_id").getAsString());
assertEquals(characterId.toString(), payload.get("character_id").getAsString());
assertTrue(payload.has("session_id"));
assertEquals("unavailable", payload.get("location_status").getAsString());
assertFalse(payload.has("location"));
assertEquals("5.0", payload.get("health_after").getAsString());
}
@Test void packagedPluginConnectsZeroDelayRecoveryConfigurationAndRestart() throws Exception {
@@ -0,0 +1,267 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import games.dmg.fantasystick.persistence.PartySettings;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
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;
import org.bukkit.command.CommandSender;
/** Evidence must join the registered policy path to actual roster enforcement and exported JSONL. */
class PartyCapacityEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredReadAndAcknowledgedReductionAndIncreaseGovernActualJoinsAndExportPolicy() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", PartySettings.KEY});
assertTrue(admin.messages.getLast().contains("=6"));
var leader = PartyCapacityNativeTest.actor(f, data);
var member = PartyCapacityNativeTest.actor(f, data);
var third = PartyCapacityNativeTest.actor(f, data);
var roster = PartyLifecycleNativeTest.parties(f);
var party = roster.create(leader.core.id).orElseThrow();
var first = roster.invite(leader.core.id, party.id(), member.core.id).orElseThrow();
assertTrue(roster.accept(member.core.id, first.id()));
var second = roster.invite(leader.core.id, party.id(), third.core.id).orElseThrow();
change(f, admin, "2");
assertFalse(roster.accept(third.core.id, second.id()));
change(f, admin, "3");
assertTrue(roster.accept(third.core.id, second.id()));
change(f, admin, "2");
assertEquals(3, roster.partyOf(leader.core.id).orElseThrow().members().size());
assertTrue(roster.invite(leader.core.id, party.id(), java.util.UUID.randomUUID()).isEmpty());
for (int i = 0; i < 200 && config().size() < 4; i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var entries = config(); assertEquals(4, entries.size(), "Only exported JSONL proves event delivery");
assertEquals(List.of("6", "2", "3", "2"), entries.stream()
.map(e -> e.getAsJsonObject("data").get("effective").getAsString()).toList());
assertEquals(List.of("config.read", "config.changed", "config.changed", "config.changed"), entries.stream()
.map(e -> e.get("type").getAsString().substring(e.get("type").getAsString().lastIndexOf(".",
e.get("type").getAsString().lastIndexOf(".") - 1) + 1)).toList());
assertTrue(entries.stream().skip(1).allMatch(e -> e.getAsJsonObject("data")
.get("existing_rosters_grandfathered").getAsBoolean()));
}
@Test void deniedInvalidAndFailedSaveNeverExportAChangeOrModifyCapacity() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender(); admin.allowed = false;
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", PartySettings.KEY});
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", PartySettings.KEY, "2"});
admin.allowed = true;
for (var invalid : List.of("0", "-2", "2.5", "2147483648", "not-a-number")) {
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", PartySettings.KEY, invalid});
assertTrue(admin.messages.getLast().contains("invalid"));
}
Files.createDirectory(data.resolve("config.yml"));
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", PartySettings.KEY, "2"});
for (int i = 0; i < 30 && !admin.messages.getLast().contains("save failed"); i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
}
assertTrue(admin.messages.getLast().contains("save failed"));
assertEquals(PartySettings.DEFAULT, ((PartySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "partySettings")).value());
f.plugin.setEnabled(false);
}
assertTrue(config().isEmpty(), "Refusals and failed writes have no feature-owned success");
}
@Test void outOfOrderSavedCallbacksAndRepeatedValueNeverPublishFalseChanges() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
while (!f.tasks.isEmpty()) f.tasks.remove().run();
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", PartySettings.KEY, "2"});
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", PartySettings.KEY, "3"});
var settings = (PartySettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "partySettings");
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(
settings, "worker");
java.util.concurrent.CompletableFuture.runAsync(() -> {}, worker).get(5, TimeUnit.SECONDS);
var first = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(first);
var second = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(second);
second.run(); first.run();
assertEquals("3", settings.value());
change(f, admin, "3");
for (int i = 0; i < 200 && config().isEmpty(); i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var entries = config(); assertEquals(1, entries.size());
var values = entries.getFirst().getAsJsonObject("data");
assertEquals("6", values.get("before").getAsString());
assertEquals("3", values.get("effective").getAsString());
assertEquals("future_join_checks", values.get("application_scope").getAsString());
assertNotNull(values.get("operation_id")); assertNotNull(values.get("policy_revision"));
}
@Test void physicalAdministratorReadReportsExactIdentityLocationAndDefaultWithoutRosters() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
doReturn("Administrator").when(admin.player).getName();
f.players.put(admin.core.id, admin.player);
f.adminCommand.execute(admin.player, "fstickadmin", new String[]{"config", PartySettings.KEY});
for (int i = 0; i < 200 && config().isEmpty(); i++) Thread.sleep(10);
var observed = config(); assertEquals(1, observed.size());
var data = observed.getFirst().getAsJsonObject("data");
assertEquals(admin.core.id.toString(), data.get("player_id").getAsString());
assertEquals(admin.core.location.getX(), data.getAsJsonObject("location").get("x").getAsDouble());
f.plugin.setEnabled(false);
}
var entries = config(); assertEquals(1, entries.size());
var values = entries.getFirst().getAsJsonObject("data");
assertEquals("6", values.get("default").getAsString());
assertEquals("6", values.get("effective").getAsString());
assertEquals("player", values.get("actor_kind").getAsString());
assertEquals("Administrator", values.get("player_name").getAsString());
assertEquals("future_join_checks", values.get("application_scope").getAsString());
assertNotNull(values.getAsJsonObject("location").get("x"));
}
@Test void unavailableWriterRetainsSingleAcceptedPolicyIntentAcrossRestartWithoutAnotherWrite() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender();
var log = (games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog");
log.close(); change(f, admin, "2");
assertEquals("2", ((PartySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "partySettings")).value());
f.plugin.setEnabled(false);
assertTrue(config().isEmpty(), "Only exported segments count as evidence");
f.plugin.setEnabled(true);
for (int i = 0; i < 200 && config().isEmpty(); i++) Thread.sleep(10);
assertEquals("2", ((PartySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "partySettings")).value());
f.plugin.setEnabled(false);
}
var entries = config(); assertEquals(1, entries.size());
assertEquals("2", entries.getFirst().getAsJsonObject("data").get("effective").getAsString());
}
@Test void auditStoreFailureCannotRollBackAnAcknowledgedLimitOrMutateGrandfatheredRoster() throws Exception {
Files.writeString(data.resolve("party-capacity-event-intents"), "blocked");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var leader = PartyCapacityNativeTest.actor(f, data);
var member = PartyCapacityNativeTest.actor(f, data);
var third = PartyCapacityNativeTest.actor(f, data);
var roster = PartyLifecycleNativeTest.parties(f);
var party = roster.create(leader.core.id).orElseThrow();
assertTrue(roster.accept(member.core.id, roster.invite(leader.core.id, party.id(), member.core.id)
.orElseThrow().id()));
assertTrue(roster.accept(third.core.id, roster.invite(leader.core.id, party.id(), third.core.id)
.orElseThrow().id()));
var admin = new EquipmentAdminCommandNativeTest.Sender();
change(f, admin, "2");
assertEquals("2", ((PartySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "partySettings")).value());
assertEquals(3, roster.partyOf(leader.core.id).orElseThrow().members().size());
assertTrue(roster.invite(leader.core.id, party.id(), java.util.UUID.randomUUID()).isEmpty());
f.plugin.setEnabled(false);
}
assertTrue(config().isEmpty(), "Intent-store failure cannot fabricate an exported success");
}
@Test void callbackAfterCloseDoesNotInventAnUnpublishedRuntimeChange() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var settings = (PartySettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "partySettings");
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(
settings, "worker");
var entered = new java.util.concurrent.CountDownLatch(1);
var release = new java.util.concurrent.CountDownLatch(1);
worker.execute(() -> {
entered.countDown();
try { release.await(); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
});
assertTrue(entered.await(5, TimeUnit.SECONDS));
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", PartySettings.KEY, "2"});
try { f.plugin.setEnabled(false); } finally { release.countDown(); }
settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
while (!f.tasks.isEmpty()) f.tasks.remove().run();
assertEquals("6", settings.value()); assertTrue(config().isEmpty());
f.plugin.setEnabled(true);
assertEquals("2", ((PartySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "partySettings")).value());
f.plugin.setEnabled(false);
}
assertTrue(config().isEmpty(), "Startup must not fabricate an earlier runtime publication");
}
@Test void failedFeedbackCannotReverseAcknowledgedLimitOrOwnedEvent() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
CommandSender broken = FantasyStickLifecycleNativeTest.port(CommandSender.class, (name, args) -> {
return switch (name) {
case "hasPermission" -> true;
case "sendMessage" -> throw new IllegalStateException("feedback delivery failed");
default -> throw new AssertionError("Unexpected sender call " + name);
};
});
f.adminCommand.execute(broken, "fstickadmin", new String[]{"config", PartySettings.KEY, "2"});
var settings = (PartySettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "partySettings");
for (int i = 0; i < 30 && !"2".equals(settings.value()); i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback);
try { callback.run(); } catch (IllegalStateException feedback) {
assertEquals("feedback delivery failed", feedback.getMessage());
}
}
assertEquals("2", settings.value());
for (int i = 0; i < 200 && config().isEmpty(); i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
assertEquals(1, config().size());
assertEquals("2", config().getFirst().getAsJsonObject("data").get("effective").getAsString());
}
private void change(FantasyStickLifecycleNativeTest.Fixture f,
EquipmentAdminCommandNativeTest.Sender admin, String value) throws Exception {
int before = admin.messages.size();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", PartySettings.KEY, value});
for (int i = 0; i < 30 && admin.messages.size() == before; i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
}
assertTrue(admin.messages.getLast().startsWith("Saved "), admin.messages.toString());
}
private List<JsonObject> config() throws Exception {
var entries = new ArrayList<JsonObject>(); var root = data.resolve("events");
if (!Files.isDirectory(root)) return entries;
try (var days = Files.list(root)) {
for (var day : days.filter(p -> p.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")
&& Files.isDirectory(p, java.nio.file.LinkOption.NOFOLLOW_LINKS)).toList()) {
try (var segments = Files.list(day)) {
for (var path : segments.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList()) {
var content = Files.readString(path); int end = content.lastIndexOf('\n');
if (end < 0) continue;
for (var line : content.substring(0, end).lines().toList()) if (!line.isBlank()) {
var event = JsonParser.parseString(line).getAsJsonObject();
if (event.get("type").getAsString().endsWith("config.read")
|| event.get("type").getAsString().endsWith("config.changed")) {
var values = event.getAsJsonObject("data");
if (values.has("key") && PartySettings.KEY.equals(values.get("key").getAsString()))
entries.add(event);
}
}
}
}
}
}
return entries;
}
}
@@ -0,0 +1,344 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import games.dmg.fantasystick.persistence.RoleplaySettings;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.bukkit.command.CommandSender;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Availability is a saved policy and exit request, not a claim that every restoration completed. */
class RoleplayAvailabilityEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredReadAndDisableRequestTwoOrdinaryExitsWithoutClaimingCompletedRestoration() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", RoleplaySettings.KEY});
assertTrue(admin.messages.getLast().contains("=false"));
var one = new FantasyStickLifecycleNativeTest.Actor(data);
var two = new FantasyStickLifecycleNativeTest.Actor(data);
var ordinary = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(one.core.id, one.player); f.players.put(two.core.id, two.player);
f.players.put(ordinary.core.id, ordinary.player);
f.activate(one); f.activate(two);
var ordinaryBefore = ordinary.core.snapshot();
change(f, admin, "true");
assertArrayEquals(ordinaryBefore, ordinary.core.snapshot());
var policy = (RoleplaySettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "roleplaySettings");
assertTrue(policy.disabled());
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
assertTrue(sessions.active(one.core.id).isEmpty()); assertTrue(sessions.active(two.core.id).isEmpty());
f.awaitCharacterAndReceipt(one); f.awaitCharacterAndReceipt(two);
assertNotNull(f.receipts().find(one.core.id).toCompletableFuture()
.get(5, TimeUnit.SECONDS).orElseThrow().lastExit());
assertNotNull(f.receipts().find(two.core.id).toCompletableFuture()
.get(5, TimeUnit.SECONDS).orElseThrow().lastExit());
var blocked = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(blocked.core.id, blocked.player);
var original = blocked.core.snapshot();
f.changed(blocked); PartyBookNativeTest.drain(f);
assertTrue(sessions.active(blocked.core.id).isEmpty());
assertArrayEquals(original, blocked.core.snapshot());
for (int i = 0; i < 200 && config().size() < 3; i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var changes = config();
assertEquals(List.of("config.read", "config.changed", "game.availability_changed"), changes.stream()
.map(event -> event.get("type").getAsString().replace("games.dmg.minecraft.fantasystick.", ""))
.toList());
var config = changes.get(1).getAsJsonObject("data");
var availability = changes.get(2).getAsJsonObject("data");
assertEquals("false", config.get("before").getAsString());
assertEquals("true", config.get("effective").getAsString());
assertEquals(config.get("operation_id").getAsString(), availability.get("origin_admin_action_id").getAsString());
assertEquals(2, availability.get("exit_requested_count").getAsInt());
assertEquals(2, availability.get("exit_request_accepted_count").getAsInt());
assertEquals(0, availability.get("exit_confirmed_count").getAsInt(),
"The synchronous policy callback cannot attest later receipt completion");
assertEquals(2, availability.get("exit_pending_count").getAsInt());
assertEquals(0, availability.get("exit_failed_count").getAsInt());
assertTrue(availability.get("available_before").getAsBoolean());
assertFalse(availability.get("available_after").getAsBoolean());
}
@Test void failedNativeRestoreIsCountedSeparatelyFromPendingAndNeverConfirmedByToggle() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender();
var failed = new FantasyStickLifecycleNativeTest.Actor(data);
var intact = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(failed.core.id, failed.player); f.players.put(intact.core.id, intact.player);
f.activate(failed); f.activate(intact);
failed.core.mutation = () -> { throw new IllegalStateException("native restoration refused"); };
change(f, admin, "true");
failed.core.mutation = () -> {};
var failedReceipt = f.receipts().find(failed.core.id).toCompletableFuture()
.get(5, TimeUnit.SECONDS).orElseThrow();
assertNotNull(failedReceipt.pending(), "Failed native custody retains proof for recovery");
f.awaitCharacterAndReceipt(intact);
assertNotNull(f.receipts().find(intact.core.id).toCompletableFuture()
.get(5, TimeUnit.SECONDS).orElseThrow().lastExit());
for (int i = 0; i < 200 && config().size() < 2; i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var result = config().stream().filter(event -> event.get("type").getAsString()
.endsWith("game.availability_changed")).findFirst().orElseThrow().getAsJsonObject("data");
assertEquals(2, result.get("exit_requested_count").getAsInt());
assertEquals(1, result.get("exit_failed_count").getAsInt());
assertEquals(1, result.get("exit_pending_count").getAsInt());
assertEquals(1, result.get("exit_native_restored_pending_receipt_count").getAsInt());
assertEquals(0, result.get("exit_confirmed_count").getAsInt());
}
@Test void deniedInvalidOrFailedSaveCannotExportReadChangeOrAvailability() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender();
var active = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(active.core.id, active.player); f.activate(active);
admin.allowed = false;
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", RoleplaySettings.KEY});
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", RoleplaySettings.KEY, "true"});
admin.allowed = true;
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", RoleplaySettings.KEY, "TRUE"});
assertTrue(admin.messages.getLast().contains("true|false"));
Files.createDirectory(data.resolve("config.yml"));
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", RoleplaySettings.KEY, "true"});
for (int i = 0; i < 20 && !admin.messages.getLast().contains("save failed"); i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
}
assertTrue(admin.messages.getLast().contains("save failed"));
assertFalse(((RoleplaySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "roleplaySettings")).disabled());
assertTrue(((EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "sessions")).active(active.core.id).isPresent());
f.plugin.setEnabled(false);
}
assertTrue(config().isEmpty());
}
@Test void repeatedSettingDoesNotDuplicateAvailabilityAndReenableHasNoExitRequest() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender();
var active = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(active.core.id, active.player); f.activate(active);
change(f, admin, "true");
f.awaitCharacterAndReceipt(active);
change(f, admin, "true");
change(f, admin, "false");
for (int i = 0; i < 200 && config().size() < 4; i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var entries = config();
assertEquals(4, entries.size(), "Read-free true/false changes have one config and one availability record each");
var availability = entries.stream().filter(e -> e.get("type").getAsString()
.endsWith("game.availability_changed")).map(e -> e.getAsJsonObject("data")).toList();
assertEquals(2, availability.size());
assertEquals(1, availability.getFirst().get("exit_requested_count").getAsInt());
assertEquals(0, availability.getLast().get("exit_requested_count").getAsInt());
assertFalse(availability.getFirst().get("available_after").getAsBoolean());
assertTrue(availability.getLast().get("available_after").getAsBoolean());
}
@Test void unavailableEventWriterRetainsOnePolicyTransitionForRestartReplayWithoutNewExits() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender();
var log = (games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog");
log.close();
change(f, admin, "true");
f.plugin.setEnabled(false);
assertTrue(config().isEmpty(), "Pending intents are not exported day-segment evidence");
f.plugin.setEnabled(true);
for (int i = 0; i < 200 && config().size() < 2; i++) Thread.sleep(10);
assertTrue(((RoleplaySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "roleplaySettings")).disabled());
f.plugin.setEnabled(false);
}
var entries = config(); assertEquals(2, entries.size());
var changed = entries.stream().filter(e -> e.get("type").getAsString().endsWith("config.changed"))
.findFirst().orElseThrow().getAsJsonObject("data");
var availability = entries.stream().filter(e -> e.get("type").getAsString()
.endsWith("game.availability_changed")).findFirst().orElseThrow().getAsJsonObject("data");
assertEquals(changed.get("operation_id").getAsString(),
availability.get("origin_admin_action_id").getAsString());
assertEquals(0, availability.get("exit_requested_count").getAsInt());
}
@Test void reversedSavedCallbacksCannotPublishSupersededDisableOrInventAnAvailabilityChange() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
for (int i = 0; i < 30 && !f.tasks.isEmpty(); i++) f.tasks.remove().run();
var admin = new EquipmentAdminCommandNativeTest.Sender();
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(actor.core.id, actor.player); f.activate(actor);
f.awaitCharacterAndReceipt(actor); PartyBookNativeTest.drain(f);
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", RoleplaySettings.KEY, "true"});
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", RoleplaySettings.KEY, "false"});
var policy = (RoleplaySettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "roleplaySettings");
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(
policy, "worker");
java.util.concurrent.CompletableFuture.runAsync(() -> {}, worker).get(5, TimeUnit.SECONDS);
var first = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(first);
var second = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(second);
second.run(); first.run();
assertFalse(policy.disabled());
assertTrue(((EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "sessions")).active(actor.core.id).isPresent());
f.plugin.setEnabled(false);
}
assertTrue(config().isEmpty(), "No effective availability transition was published");
}
@Test void administratorReadCapturesRealPlayerAndLocationWithoutPublishingPolicy() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
doReturn("Administrator").when(admin.player).getName();
doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
f.players.put(admin.core.id, admin.player);
f.adminCommand.execute(admin.player, "fstickadmin", new String[]{"config", RoleplaySettings.KEY});
for (int i = 0; i < 200 && config().isEmpty(); i++) Thread.sleep(10);
f.plugin.setEnabled(false);
var events = config(); assertEquals(1, events.size());
var read = events.getFirst().getAsJsonObject("data");
assertEquals(admin.core.id.toString(), read.get("player_id").getAsString());
assertEquals("Administrator", read.get("player_name").getAsString());
assertEquals("player", read.get("actor_kind").getAsString());
assertFalse(read.get("effective").getAsBoolean());
assertEquals(admin.core.location.getX(), read.getAsJsonObject("location").get("x").getAsDouble());
}
assertFalse(Files.exists(data.resolve("config.yml")));
}
@Test void closedLifetimeCannotPublishOrReplayAnOldAvailabilitySuccess() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var old = (RoleplaySettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "roleplaySettings");
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(
old, "worker");
var entered = new java.util.concurrent.CountDownLatch(1);
var release = new java.util.concurrent.CountDownLatch(1);
worker.execute(() -> {
entered.countDown();
try { release.await(); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
});
assertTrue(entered.await(5, TimeUnit.SECONDS));
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", RoleplaySettings.KEY, "true"});
try { f.plugin.setEnabled(false); } finally { release.countDown(); }
old.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
while (!f.tasks.isEmpty()) f.tasks.remove().run();
assertFalse(old.disabled(), "The disabled old command did not publish a late callback");
assertTrue(config().isEmpty());
f.plugin.setEnabled(true);
for (int i = 0; i < 40 && FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions") == null; i++)
f.tick();
assertTrue(((RoleplaySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "roleplaySettings")).disabled(), "The new boot loads the saved policy");
f.plugin.setEnabled(false);
}
assertTrue(config().isEmpty(), "Startup must not invent an old-lifetime admin action");
}
@Test void feedbackFailureCannotRollBackAcknowledgedPolicyOrItsAvailabilityRecords() 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); f.activate(actor);
CommandSender broken = FantasyStickLifecycleNativeTest.port(CommandSender.class, (name, args) -> {
return switch (name) {
case "hasPermission" -> true;
case "sendMessage" -> throw new IllegalStateException("feedback delivery failed");
default -> throw new AssertionError("Unexpected sender call " + name);
};
});
f.adminCommand.execute(broken, "fstickadmin", new String[]{"config", RoleplaySettings.KEY, "true"});
for (int i = 0; i < 30 && !((RoleplaySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "roleplaySettings")).disabled(); i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback);
try { callback.run(); } catch (IllegalStateException feedback) {
assertEquals("feedback delivery failed", feedback.getMessage());
}
}
f.awaitCharacterAndReceipt(actor);
for (int i = 0; i < 200 && config().size() < 2; i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var entries = config(); assertEquals(2, entries.size());
assertEquals(1, entries.stream().filter(e -> e.get("type").getAsString()
.endsWith("game.availability_changed")).count());
assertTrue(entries.stream().anyMatch(e -> e.get("type").getAsString().endsWith("config.changed")));
}
@Test void auditStoreFailureDoesNotPreventSavedDisableAndNativeExit() throws Exception {
Files.writeString(data.resolve("roleplay-availability-event-intents"), "unavailable");
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); f.activate(actor);
var admin = new EquipmentAdminCommandNativeTest.Sender();
change(f, admin, "true");
f.awaitCharacterAndReceipt(actor);
assertTrue(((RoleplaySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "roleplaySettings")).disabled());
assertTrue(((EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "sessions")).active(actor.core.id).isEmpty());
f.plugin.setEnabled(false);
}
assertTrue(config().isEmpty(), "An unavailable audit store cannot undo or claim the real transition");
}
private static void change(FantasyStickLifecycleNativeTest.Fixture f,
EquipmentAdminCommandNativeTest.Sender admin, String value) throws Exception {
int responses = admin.messages.size();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", RoleplaySettings.KEY, value});
for (int i = 0; i < 30 && admin.messages.size() == responses; i++) {
var task = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(task); task.run();
}
assertTrue(admin.messages.getLast().startsWith("Saved "), admin.messages.toString());
}
private List<JsonObject> config() throws Exception {
var events = new ArrayList<JsonObject>();
var root = data.resolve("events");
if (!Files.isDirectory(root)) return events;
try (var days = Files.list(root)) {
for (var day : days.filter(path -> path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")
&& Files.isDirectory(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)).toList()) {
try (var segments = Files.list(day)) {
for (var path : segments.filter(file -> file.getFileName().toString().endsWith(".jsonl")).toList()) {
var content = Files.readString(path);
int end = content.lastIndexOf('\n');
if (end < 0) continue;
for (var line : content.substring(0, end).lines().toList()) if (!line.isBlank()) {
var event = JsonParser.parseString(line).getAsJsonObject();
var type = event.get("type").getAsString();
if (type.endsWith("game.availability_changed")
|| (type.endsWith("config.read") || type.endsWith("config.changed"))
&& event.getAsJsonObject("data").has("key")
&& event.getAsJsonObject("data").get("key").getAsString().equals(RoleplaySettings.KEY))
events.add(event);
}
}
}
}
}
return events;
}
}
@@ -0,0 +1,384 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import games.dmg.fantasystick.persistence.SheetResourcePackSettings;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.bukkit.event.player.PlayerResourcePackStatusEvent;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
/** Native optional delivery and actual status callbacks, not a cosmetic or request-only inference. */
class SheetResourcePackEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void startupNativeRequestAndRealAcceptedThenLoadedCallbacksExportDistinctEvents() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
var player = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(player.core.id, player.player);
f.plugin.setEnabled(true);
assertEquals(1, player.resourcePacks.size());
var request = SheetResourcePackNativeTest.request(player.resourcePacks.getFirst());
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", SheetResourcePackSettings.URL_KEY});
f.dispatcher.accept(new PlayerResourcePackStatusEvent(player.player, request.id(),
PlayerResourcePackStatusEvent.Status.ACCEPTED));
f.dispatcher.accept(new PlayerResourcePackStatusEvent(player.player, request.id(),
PlayerResourcePackStatusEvent.Status.SUCCESSFULLY_LOADED));
for (int i = 0; i < 200 && events().size() < 4; i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var entries = events(); assertEquals(4, entries.size(), "Native delivery/status/read must reach exported JSONL");
assertEquals(1, entries.stream().filter(e -> e.get("type").getAsString().endsWith("delivery_requested")).count());
assertEquals(2, entries.stream().filter(e -> e.get("type").getAsString().endsWith("delivery_resolved")).count());
assertEquals(1, entries.stream().filter(e -> e.get("type").getAsString().endsWith("config.read")).count());
}
@Test void acknowledgedRedeliveryUsesDistinctRequestIdentitySoLateOldStatusCannotClaimNewLoad() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
var player = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(player.core.id, player.player); f.plugin.setEnabled(true);
var first = SheetResourcePackNativeTest.request(player.resourcePacks.getFirst());
var admin = new EquipmentAdminCommandNativeTest.Sender();
var url = "https://packs.example.test/sheet-v2.zip?secret=do-not-export";
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"config", SheetResourcePackSettings.URL_KEY, url});
for (int i = 0; i < 30 && player.resourcePacks.size() < 2; i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
}
var second = SheetResourcePackNativeTest.request(player.resourcePacks.getLast());
assertNotEquals(first.id(), second.id(), "A status exposes only pack ID; each offer needs a distinct ID");
f.dispatcher.accept(new PlayerResourcePackStatusEvent(player.player, first.id(),
PlayerResourcePackStatusEvent.Status.SUCCESSFULLY_LOADED));
f.dispatcher.accept(new PlayerResourcePackStatusEvent(player.player, second.id(),
PlayerResourcePackStatusEvent.Status.DECLINED));
for (int i = 0; i < 200 && events().size() < 4; i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
assertTrue(events().stream().noneMatch(e -> e.toString().contains("do-not-export")));
var requested = events().stream().filter(e -> e.get("type").getAsString().endsWith("delivery_requested")).toList();
assertEquals(2, requested.size());
var statuses = events().stream().filter(e -> e.get("type").getAsString().endsWith("delivery_resolved")).toList();
assertEquals(1, statuses.size(), "A late prior-request callback cannot claim current client load");
assertEquals("declined", statuses.getFirst().getAsJsonObject("data").get("status").getAsString());
}
@Test void reloadCannotAssignAnOldLifetimeStatusToANewStartupOffer() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
var player = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(player.core.id, player.player);
f.plugin.setEnabled(true);
var old = SheetResourcePackNativeTest.request(player.resourcePacks.getLast());
f.plugin.setEnabled(false);
f.plugin.setEnabled(true);
var current = SheetResourcePackNativeTest.request(player.resourcePacks.getLast());
assertNotEquals(old.id(), current.id(), "Old callbacks must never identify a new lifetime's offer");
f.dispatcher.accept(new PlayerResourcePackStatusEvent(player.player, old.id(),
PlayerResourcePackStatusEvent.Status.SUCCESSFULLY_LOADED));
for (int i = 0; i < 200 && events().size() < 2; i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
assertEquals(2, events().stream().filter(e -> e.get("type").getAsString()
.endsWith("delivery_requested")).count());
assertTrue(events().stream().noneMatch(e -> e.get("type").getAsString()
.endsWith("delivery_resolved")));
}
@Test void joinAndNativeOutcomesAreDistinctDeduplicatedAndNeverImplyAnUnobservedLoad() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var player = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(player.core.id, player.player);
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(player.player,
(net.kyori.adventure.text.Component)null));
var request = SheetResourcePackNativeTest.request(player.resourcePacks.getFirst());
for (var status : List.of(PlayerResourcePackStatusEvent.Status.ACCEPTED,
PlayerResourcePackStatusEvent.Status.DOWNLOADED,
PlayerResourcePackStatusEvent.Status.FAILED_DOWNLOAD,
PlayerResourcePackStatusEvent.Status.FAILED_DOWNLOAD))
f.dispatcher.accept(new PlayerResourcePackStatusEvent(player.player, request.id(), status));
f.dispatcher.accept(new PlayerResourcePackStatusEvent(player.player, java.util.UUID.randomUUID(),
PlayerResourcePackStatusEvent.Status.SUCCESSFULLY_LOADED));
for (int i = 0; i < 200 && events().size() < 4; i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var entries = events(); assertEquals(4, entries.size());
var requested = entries.stream().filter(e -> e.get("type").getAsString().endsWith("delivery_requested")).toList();
assertEquals("join", requested.getFirst().getAsJsonObject("data").get("trigger").getAsString());
var statuses = entries.stream().filter(e -> e.get("type").getAsString().endsWith("delivery_resolved")).toList();
assertEquals(List.of("accepted", "downloaded", "failed_download"), statuses.stream()
.map(e -> e.getAsJsonObject("data").get("status").getAsString()).toList());
assertTrue(statuses.stream().allMatch(e -> e.getAsJsonObject("data")
.get("origin_delivery_action_id").getAsString().equals(requested.getFirst().getAsJsonObject("data")
.get("action_id").getAsString())));
}
@Test void authorizedPhysicalReadsAndRefusalsNeverLeakUrlSecretsOrClaimFailedChanges() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(admin.core.id, admin.player);
doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
doReturn("Administrator").when(admin.player).getName();
f.adminCommand.execute(admin.player, "fstickadmin", new String[]{"config", SheetResourcePackSettings.URL_KEY});
f.adminCommand.execute(admin.player, "fstickadmin", new String[]{"config", SheetResourcePackSettings.SHA1_KEY});
for (int i = 0; i < 200 && events().size() < 2; i++) Thread.sleep(10);
var reads = events(); assertEquals(2, reads.size());
assertEquals(admin.core.id.toString(), reads.getFirst().getAsJsonObject("data")
.get("player_id").getAsString());
assertEquals(admin.core.location.getX(), reads.getFirst().getAsJsonObject("data")
.getAsJsonObject("location").get("x").getAsDouble());
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", SheetResourcePackSettings.URL_KEY});
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", SheetResourcePackSettings.URL_KEY,
"https://packs.example.test/other.zip?secret=do-not-export"});
sender.allowed = true;
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", SheetResourcePackSettings.URL_KEY,
"http://packs.example.test/invalid.zip"});
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", SheetResourcePackSettings.SHA1_KEY,
"not-a-hash"});
Files.createDirectory(data.resolve("config.yml"));
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"config", SheetResourcePackSettings.URL_KEY,
"https://packs.example.test/other.zip?secret=do-not-export"});
for (int i = 0; i < 30 && !sender.messages.getLast().contains("save failed"); i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
}
assertTrue(sender.messages.getLast().contains("save failed"));
f.plugin.setEnabled(false);
}
assertEquals(2, events().size());
assertTrue(events().stream().noneMatch(e -> e.toString().contains("do-not-export")));
}
@Test void writerUnavailableRetainsOneAcknowledgedChangeForReplayAfterRestart() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var log = (games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog");
log.close();
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{
"config", SheetResourcePackSettings.SHA1_KEY, "a".repeat(40)});
for (int i = 0; i < 30 && sender.messages.isEmpty(); i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
}
assertTrue(sender.messages.getLast().startsWith("Saved "));
f.plugin.setEnabled(false);
assertTrue(events().stream().noneMatch(e -> e.get("type").getAsString().endsWith("config.changed")));
f.plugin.setEnabled(true);
for (int i = 0; i < 200 && events().stream().noneMatch(e -> e.get("type").getAsString()
.endsWith("config.changed")); i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var changes = events().stream().filter(e -> e.get("type").getAsString().endsWith("config.changed")).toList();
assertEquals(1, changes.size());
assertEquals("a".repeat(40), changes.getFirst().getAsJsonObject("data").get("effective").getAsString());
}
@Test void closedOldCallbackAndFailedFeedbackNeverFabricateClientLoadOrReverseSavedPair() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
CommandSender broken = FantasyStickLifecycleNativeTest.port(CommandSender.class, (name, args) -> {
return switch (name) {
case "hasPermission" -> true;
case "sendMessage" -> throw new IllegalStateException("feedback delivery failed");
default -> throw new AssertionError("Unexpected sender call " + name);
};
});
f.adminCommand.execute(broken, "fstickadmin", new String[]{
"config", SheetResourcePackSettings.SHA1_KEY, "a".repeat(40)});
var settings = (SheetResourcePackSettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "sheetResourcePackSettings");
for (int i = 0; i < 30 && !"a".repeat(40).equals(settings.sha1()); i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback);
try { callback.run(); } catch (IllegalStateException feedback) {
assertEquals("feedback delivery failed", feedback.getMessage());
}
}
assertEquals("a".repeat(40), settings.sha1());
for (int i = 0; i < 200 && events().stream().noneMatch(e -> e.get("type").getAsString()
.endsWith("config.changed")); i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
assertEquals(1, events().stream().filter(e -> e.get("type").getAsString().endsWith("config.changed")).count());
assertTrue(events().stream().noneMatch(e -> e.get("type").getAsString().endsWith("delivery_resolved")));
}
@Test void nativeOfferAndFeedbackFailureRemainOptionalAndCannotPreventStartup() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
var player = new FantasyStickLifecycleNativeTest.Actor(data);
Player broken = FantasyStickLifecycleNativeTest.port(Player.class, (name, args) -> {
if (name.equals("addResourcePack")) throw new IllegalStateException("native offer failed");
if (name.equals("sendMessage")) throw new IllegalStateException("feedback failed");
var arguments = args == null ? new Object[0] : args;
var matching = java.util.Arrays.stream(Player.class.getMethods())
.filter(method -> method.getName().equals(name) && method.getParameterCount() == arguments.length)
.filter(method -> java.util.stream.IntStream.range(0, arguments.length)
.allMatch(i -> arguments[i] == null || method.getParameterTypes()[i].isInstance(arguments[i])
|| method.getParameterTypes()[i].isPrimitive()))
.findFirst().orElseThrow();
try { return matching.invoke(player.player, arguments); }
catch (ReflectiveOperationException failed) { throw new IllegalStateException(failed); }
});
f.players.put(player.core.id, broken);
assertDoesNotThrow(() -> f.plugin.setEnabled(true));
for (int i = 0; i < 200 && events().isEmpty(); i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var requested = events().stream().filter(e -> e.get("type").getAsString().endsWith("delivery_requested")).toList();
assertEquals(1, requested.size());
assertEquals("native_offer_failed", requested.getFirst().getAsJsonObject("data").get("outcome").getAsString());
assertTrue(events().stream().noneMatch(e -> e.get("type").getAsString().endsWith("delivery_resolved")));
}
@Test void unavailableLocationIsExplicitlyQualifiedWithoutBlockingNativeOfferAndStatus() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var player = mock(Player.class);
when(player.isOnline()).thenReturn(true);
when(player.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(player.getName()).thenReturn("LocationUnavailable");
when(player.getLocation()).thenThrow(new IllegalArgumentException("World unloaded"));
var delivery = (games.dmg.fantasystick.presentation.SheetResourcePack)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sheetResourcePack");
delivery.deliver(player);
var offered = org.mockito.ArgumentCaptor.forClass(java.util.UUID.class);
verify(player).addResourcePack(offered.capture(), anyString(), any(byte[].class), anyString(), eq(false));
f.dispatcher.accept(new PlayerResourcePackStatusEvent(player, offered.getValue(),
PlayerResourcePackStatusEvent.Status.DECLINED));
for (int i = 0; i < 200 && events().size() < 2; i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var entries = events(); assertEquals(2, entries.size());
assertTrue(entries.stream().allMatch(e -> e.getAsJsonObject("data").has("location_status")
&& "unavailable".equals(e.getAsJsonObject("data").get("location_status").getAsString())));
assertTrue(entries.stream().allMatch(e -> !e.getAsJsonObject("data").has("location")));
}
@Test void failedAuditIntentCannotPreventOptionalDeliveryOrSaveBeforeRedelivery() throws Exception {
Files.writeString(data.resolve("sheet-resource-pack-event-intents"), "blocked");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
var player = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(player.core.id, player.player);
f.plugin.setEnabled(true);
assertEquals(1, player.resourcePacks.size());
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"config", SheetResourcePackSettings.SHA1_KEY, "a".repeat(40)});
for (int i = 0; i < 30 && player.resourcePacks.size() < 2; i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
}
assertEquals(2, player.resourcePacks.size());
assertArrayEquals(java.util.HexFormat.of().parseHex("a".repeat(40)),
SheetResourcePackNativeTest.request(player.resourcePacks.getLast()).hash());
f.plugin.setEnabled(false);
}
assertTrue(events().isEmpty(), "The unavailable audit writer cannot manufacture exported evidence");
}
@Test void supersededSavedCallbacksNeverRedeliverOrRecordTheStalePair() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
var player = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(player.core.id, player.player);
f.plugin.setEnabled(true);
f.awaitCharacterAndReceipt(player);
PartyBookNativeTest.drain(f);
while (!f.tasks.isEmpty()) f.tasks.remove().run();
var admin = new EquipmentAdminCommandNativeTest.Sender();
var callbacks = new java.util.concurrent.LinkedBlockingQueue<Runnable>();
var command = FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "admin");
var scheduler = command.getClass().getDeclaredField("nextTick");
scheduler.setAccessible(true);
var original = scheduler.get(command);
try {
scheduler.set(command, (java.util.function.Consumer<Runnable>)callbacks::add);
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"config", SheetResourcePackSettings.SHA1_KEY, "a".repeat(40)});
var first = callbacks.poll(5, TimeUnit.SECONDS); assertNotNull(first);
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"config", SheetResourcePackSettings.SHA1_KEY, "b".repeat(40)});
var second = callbacks.poll(5, TimeUnit.SECONDS); assertNotNull(second);
second.run(); first.run();
} finally { scheduler.set(command, original); }
var settings = (SheetResourcePackSettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "sheetResourcePackSettings");
assertEquals("b".repeat(40), settings.sha1());
assertEquals(2, player.resourcePacks.size(), "Only the latest acknowledged policy redelivers");
for (int i = 0; i < 200 && events().stream().noneMatch(e -> e.get("type").getAsString()
.endsWith("config.changed")); i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var changes = events().stream().filter(e -> e.get("type").getAsString().endsWith("config.changed")).toList();
assertEquals(1, changes.size());
assertEquals("b".repeat(40), changes.getFirst().getAsJsonObject("data").get("effective").getAsString());
}
@Test void closeBeforeSavedCallbackCannotClaimUnpublishedConfigOrDeliverPack() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
var player = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(player.core.id, player.player);
f.plugin.setEnabled(true);
var settings = (SheetResourcePackSettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "sheetResourcePackSettings");
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(settings, "worker");
var entered = new java.util.concurrent.CountDownLatch(1);
var release = new java.util.concurrent.CountDownLatch(1);
worker.execute(() -> {
entered.countDown();
try { release.await(); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
});
assertTrue(entered.await(5, TimeUnit.SECONDS));
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"config", SheetResourcePackSettings.SHA1_KEY, "a".repeat(40)});
try { f.plugin.setEnabled(false); } finally { release.countDown(); }
settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
while (!f.tasks.isEmpty()) f.tasks.remove().run();
assertEquals(1, player.resourcePacks.size());
assertTrue(events().stream().noneMatch(e -> e.get("type").getAsString().endsWith("config.changed")));
f.plugin.setEnabled(true);
assertEquals("a".repeat(40), ((SheetResourcePackSettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "sheetResourcePackSettings")).sha1());
f.plugin.setEnabled(false);
}
assertTrue(events().stream().noneMatch(e -> e.get("type").getAsString().endsWith("config.changed")));
}
private List<JsonObject> events() throws Exception {
var entries = new ArrayList<JsonObject>(); var root = data.resolve("events");
if (!Files.isDirectory(root)) return entries;
try (var days = Files.list(root)) {
for (var day : days.filter(p -> p.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")
&& Files.isDirectory(p, java.nio.file.LinkOption.NOFOLLOW_LINKS)).toList()) {
try (var segments = Files.list(day)) {
for (var path : segments.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList()) {
var content = Files.readString(path); int end = content.lastIndexOf('\n');
if (end < 0) continue;
for (var line : content.substring(0, end).lines().toList()) if (!line.isBlank()) {
var event = JsonParser.parseString(line).getAsJsonObject();
var type = event.get("type").getAsString();
if (type.endsWith("resource_pack.delivery_requested")
|| type.endsWith("resource_pack.delivery_resolved")
|| (type.endsWith("config.read") || type.endsWith("config.changed"))
&& event.getAsJsonObject("data").has("key")
&& event.getAsJsonObject("data").get("key").getAsString().startsWith("sheet-resource-pack-"))
entries.add(event);
}
}
}
}
}
return entries;
}
}
@@ -2,7 +2,7 @@ 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.*;import net.kyori.adventure.text.Component;import org.bukkit.event.player.PlayerResourcePackStatusEvent;import org.junit.jupiter.api.BeforeAll;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
class SheetResourcePackNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
record Request(UUID id,String url,byte[] hash,String prompt,boolean required){}
@Test void packagedStartupJoinAndStatusesUseOptionalNativeDeliveryWithoutGameplayMutation()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){var a=new FantasyStickLifecycleNativeTest.Actor(data);a.core.inventory.setItemInOffHand(null);f.players.put(a.core.id,a.player);var before=a.core.snapshot();f.plugin.setEnabled(true);f.awaitCharacterAndReceipt(a);PartyBookNativeTest.drain(f);assertEquals(1,a.resourcePacks.size());var request=request(a.resourcePacks.getFirst());assertEquals(games.dmg.fantasystick.presentation.SheetResourcePack.ID,request.id());assertEquals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.packagedDefaults().url(),request.url());assertEquals(20,request.hash().length);assertFalse(request.required());assertArrayEquals(before,a.core.snapshot());for(var status:List.of(PlayerResourcePackStatusEvent.Status.DECLINED,PlayerResourcePackStatusEvent.Status.FAILED_DOWNLOAD,PlayerResourcePackStatusEvent.Status.INVALID_URL,PlayerResourcePackStatusEvent.Status.FAILED_RELOAD,PlayerResourcePackStatusEvent.Status.DISCARDED)){f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),status));assertTrue(a.messages.getLast().toLowerCase().contains("cosmetic"));assertArrayEquals(before,a.core.snapshot());}int messages=a.messages.size();f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.ACCEPTED));f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.DOWNLOADED));f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.SUCCESSFULLY_LOADED));assertEquals(messages,a.messages.size());var active=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(active.core.id,active.player);var legacyMeta=active.core.inventory.getItemInOffHand().getItemMeta();legacyMeta.setItemModel(null);active.core.inventory.getItemInOffHand().setItemMeta(legacyMeta);var activeBefore=active.core.snapshot();var expectedMeta=activeBefore[40].getItemMeta();expectedMeta.setItemModel(games.dmg.fantasystick.items.CharacterSheets.MODEL);activeBefore[40].setItemMeta(expectedMeta);var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");sessions.enter(active.player);awaitPacked(f,active);assertEquals(games.dmg.fantasystick.items.CharacterSheets.MODEL,active.core.inventory.getItemInOffHand().getItemMeta().getItemModel());sessions.exit(active.player);f.awaitCharacterAndReceipt(active);assertArrayEquals(activeBefore,active.core.snapshot());var retained=(games.dmg.fantasystick.presentation.SheetResourcePack)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sheetResourcePack");f.plugin.setEnabled(false);retained.status(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.DECLINED));assertEquals(messages,a.messages.size());f.plugin.setEnabled(true);var late=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(late.core.id,late.player);f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(late.player,(Component)null));assertEquals(1,late.resourcePacks.size());}}
@Test void packagedStartupJoinAndStatusesUseOptionalNativeDeliveryWithoutGameplayMutation()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){var a=new FantasyStickLifecycleNativeTest.Actor(data);a.core.inventory.setItemInOffHand(null);f.players.put(a.core.id,a.player);var before=a.core.snapshot();f.plugin.setEnabled(true);f.awaitCharacterAndReceipt(a);PartyBookNativeTest.drain(f);assertEquals(1,a.resourcePacks.size());var request=request(a.resourcePacks.getFirst());assertNotNull(request.id(),"Every native offer has its own correlatable pack identity");assertEquals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.packagedDefaults().url(),request.url());assertEquals(20,request.hash().length);assertFalse(request.required());assertArrayEquals(before,a.core.snapshot());for(var status:List.of(PlayerResourcePackStatusEvent.Status.DECLINED,PlayerResourcePackStatusEvent.Status.FAILED_DOWNLOAD,PlayerResourcePackStatusEvent.Status.INVALID_URL,PlayerResourcePackStatusEvent.Status.FAILED_RELOAD,PlayerResourcePackStatusEvent.Status.DISCARDED)){f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),status));assertTrue(a.messages.getLast().toLowerCase().contains("cosmetic"));assertArrayEquals(before,a.core.snapshot());}int messages=a.messages.size();f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.ACCEPTED));f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.DOWNLOADED));f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.SUCCESSFULLY_LOADED));assertEquals(messages,a.messages.size());var active=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(active.core.id,active.player);var legacyMeta=active.core.inventory.getItemInOffHand().getItemMeta();legacyMeta.setItemModel(null);active.core.inventory.getItemInOffHand().setItemMeta(legacyMeta);var activeBefore=active.core.snapshot();var expectedMeta=activeBefore[40].getItemMeta();expectedMeta.setItemModel(games.dmg.fantasystick.items.CharacterSheets.MODEL);activeBefore[40].setItemMeta(expectedMeta);var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");sessions.enter(active.player);awaitPacked(f,active);assertEquals(games.dmg.fantasystick.items.CharacterSheets.MODEL,active.core.inventory.getItemInOffHand().getItemMeta().getItemModel());sessions.exit(active.player);f.awaitCharacterAndReceipt(active);assertArrayEquals(activeBefore,active.core.snapshot());var retained=(games.dmg.fantasystick.presentation.SheetResourcePack)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sheetResourcePack");f.plugin.setEnabled(false);retained.status(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.DECLINED));assertEquals(messages,a.messages.size());f.plugin.setEnabled(true);var late=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(late.core.id,late.player);f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(late.player,(Component)null));assertEquals(1,late.resourcePacks.size());}}
@Test void invalidStartupAndFailedRegisteredWriteNeverDeliverOrChangeEffectiveDefaults()throws Exception{int route=0;for(String value:List.of("sheet-resource-pack-url: 'http://bad.test/x.zip'\n","sheet-resource-pack-sha1: bad\n")){var dir=data.resolve("invalid-"+route++);java.nio.file.Files.createDirectories(dir);java.nio.file.Files.writeString(dir.resolve("config.yml"),value);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){assertThrows(IllegalArgumentException.class,()->f.plugin.setEnabled(true));assertTrue(f.order.isEmpty(),"native registration order "+f.order);assertTrue(f.tasks.isEmpty(),"queued tasks");}}var failed=data.resolve("failed");java.nio.file.Files.createDirectories(failed);try(var f=new FantasyStickLifecycleNativeTest.Fixture(failed)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(failed);f.players.put(a.core.id,a.player);java.nio.file.Files.createDirectory(failed.resolve("config.yml"));var sender=new EquipmentAdminCommandNativeTest.Sender();f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url","https://packs.example.test/x.zip"});var callback=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(callback);callback.run();assertTrue(sender.messages.getLast().contains("save failed"),sender.messages::toString);assertTrue(a.resourcePacks.isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url"});assertTrue(sender.messages.getLast().contains("git.garvis.dev"));}}
@Test void registeredConfigurationDurablyPublishesAndRedeliversWhileFailureRetainsPair()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(a.core.id,a.player);var sender=new EquipmentAdminCommandNativeTest.Sender();assertEquals(List.of("sheet-resource-pack-sha1","sheet-resource-pack-url"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sheet-r"}));assertEquals(1,f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url",""}).size());sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sheet-r"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url","https://packs.example.test/sheet.zip"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url","https://packs.example.test/x.zip","extra"});assertTrue(sender.messages.getLast().contains("Usage"));for(var pair:List.of(new String[]{"sheet-resource-pack-url","http://bad.test/x.zip"},new String[]{"sheet-resource-pack-sha1","bad"})){f.command.execute(sender.port,"fantasystick",new String[]{"config",pair[0],pair[1]});assertTrue(sender.messages.getLast().contains("invalid"));}save(f,sender,"sheet-resource-pack-url","https://packs.example.test/sheet.zip");assertEquals(1,a.resourcePacks.size());assertEquals("https://packs.example.test/sheet.zip",request(a.resourcePacks.getLast()).url());save(f,sender,"sheet-resource-pack-sha1","a".repeat(40));assertEquals(2,a.resourcePacks.size());assertArrayEquals(java.util.HexFormat.of().parseHex("a".repeat(40)),request(a.resourcePacks.getLast()).hash());f.plugin.setEnabled(false);f.plugin.setEnabled(true);f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url"});assertTrue(sender.messages.getLast().contains("https://packs.example.test/sheet.zip"));}}
static void awaitPacked(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor)throws Exception{long deadline=System.nanoTime()+java.util.concurrent.TimeUnit.SECONDS.toNanos(10);while(System.nanoTime()<deadline){var marker=actor.core.marker();if(marker!=null&&marker.endsWith("|PACKED"))return;var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();}fail("Session did not activate; notices="+actor.messages);}
@@ -34,6 +34,8 @@ final class ChampionTrophyDirectoryTest {
assertEquals(FishingDirectory.TrophyRedeemStatus.APPLIED,directory.redeemTrophy(trophyId,carrier).get(5,TimeUnit.SECONDS));
assertTrue(directory.trophies(carrier).isEmpty());
assertEquals(new FishingState.Reward(BigInteger.valueOf(150),BigInteger.valueOf(40)),directory.unclaimed(beneficiary));
assertTrue(Files.readString(data.resolve("fishing.yml")).contains("source-kind: champion_trophy"),
"source type must survive consumption of the last native trophy item");
assertEquals(FishingDirectory.TrophyRedeemStatus.REDEEMED,directory.redeemTrophy(trophyId,carrier).get(5,TimeUnit.SECONDS));
assertEquals(FishingDirectory.TrophyPrepareStatus.REDEEMED,directory.prepareTrophy(trophy).get(5,TimeUnit.SECONDS));
}
@@ -44,6 +46,55 @@ final class ChampionTrophyDirectoryTest {
}
}
@Test void redemptionCapturesAllocationIdsBeforeAConcurrentClaimRemovesStock() throws Exception {
UUID carrier=UUID.randomUUID(),beneficiary=UUID.randomUUID(),zero=UUID.randomUUID();
var trophy=new FishingState.Trophy(UUID.randomUUID(),FishingState.TrophyType.CHAMPION,
carrier,UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),
"Daybreak Champion Head","Peak",java.util.Map.of(beneficiary,
new FishingState.Reward(BigInteger.ONE,BigInteger.ZERO),zero,FishingState.Reward.empty()));
try(var directory=new FishingDirectory(new YamlFishingRepository(data))){
assertEquals(FishingDirectory.TrophyPrepareStatus.APPLIED,
directory.prepareTrophy(trophy).get(5,TimeUnit.SECONDS));
assertEquals(FishingDirectory.TrophyCommitStatus.APPLIED,
directory.commitTrophy(trophy.id()).get(5,TimeUnit.SECONDS));
var receipt=directory.redeemTrophyWithEvidence(trophy.id(),carrier).get(5,TimeUnit.SECONDS);
assertEquals(FishingDirectory.TrophyRedeemStatus.APPLIED,receipt.status());
assertEquals(1,receipt.allocations().size());
var allocation=receipt.allocations().getFirst();
assertEquals(beneficiary,allocation.character());
assertEquals("champion_trophy",allocation.sourceKind());
var claim=directory.beginClaim(beneficiary).get(5,TimeUnit.SECONDS).orElseThrow();
assertTrue(directory.finishClaim(claim.id()).get(5,TimeUnit.SECONDS));
assertTrue(directory.snapshot().allocations().isEmpty());
assertEquals(allocation,receipt.allocations().getFirst(),
"an asynchronous event callback must use the immutable redemption result");
assertEquals(FishingDirectory.TrophyRedeemStatus.REDEEMED,
directory.redeemTrophyWithEvidence(trophy.id(),carrier).get(5,TimeUnit.SECONDS).status());
}
}
@Test void capturedChampionIdentityAndLocationSurvivePreparedRecoveryAndCommit() throws Exception {
UUID carrier=UUID.randomUUID(),champion=UUID.randomUUID();
var audit=java.util.Map.of("champion_id",champion.toString(),"defeat_world_id",UUID.randomUUID().toString(),
"defeat_x","12.5","defeat_y","64.0","defeat_z","-4.0",
"unit_kind","party","accepted_roster",carrier.toString());
var trophy=new FishingState.Trophy(UUID.randomUUID(),FishingState.TrophyType.CHAMPION,carrier,
UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),
"Daybreak Champion Head","Summit",
java.util.Map.of(carrier,new FishingState.Reward(BigInteger.ONE,BigInteger.ZERO)),
FishingState.TrophyPhase.PREPARED,audit);
try(var directory=new FishingDirectory(new YamlFishingRepository(data))){
assertEquals(FishingDirectory.TrophyPrepareStatus.APPLIED,directory.prepareTrophy(trophy).get(5,TimeUnit.SECONDS));
}
try(var restarted=new FishingDirectory(new YamlFishingRepository(data))){
assertEquals(audit,restarted.snapshot().trophies().getFirst().audit());
assertEquals(FishingDirectory.TrophyCommitStatus.APPLIED,
restarted.commitTrophy(trophy.id()).get(5,TimeUnit.SECONDS));
assertEquals(audit,restarted.trophies(carrier).getFirst().audit());
assertEquals(2,restarted.trophies(carrier).getFirst().revision());
}
}
@Test void releasedFishingSchemaLoadsWithEmptyTrophyCollections() throws Exception {
Files.writeString(data.resolve("fishing.yml"),"schema-version: 1\ninventory: []\ncatches: []\ncatch-receipts: []\nsubmissions: []\ncompletions: []\nallocations: []\nclaims: []\n");
try(var directory=new FishingDirectory(new YamlFishingRepository(data))){assertTrue(directory.snapshot().trophies().isEmpty());assertTrue(directory.snapshot().trophyReceipts().isEmpty());}
@@ -20,12 +20,14 @@ final class FishingDirectoryTest {
@Test void preparedCatchRecoversAndCompletesItemExactlyOnce() throws Exception {
UUID operation=UUID.randomUUID(),character=UUID.randomUUID(),site=UUID.randomUUID(),item=UUID.randomUUID();
var plan=new FishingState.Catch(operation,character,UUID.randomUUID(),site,item,"Silver Scale",
BigInteger.valueOf(3),true);
BigInteger.valueOf(3),true,Map.of("hook_id",UUID.randomUUID().toString(),"chance_percent","42.5",
"sample_percent","23.75","native_item_disposition","suppressed"));
try(var directory=new FishingDirectory(new YamlFishingRepository(data))){
assertEquals(FishingDirectory.PrepareStatus.APPLIED,directory.prepareCatch(plan).get(5,TimeUnit.SECONDS));
assertEquals(plan,directory.pendingCatches().getFirst());
}
try(var restarted=new FishingDirectory(new YamlFishingRepository(data))){
assertEquals(plan,restarted.pendingCatches().getFirst(),"native capture survives writer restart before claim completion");
assertEquals(FishingDirectory.CompleteStatus.APPLIED,restarted.completeCatch(operation).get(5,TimeUnit.SECONDS));
assertEquals(BigInteger.ONE,restarted.quantity(character,item));
assertEquals("Silver Scale",restarted.inventory(character).getFirst().name());
@@ -37,6 +39,58 @@ final class FishingDirectoryTest {
}
}
@Test void serialCatchReceiptCarriesItsOwnAuthoritativeStockDelta() throws Exception {
var character=UUID.randomUUID();var site=UUID.randomUUID();var item=UUID.randomUUID();
try(var directory=new FishingDirectory(new YamlFishingRepository(data))){
for(int expected=0;expected<2;expected++){
var operation=UUID.randomUUID();
var plan=new FishingState.Catch(operation,character,UUID.randomUUID(),site,item,"Scale",
BigInteger.ONE,true);
assertEquals(FishingDirectory.PrepareStatus.APPLIED,directory.prepareCatch(plan).get(5,TimeUnit.SECONDS));
var receipt=directory.completeCatchWithEvidence(operation).get(5,TimeUnit.SECONDS);
assertEquals(FishingDirectory.CompleteStatus.APPLIED,receipt.status());
assertEquals(BigInteger.valueOf(expected),receipt.stockBefore());
assertEquals(BigInteger.valueOf(expected+1),receipt.stockAfter());
}
}
}
@Test void committedSubmissionReturnsTheSavedStockDeltaNotALaterInventoryGuess() throws Exception {
var character=UUID.randomUUID();var site=UUID.randomUUID();var item=UUID.randomUUID();var quest=UUID.randomUUID();
var catalog=UUID.randomUUID();var receipt=UUID.randomUUID();var submission=UUID.randomUUID();
try(var directory=new FishingDirectory(new YamlFishingRepository(data))){
var caught=new FishingState.Catch(receipt,character,quest,site,item,"Scale",BigInteger.ZERO,true);
directory.prepareCatch(caught).get(5,TimeUnit.SECONDS);
directory.completeCatch(receipt).get(5,TimeUnit.SECONDS);
var plan=new FishingState.Submission(submission,quest,catalog,site,character,item,BigInteger.ONE,
Map.of(character,FishingState.Reward.empty()),FishingState.SubmissionPhase.PREPARED);
directory.prepareSubmission(plan).get(5,TimeUnit.SECONDS);
var committed=directory.commitSubmissionWithEvidence(submission).get(5,TimeUnit.SECONDS);
assertEquals(FishingDirectory.CommitStatus.APPLIED,committed.status());
assertEquals(BigInteger.ONE,committed.stockBefore());
assertEquals(BigInteger.ZERO,committed.stockAfter());
}
}
@Test void preparedSubmissionRetainsCapturedUnitAndZeroShareRosterAfterRestart() throws Exception {
var character=UUID.randomUUID();var other=UUID.randomUUID();var site=UUID.randomUUID();
var item=UUID.randomUUID();var quest=UUID.randomUUID();var party=UUID.randomUUID();
var catchId=UUID.randomUUID();
var plan=new FishingState.Submission(UUID.randomUUID(),quest,UUID.randomUUID(),site,character,item,
BigInteger.ONE,Map.of(character,new FishingState.Reward(BigInteger.ONE,BigInteger.ZERO),
other,FishingState.Reward.empty()),FishingState.SubmissionPhase.PREPARED,
Map.of("unit_kind","party","unit_id",party.toString(),"roster",character+","+other));
try(var directory=new FishingDirectory(new YamlFishingRepository(data))){
directory.prepareCatch(new FishingState.Catch(catchId,character,quest,site,item,"Scale",
BigInteger.ZERO,true)).get(5,TimeUnit.SECONDS);
directory.completeCatch(catchId).get(5,TimeUnit.SECONDS);
assertEquals(FishingDirectory.PrepareStatus.APPLIED,directory.prepareSubmission(plan).get(5,TimeUnit.SECONDS));
}
try(var restarted=new FishingDirectory(new YamlFishingRepository(data))){
assertEquals(plan,restarted.pendingSubmissions().getFirst());
}
}
@Test void submissionConsumesOneCharactersStockAndFreezesConservedNonexpiringShares() throws Exception {
UUID character=UUID.randomUUID(),other=UUID.randomUUID(),site=UUID.randomUUID(),item=UUID.randomUUID();
UUID catchOperation=UUID.randomUUID(),catalog=UUID.randomUUID(),quest=UUID.randomUUID(),submission=UUID.randomUUID();
@@ -15,6 +15,40 @@ import org.junit.jupiter.api.io.TempDir;
final class DungeonDirectoryTest {
@TempDir Path data;
@Test void committedBossCompletionProofSurvivesRunMutationAndRepositoryReload() throws Exception {
var repository=new YamlDungeonRepository(data);
var generation=UUID.randomUUID();var boss=UUID.randomUUID();
UUID dungeonId,roomId;
try(var directory=new DungeonDirectory(repository,()->1)){
var dungeon=directory.create("Proof Vault",UUID.randomUUID(),0,64,0,0,0)
.get(5,TimeUnit.SECONDS);
dungeonId=dungeon.id();
var room=directory.addRadiusRoom(dungeonId,"Boss",0,64,0,3,3).get(5,TimeUnit.SECONDS);
roomId=room.id();
directory.run(dungeonId,generation).get(5,TimeUnit.SECONDS);
var proof=java.util.Map.of("boss_id",boss.toString(),"room_id",roomId.toString(),
"defeated_at",java.time.Instant.now().truncatedTo(
java.time.temporal.ChronoUnit.SECONDS).toString());
var defeated=directory.defeat(dungeonId,generation,proof).get(5,TimeUnit.SECONDS);
assertTrue(defeated.defeated());
assertEquals(proof,defeated.completionAudit());
assertFalse(defeated.completionPublished());
assertFalse(directory.acknowledgeCompletion(dungeonId,generation,UUID.randomUUID())
.get(5,TimeUnit.SECONDS),"another boss cannot acknowledge the committed defeat");
assertTrue(directory.acknowledgeCompletion(dungeonId,generation,boss)
.get(5,TimeUnit.SECONDS));
assertTrue(directory.acknowledgeCompletion(dungeonId,generation,boss)
.get(5,TimeUnit.SECONDS),"acknowledgement remains idempotent");
assertEquals(proof,directory.markCleared(dungeonId,generation,roomId)
.get(5,TimeUnit.SECONDS).completionAudit());
}
var loaded=repository.load().runs().stream().filter(run->run.dungeon().equals(dungeonId))
.findFirst().orElseThrow();
assertEquals(boss.toString(),loaded.completionAudit().get("boss_id"));
assertEquals(roomId.toString(),loaded.completionAudit().get("room_id"));
assertTrue(loaded.completionPublished(),"acknowledged export survives room updates and reload");
}
@Test void persistsConnectedDefinitionAndRejectsOverlapOrDisconnectedRemoval() throws Exception {
var repository=new YamlDungeonRepository(data);
UUID world=UUID.randomUUID();
@@ -0,0 +1,51 @@
package games.dmg.fantasystick.events;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.dungeon.DungeonState;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
final class DungeonCompletionExportEvidenceTest {
@TempDir Path data;
@Test void onlyMatchingExportedJsonlCanAcknowledgeTheExactCommittedBoss() throws Exception {
var dungeon=UUID.randomUUID();var generation=UUID.randomUUID();var boss=UUID.randomUUID();
var run=new DungeonState.Run(dungeon,generation,Set.of(),Set.of(),Set.of(),Set.of(),
true,Map.of("boss_id",boss.toString()));
var id=DungeonEventBridge.completionEventId(run);
var pending=data.resolve("events/pending");Files.createDirectories(pending);
Files.writeString(pending.resolve(id+".pending"),record(id,dungeon,generation,boss));
assertFalse(DungeonEventBridge.exported(data,id,dungeon,generation,boss),
"a transient pending intent is not an exported acknowledgement");
var day=data.resolve("events/2026-09-27");Files.createDirectories(day);
var segment=day.resolve("segment.jsonl");
Files.writeString(segment,record(id,dungeon,generation,boss)+"\n");
assertTrue(DungeonEventBridge.exported(data,id,dungeon,generation,boss));
assertFalse(DungeonEventBridge.exported(data,UUID.randomUUID(),dungeon,generation,boss));
var externalId=UUID.randomUUID();
var outside=data.resolve("outside.jsonl");
Files.writeString(outside,record(externalId,dungeon,generation,boss)+"\n");
Files.createSymbolicLink(day.resolve("linked.jsonl"),outside);
assertFalse(DungeonEventBridge.exported(data,externalId,dungeon,generation,boss),
"a linked external file is not an exported event segment");
assertThrows(IOException.class,()->DungeonEventBridge.exported(data,id,dungeon,
generation,UUID.randomUUID()),"an identity collision cannot acknowledge another boss");
Files.writeString(segment,record(id,dungeon,generation,boss)+"\n{" );
assertThrows(IOException.class,()->DungeonEventBridge.exported(data,UUID.randomUUID(),
dungeon,generation,boss),"corrupt exported segments cannot attest absence safely");
}
private static String record(UUID id,UUID dungeon,UUID generation,UUID boss){
return "{\"id\":\""+id+"\",\"type\":\"dungeon.completed\",\"data\":{"
+"\"dungeon_id\":\""+dungeon+"\",\"generation_id\":\""+generation
+"\",\"boss_id\":\""+boss+"\"}}";
}
}
@@ -0,0 +1,95 @@
package games.dmg.fantasystick.events;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Path;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class FeatureEventOutboxCollectionsTest {
@TempDir Path data;
@Test void rejectedInFlightIntentCanReplayWithItsOriginalIdAndDifferentIdsStillDeliver() throws Exception {
var first=new EventInput(UUID.randomUUID(),UUID.randomUUID(),Instant.parse("2026-09-26T00:00:00Z"),
"adventure.trophy_earned",null,Map.of("receipt_id",UUID.randomUUID().toString()));
var other=new EventInput(UUID.randomUUID(),UUID.randomUUID(),Instant.parse("2026-09-26T00:00:01Z"),
"adventure.trophy_earned",null,Map.of("receipt_id",UUID.randomUUID().toString()));
var refused=new CompletableFuture<LocalEventLog.Delivery>();
var initial=new java.util.concurrent.CountDownLatch(2);
var deliveries=new java.util.concurrent.CopyOnWriteArrayList<EventInput>();
var failures=new ArrayList<Throwable>();
var writer=new FeatureEventOutbox(data,"fishing-event-intents",
Set.of("adventure.trophy_earned"),event->{
deliveries.add(event);
if(deliveries.size()<=2)initial.countDown();
return event.id().equals(first.id())&&deliveries.stream()
.filter(value->value.id().equals(first.id())).count()==1
?refused:CompletableFuture.completedFuture(LocalEventLog.Delivery.EXPORTED);
},failures::add);
writer.accepted(first);
writer.accepted(other);
assertTrue(initial.await(5,TimeUnit.SECONDS));
assertEquals(List.of(first,other),deliveries);
refused.complete(LocalEventLog.Delivery.REJECTED);
assertTrue(java.nio.file.Files.exists(data.resolve("fishing-event-intents")
.resolve(first.id()+".pending")));
writer.replay();writer.close();writer.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
assertEquals(List.of(first,other,first),deliveries);
assertTrue(failures.isEmpty(),failures.toString());
}
@Test void failedInFlightIntentRemainsRetryableWithItsOriginalId() throws Exception {
var original=new EventInput(UUID.randomUUID(),UUID.randomUUID(),
Instant.parse("2026-09-26T00:00:00Z"),"adventure.trophy_earned",null,
Map.of("receipt_id",UUID.randomUUID().toString()));
var deliveries=new java.util.concurrent.CopyOnWriteArrayList<EventInput>();
var failures=new java.util.concurrent.CopyOnWriteArrayList<Throwable>();
var writer=new FeatureEventOutbox(data,"fishing-event-intents",
Set.of("adventure.trophy_earned"),event->{
deliveries.add(event);
return deliveries.size()==1
?CompletableFuture.failedFuture(new java.io.IOException("sink unavailable"))
:CompletableFuture.completedFuture(LocalEventLog.Delivery.EXPORTED);
},failures::add);
writer.accepted(original);
writer.replay();writer.close();writer.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
assertEquals(List.of(original,original),deliveries);
assertEquals(1,failures.size());
assertFalse(java.nio.file.Files.exists(data.resolve("fishing-event-intents")
.resolve(original.id()+".pending")));
}
@Test void durableFishingRosterAndAllocationsReplayAsStructuredJsonValues() throws Exception {
var original = new EventInput(UUID.randomUUID(), UUID.randomUUID(), Instant.parse("2026-09-26T00:00:00Z"),
"adventure.items_submitted", null, Map.of("roster", List.of("first", "second"),
"allocations", List.of(Map.of("character_id", "first", "xp", "2", "coins", "0"),
Map.of("character_id", "second", "xp", "0", "coins", "0"))));
var failures = new ArrayList<Throwable>();
var writer = new FeatureEventOutbox(data, "fishing-event-intents",
Set.of("adventure.items_submitted"), event -> CompletableFuture.completedFuture(LocalEventLog.Delivery.REJECTED),
failures::add);
writer.accepted(original);
writer.close();
writer.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
assertTrue(failures.isEmpty(), failures.toString());
var exported = new ArrayList<EventInput>();
var reader = new FeatureEventOutbox(data, "fishing-event-intents",
Set.of("adventure.items_submitted"), event -> {
exported.add(event);
return CompletableFuture.completedFuture(LocalEventLog.Delivery.EXPORTED);
}, failures::add);
reader.replay();
reader.close();
reader.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
assertTrue(failures.isEmpty(), failures.toString());
assertEquals(List.of(original), exported);
}
}