Compare commits

...
8 Commits
Author SHA1 Message Date
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
35 changed files with 4836 additions and 168 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;
@@ -666,7 +677,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
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.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 +699,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 +751,13 @@ 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());
fishingAuditEvents = new games.dmg.fantasystick.events.FishingEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
failure -> getLogger().log(Level.WARNING, "Fishing event intent failed", failure));
adventureQuestCommands.attachFishingEvents(fishingAuditEvents);
adventureQuestController.attachFishingEvents(fishingAuditEvents);
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 +802,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 +837,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 +881,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 +967,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 +1022,11 @@ 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();
});
recipeEvents.replay(); // Serial worker replays old intents before new discoveries are captured.
}
getServer().getPluginManager().registerEvents(recipeDiscovery, this);
@@ -990,14 +1065,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 +1395,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 +1484,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,66 @@ 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("champion_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());
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 +202,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,55 @@ 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.championEarned(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.championEarned(trophy.earned());
});
}
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 +81,273 @@ 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&&captured.type()==FishingState.TrophyType.CHAMPION&&fishingEvents!=null){
var data=new LinkedHashMap<String,Object>();data.put("receipt_id",captured.id().toString());
data.put("trophy_type","champion_trophy");data.put("trophy_name",captured.name());
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);
}));
}
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 +359,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) {
@@ -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(); }
}
@@ -134,6 +134,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 +190,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,6 +214,30 @@ 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) {
try { sink.apply(event).whenComplete((delivery, failure) -> {
if (failure != null) { report(failure); return; }
@@ -0,0 +1,261 @@
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");
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 champion outcome solely from the saved receipt, including after a prepared retry. */
public void championEarned(FishingState.Trophy receipt) {
if (receipt.type() != FishingState.TrophyType.CHAMPION
|| 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", "champion_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());
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"));
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);
}
private 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(); }
}
@@ -247,7 +247,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");
@@ -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;
}
}
@@ -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);
@@ -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,138 @@
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 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.championEarned(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 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,387 @@ 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 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();
@@ -0,0 +1,45 @@
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 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);
}
}