Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5923fcaf75 | ||
|
|
ec46e667f1 | ||
|
|
04101d4565 | ||
|
|
71839364a2 | ||
|
|
9425103745 | ||
|
|
8f2501f1c0 | ||
|
|
2f7a72355d | ||
|
|
01fd2f10ff |
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&¤t.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())&§ion(giver.x())==chunk.getX()&§ion(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&¤t.active.equals(context.active)&¤t.character.equals(context.character)&&(type==AdventureQuestState.UnitType.SOLO||current.leader&&Objects.equals(current.partyId,context.partyId)&¤t.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&¤t.active.equals(context.active)&¤t.character.equals(context.character)&&(type==AdventureQuestState.UnitType.SOLO||current.leader&&Objects.equals(current.partyId,context.partyId)&¤t.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&¤t.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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user