Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
185de89c85 | ||
|
|
618a6a2776 | ||
|
|
e308f2f884 | ||
|
|
cf2e013f74 | ||
|
|
720fdc1687 | ||
|
|
4d7ce211f5 | ||
|
|
bca3033fd9 | ||
|
|
1e3c7f28e2 | ||
|
|
dc68811a56 | ||
|
|
afc596e727 | ||
|
|
421e44d86f | ||
|
|
871258070b | ||
|
|
cbd0f70a8b | ||
|
|
7b588e08d2 | ||
|
|
86f248d80c |
No files matched your search
@@ -136,10 +136,12 @@ Optional `character-swap-cooldown-seconds` defaults to `900`; zero bypasses the
|
||||
|
||||
Administration (permission `fantasystick.admin`, default operators):
|
||||
|
||||
- `/fantasystick character view <online-player>` — opens the exact online account's current active character as a complete read-only virtual book for an in-game administrator.
|
||||
- `/fantasystick inspect <player-uuid>` — asynchronous receipt/native-session inspection.
|
||||
- `/fantasystick recover <player-uuid>` — validated online equipment/game-mode restoration only; no forced overwrite, progression grant or ambiguous replay.
|
||||
- `/fantasystick config adventure-mode [true|false]` — show or persist the policy for new sessions; no change to active sessions.
|
||||
- `/fstickadmin character find <name...>` and `character show <character-uuid>` — asynchronously locate authoritative saved characters and inspect one exact UUID read-only.
|
||||
- `/fstickadmin character view <online-player>` — opens the exact online account's current active character as a complete read-only virtual book for an in-game administrator.
|
||||
- `/fstickadmin character recover <character-uuid> <online-player>` — previews and explicitly confirms a generation-revoking reissue of the same saved character; dead characters use a distinct no-Tail revival action, and unresolved native delivery remains fail-closed.
|
||||
- `/fstickadmin inspect <player-uuid>` — asynchronous receipt/native-session inspection.
|
||||
- `/fstickadmin recover <player-uuid>` — validated online equipment/game-mode restoration only; no forced overwrite, progression grant or ambiguous replay.
|
||||
- `/fstickadmin config adventure-mode [true|false]` — show or persist the policy for new sessions; no change to active sessions.
|
||||
|
||||
Tests use actual native items, inventories, serialization, Paper event dispatch and menu-close behavior, with explicit scheduler/player/world ports. Drag continuation ordering includes a model based on pinned bytecode. The packaged lifecycle fixture isolates the entrypoint but delegates helpers parent-first. Live clients, production PluginClassLoader execution and arbitrary-plugin/crash atomicity are not claimed verified. No deployment or existing-data migration is authorized by story completion.
|
||||
|
||||
@@ -167,7 +169,7 @@ A same-process retry can acknowledge gear already verified restored for that exa
|
||||
|
||||
## Native fantasy health, exit and defeat
|
||||
|
||||
Offhand entry applies actual native maximum health `min(10 + CON, native health limit)`, resumes saved remaining character health, and supplies the recurring blue active-character aura to native viewers who opt in with `/fantasyparticles players on`. The account-owned aura preference defaults to off; `/fantasyparticles players` reports it and `/fantasyparticles players off` hides only this recurring aura. The independent, default-on `/fantasyparticles all [on|off]` preference durably controls every explicit Fantasy Stick particle for that account without changing nearby players' delivery, sounds or gameplay. Turning `all` off temporarily takes precedence without overwriting the saved `players` choice. A fresh character's saved health starts full. CON has no gameplay cap; high values do not reject entry. Native damage/healing rules remain in effect, with the existing party/mixed-mode protection. The book reports current native health rather than waiting for an exit save.
|
||||
Offhand entry applies actual native maximum health `min(10 + CON, native health limit)`, resumes saved remaining character health, and supplies the recurring blue active-character aura to native viewers who opt in with `/fstick particles players on`. The account-owned aura preference defaults to off; `/fstick particles players` reports it and `/fstick particles players off` hides only this recurring aura. The independent, default-on `/fstick particles all [on|off]` preference durably controls every explicit Fantasy Stick particle for that account without changing nearby players' delivery, sounds or gameplay. Turning `all` off temporarily takes precedence without overwriting the saved `players` choice. A fresh character's saved health starts full. CON has no gameplay cap; high values do not reject entry. Native damage/healing rules remain in effect, with the existing party/mixed-mode protection. The book reports current native health rather than waiting for an exit save.
|
||||
|
||||
`NativeMaximumHealth` owns only the effective-value cache/dirty callback of that player's existing native maximum-health instance. Base values, modifiers and native potion expiry remain owned by their original sources; nothing is copied back from an old modifier snapshot. The native player health methods and client attribute snapshot are tested. This adapter uses **pinned Purpur 2618 internals**, including private fields; a detached startup probe exercises installation, recalculation and removal before gameplay is registered. Runtime upgrades require renewed native compatibility verification, not a guessed fallback or global attribute-limit changes. Matching native health scaling displays the real fantasy maximum, not a separate virtual health pool; a later unrelated display-scale change is preserved on exit.
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private org.bukkit.scheduler.BukkitTask armorMenuPulse;
|
||||
private games.dmg.fantasystick.party.PartyCombat partyCombat;
|
||||
private EquipmentAdminCommand admin;
|
||||
private games.dmg.fantasystick.session.AdminCharacterRecovery characterRecovery;
|
||||
private games.dmg.fantasystick.persistence.PlayerFeedbackPreferences feedbackPreferences;
|
||||
private games.dmg.fantasystick.session.FantasyFeedbackCommand feedbackCommand;
|
||||
private games.dmg.fantasystick.session.FantasyParticlesCommand particlesCommand;
|
||||
@@ -126,6 +127,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.adventure.AdventureQuestDirectory adventureQuests;
|
||||
private games.dmg.fantasystick.adventure.AdventureQuestController adventureQuestController;
|
||||
private games.dmg.fantasystick.adventure.AdventureQuestCommands adventureQuestCommands;
|
||||
private games.dmg.fantasystick.adventure.FishingDirectory fishing;
|
||||
private games.dmg.fantasystick.adventure.FishingController fishingController;
|
||||
private games.dmg.fantasystick.dungeon.DungeonDirectory dungeons;
|
||||
private games.dmg.fantasystick.dungeon.DungeonController dungeonController;
|
||||
private games.dmg.fantasystick.dungeon.DungeonCommands dungeonCommands;
|
||||
@@ -318,12 +321,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
new games.dmg.fantasystick.persistence.YamlPartyRegistrarRepository(getDataFolder().toPath()));
|
||||
trainingDummies = new games.dmg.fantasystick.training.TrainingDummyDirectory(
|
||||
new games.dmg.fantasystick.persistence.YamlTrainingDummyRepository(getDataFolder().toPath()));
|
||||
var adventureRepository=new games.dmg.fantasystick.persistence.YamlAdventureQuestRepository(getDataFolder().toPath());
|
||||
adventureQuests = new games.dmg.fantasystick.adventure.AdventureQuestDirectory(
|
||||
new games.dmg.fantasystick.persistence.YamlAdventureQuestRepository(getDataFolder().toPath()),
|
||||
Clock.systemUTC(), adventureQuestSettings::rules, java.util.random.RandomGenerator.getDefault());
|
||||
adventureRepository, Clock.systemUTC(), adventureQuestSettings::rules, java.util.random.RandomGenerator.getDefault());
|
||||
fishing = new games.dmg.fantasystick.adventure.FishingDirectory(
|
||||
new games.dmg.fantasystick.persistence.YamlFishingRepository(getDataFolder().toPath()));
|
||||
dungeons = new games.dmg.fantasystick.dungeon.DungeonDirectory(
|
||||
new games.dmg.fantasystick.persistence.YamlDungeonRepository(getDataFolder().toPath()),
|
||||
() -> adventureQuestSettings.rules().dungeonRoomLinkDistance());
|
||||
if(adventureRepository.requiresDungeonRewardMigration())adventureQuests.migrateLegacyDungeonRewards(id->dungeons.find(id).flatMap(value->value.rooms().stream().filter(room->room.boss()!=null).findFirst().map(room->new games.dmg.fantasystick.adventure.AdventureQuestDirectory.LegacyDungeonReward(new games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward(value.completionReward().xp(),value.completionReward().coins()),room.id(),dungeons.runView(id,adventureQuests.snapshot().catalog()==null?new java.util.UUID(0,0):adventureQuests.snapshot().catalog().revision()).map(games.dmg.fantasystick.dungeon.DungeonState.Run::defeated).orElse(false))))).join();
|
||||
armorers=new games.dmg.fantasystick.shop.FantasyArmorerDirectory(new games.dmg.fantasystick.persistence.YamlFantasyArmorerRepository(getDataFolder().toPath()));
|
||||
} catch (java.io.IOException invalidManagedState) {
|
||||
throw new java.io.UncheckedIOException("Persistent party, shop, training or adventure state could not be loaded", invalidManagedState);
|
||||
@@ -559,19 +565,28 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
Clock.systemUTC(), java.util.random.RandomGenerator.getDefault(), particleDelivery);
|
||||
adventureQuestCommands = new games.dmg.fantasystick.adventure.AdventureQuestCommands(
|
||||
this, adventureQuests, adventureQuestController, nextTick);
|
||||
fishing.registerLabels(value->adventureQuests.findSite(value.site()).map(games.dmg.fantasystick.adventure.AdventureQuestState.Site::fishing).filter(java.util.Objects::nonNull).filter(definition->definition.itemId().equals(value.item())).map(games.dmg.fantasystick.adventure.AdventureQuestState.FishingDefinition::itemName).orElse(value.name()));
|
||||
fishingController=new games.dmg.fantasystick.adventure.FishingController(this,adventureQuests,fishing,
|
||||
adventureQuestSettings,sessions,parties,characters,rules,nextTick,java.util.random.RandomGenerator.getDefault(),
|
||||
failure->getLogger().log(Level.WARNING,"Fishing "+failure.phase()+" failed for character "+failure.character()+"; operation "+failure.operation(),failure.cause()));
|
||||
adventureQuestController.registerFishing(fishingController);adventureQuestController.registerResetParticipant(fishingController);books.attachFishingInventory(fishing);
|
||||
sessionEvents.attachNativeUseControl(fishingController::nativeUse);
|
||||
var bossRecoverySettings=outOfCombatRecoverySettings;var bossQuickerRegeneration=quickerRegeneration;var bossQuickerRegenerationII=quickerRegenerationII;var bossHealthStealSettings=healthStealSettings;
|
||||
dungeonController = new games.dmg.fantasystick.dungeon.DungeonController(this,dungeons,adventureQuests,sessions,parties,characters,rules,nextTick,java.util.random.RandomGenerator.getDefault(),abilityRegistry,passiveRegistry,bossRecoverySettings::settings,record->{if(!record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationI.ID))return java.math.BigInteger.ZERO;var bonus=bossQuickerRegeneration.bonus();return record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationII.ID)?bonus.add(bossQuickerRegenerationII.bonus()):bonus;},()->bossHealthStealSettings.settings().healing(),particleDelivery);
|
||||
dungeonController.registerFishing(fishingController);
|
||||
var custodyDirectory=dungeons;
|
||||
sessions.attachUnavailableCharacter(character->custodyDirectory.custody(character).isPresent());
|
||||
characters.attachUnavailableCharacter(character->custodyDirectory.custody(character).isPresent());
|
||||
characterParties.attachUnavailableCharacter(character->custodyDirectory.custody(character).isPresent());
|
||||
dungeonCommands = new games.dmg.fantasystick.dungeon.DungeonCommands(this,dungeons,dungeonController,adventureQuests,nextTick,characters::find,character->sessions.activeCharacter(character).isPresent(),character->characterParties.partyOf(character).isPresent());
|
||||
adventureQuests.registerDungeonRewards(site->dungeons.find(site.id()).map(value->new games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward(value.maximumXp(),value.maximumCoins())).orElse(new games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward(java.math.BigInteger.ZERO,java.math.BigInteger.ZERO)));
|
||||
adventureQuests.registerDungeonSites(()->dungeons.snapshot().dungeons().stream().filter(value->dungeons.eligibleForFutureOffer(value.id())).map(value->new games.dmg.fantasystick.adventure.AdventureQuestState.Site(value.id(),games.dmg.fantasystick.adventure.AdventureQuestState.SiteType.DUNGEON,value.name(),value.world(),value.entrance().x(),value.entrance().y(),value.entrance().z())).toList());
|
||||
adventureQuests.registerDungeonSites(()->dungeons.snapshot().dungeons().stream().filter(value->dungeons.eligibleForFutureOffer(value.id())).map(value->new games.dmg.fantasystick.adventure.AdventureQuestState.Site(value.id(),games.dmg.fantasystick.adventure.AdventureQuestState.SiteType.DUNGEON,value.name(),value.world(),value.entrance().x(),value.entrance().y(),value.entrance().z(),null,value.participationMode())).toList());
|
||||
adventureQuests.registerDungeonAvailability(site->{var definition=dungeons.find(site.id());if(definition.isEmpty()||!definition.orElseThrow().enabled())return false;var catalog=adventureQuests.snapshot().catalog();return catalog==null||dungeons.runView(site.id(),catalog.revision()).map(run->!run.defeated()).orElse(true);});
|
||||
adventureQuestController.registerResetParticipant(dungeonController);
|
||||
adventureQuestController.registerDungeonRoomPresence((dungeon,location)->location.getWorld()!=null&&dungeons.find(dungeon).filter(value->value.world().equals(location.getWorld().getUID())).isPresent()&&dungeons.roomAt(dungeon,location.getBlockX(),location.getBlockY(),location.getBlockZ()).isPresent());
|
||||
combatExperience.attachExcluded(entity->adventureQuestController.managed(entity)||dungeonController.managed(entity));
|
||||
combatExperience.attachManagedOrigins(entity->adventureQuestController.encounterOrigin(entity).or(()->dungeonController.encounterOrigin(entity)));
|
||||
combatExperience.attachManagedSettlement(dungeonController::settleKill);
|
||||
combatExperience.attachExcluded(adventureQuestController::managed);
|
||||
sessionEvents.attachManagedInteractionTarget(entity -> partyRegistrarController.tagged(entity)
|
||||
|| armorerController.tagged(entity) || adventureQuestController.giver(entity));
|
||||
admin = new EquipmentAdminCommand(receipts, sessions, getServer(), nextTick,
|
||||
@@ -618,22 +633,28 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
() -> factory.create(rules), nextTick, problem -> getLogger().log(Level.WARNING,
|
||||
"Admin character issue " + problem.operation() + " for player " + problem.player()
|
||||
+ "; character " + problem.character(), problem.cause())));
|
||||
admin.attachCharacterBooks(books);
|
||||
var command = java.util.Objects.requireNonNull(getCommand("fantasystick"), "Missing packaged fantasystick command");
|
||||
command.setExecutor(admin);
|
||||
command.setTabCompleter(admin);
|
||||
var playerFeedbackCommand = java.util.Objects.requireNonNull(getCommand("fantasyfeedback"),
|
||||
"Missing packaged fantasyfeedback command");
|
||||
playerFeedbackCommand.setExecutor(feedbackCommand);
|
||||
playerFeedbackCommand.setTabCompleter(feedbackCommand);
|
||||
var playerParticlesCommand = java.util.Objects.requireNonNull(getCommand("fantasyparticles"),
|
||||
"Missing packaged fantasyparticles command");
|
||||
playerParticlesCommand.setExecutor(particlesCommand);
|
||||
playerParticlesCommand.setTabCompleter(particlesCommand);
|
||||
admin.attachCharacterBooks(books, characters);
|
||||
characterRecovery = new games.dmg.fantasystick.session.AdminCharacterRecovery(
|
||||
characters, receipts, revivals, sessions, getServer(), nextTick,
|
||||
problem -> getLogger().log(Level.WARNING,
|
||||
"Saved character recovery " + problem.operation() + " for character "
|
||||
+ problem.character() + " and recipient " + problem.recipient(), problem.cause()));
|
||||
admin.attachCharacterRecovery(characterRecovery);
|
||||
var playerRoot = new games.dmg.fantasystick.session.FantasyPlayerCommand(
|
||||
admin, feedbackCommand, particlesCommand);
|
||||
var playerCommand = java.util.Objects.requireNonNull(getCommand("fstick"),
|
||||
"Missing packaged fstick command");
|
||||
playerCommand.setExecutor(playerRoot);
|
||||
playerCommand.setTabCompleter(playerRoot);
|
||||
var adminCommand = java.util.Objects.requireNonNull(getCommand("fstickadmin"),
|
||||
"Missing packaged fstickadmin command");
|
||||
adminCommand.setExecutor(admin);
|
||||
adminCommand.setTabCompleter(admin);
|
||||
getServer().getPluginManager().registerEvents(crafting, this);
|
||||
getServer().getPluginManager().registerEvents(guard, this);
|
||||
getServer().getPluginManager().registerEvents(anvilNames, this);
|
||||
getServer().getPluginManager().registerEvents(sessionEvents, this);
|
||||
getServer().getPluginManager().registerEvents(characterRecovery, this);
|
||||
getServer().getPluginManager().registerEvents(chatNames, this);
|
||||
getServer().getPluginManager().registerEvents(partyEvents, this);
|
||||
getServer().getPluginManager().registerEvents(partyCombat, this);
|
||||
@@ -643,12 +664,14 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getServer().getPluginManager().registerEvents(armorerController,this);
|
||||
getServer().getPluginManager().registerEvents(armorShopMenus,this);
|
||||
getServer().getPluginManager().registerEvents(adventureQuestController,this);
|
||||
getServer().getPluginManager().registerEvents(fishingController,this);
|
||||
getServer().getPluginManager().registerEvents(dungeonController,this);
|
||||
partyRegistrarController.install();
|
||||
trainingDummyController.install();
|
||||
armorerController.install();
|
||||
dungeonController.install();
|
||||
adventureQuestController.install();
|
||||
fishingController.install();
|
||||
var localArmorMarket=armorMarket;
|
||||
armorMarketPulse=getServer().getScheduler().runTaskTimer(this,()->{if(lifetime!=generation)return;
|
||||
localArmorMarket.current().whenComplete((catalog,failure)->{
|
||||
@@ -711,6 +734,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingProtocol = protocolPresentation; protocolPresentation = null;
|
||||
var closingChat = chatNames; chatNames = null;
|
||||
var closingAdmin = admin; admin = null;
|
||||
var closingCharacterRecovery = characterRecovery; characterRecovery = null;
|
||||
var closingFeedbackCommand = feedbackCommand; feedbackCommand = null;
|
||||
var closingParticlesCommand = particlesCommand; particlesCommand = null;
|
||||
var closingFeedbackPreferences = feedbackPreferences; feedbackPreferences = null;
|
||||
@@ -729,6 +753,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingAdventureQuests = adventureQuests; adventureQuests = null;
|
||||
var closingAdventureQuestController = adventureQuestController; adventureQuestController = null;
|
||||
adventureQuestCommands = null;
|
||||
var closingFishing=fishing;fishing=null;var closingFishingController=fishingController;fishingController=null;
|
||||
var closingDungeons=dungeons;dungeons=null;var closingDungeonController=dungeonController;dungeonController=null;dungeonCommands=null;
|
||||
var closingPartyRegistrarMenus = partyRegistrarMenus; partyRegistrarMenus = null;
|
||||
var closingPartyManagement = partyManagement; partyManagement = null;
|
||||
@@ -862,6 +887,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (closingAdventureQuestSettings != null) cleanup("adventure quest configuration", closingAdventureQuestSettings::close);
|
||||
if (closingAdventureQuests != null) cleanup("adventure quest persistence", closingAdventureQuests::close);
|
||||
if (closingAdventureQuestController != null) cleanup("adventure quests", closingAdventureQuestController::close);
|
||||
if(closingFishingController!=null)cleanup("fishing",closingFishingController::close);
|
||||
if(closingFishing!=null)cleanup("fishing persistence",closingFishing::close);
|
||||
if (closingDungeonController != null) cleanup("dungeons", closingDungeonController::close);
|
||||
if (closingDungeons != null) cleanup("dungeon persistence", closingDungeons::close);
|
||||
if (closingSheetResourcePackSettings != null) cleanup("sheet resource-pack configuration", closingSheetResourcePackSettings::close);
|
||||
@@ -892,6 +919,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
: closingAdventureQuestSettings.drained().toCompletableFuture(),
|
||||
closingAdventureQuests == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingAdventureQuests.drained().toCompletableFuture(),
|
||||
closingFishing==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingFishing.drained().toCompletableFuture(),
|
||||
closingDungeons == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingDungeons.drained().toCompletableFuture(),
|
||||
closingSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
@@ -1003,6 +1031,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
unregister(closingPartyRegistrarMenus);
|
||||
unregister(closingArmorerController);unregister(closingArmorShopMenus);
|
||||
unregister(closingAdventureQuestController);
|
||||
unregister(closingFishingController);
|
||||
unregister(closingDungeonController);
|
||||
unregister(closingCombatExperience);
|
||||
unregister(closingHealthSteal);
|
||||
@@ -1017,6 +1046,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
unregister(closingGreatSword);
|
||||
unregister(closingRevive);
|
||||
unregister(closingAdminRevival);
|
||||
unregister(closingCharacterRecovery);
|
||||
unregister(closingBasicAttack);
|
||||
unregister(closingNutrition);
|
||||
unregister(closingOutOfCombatRecovery);
|
||||
|
||||
@@ -270,7 +270,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
try {
|
||||
var provisional = new CharacterRecord(dead.id(), dead.name(), dead.revision(), dead.stats(), dead.level(), dead.experience(),
|
||||
dead.unspentPoints(), dead.maximumHealth(), dead.learned(), dead.loadout(), dead.passives(), dead.cooldowns(), dead.restrictedKills(),
|
||||
false, dead.deathId(), dead.diedAt());
|
||||
false, dead.deathId(), dead.diedAt(), dead.sheetGeneration());
|
||||
ItemStack sheet = sheets.create(provisional);
|
||||
ItemStack source = view.target.getInventory().getItem(reservation.sourceSlot());
|
||||
if (reservation.sourceAmount() == 1) view.target.getInventory().setItem(reservation.sourceSlot(), sheet);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package games.dmg.fantasystick.adventure;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.*;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.command.CommandSender;
|
||||
@@ -11,22 +12,33 @@ 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);}
|
||||
public void adventure(CommandSender sender,String[] args){if(args.length==1){if(sender instanceof Player player)controller.inspect(player);else sender.sendMessage("Usage: /fantasystick 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 /fantasystick adventure reroll confirm to reset the day.");return;}if(args.length!=3||!args[1].equals("reroll")||!args[2].equals("confirm")){sender.sendMessage("Usage: /fantasystick adventure [tracking [on|off]|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()));}case"place"->{if(args.length<3||!(args[2].equals("camp")||args[2].equals("champion"))||!(sender instanceof Player player)){usageSite(sender);return;}var placement=placement(args);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()).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()+".":"Adventure site save failed; desired state is unchanged.")));}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 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 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("list","place","remove","teleport"):List.of("list","remove"),args[1]);if(args.length==3&&args[0].equals("adventure-site")&&args[1].equals("place"))return matching(List.of("camp","champion"),args[2]);if(args.length==4&&args[0].equals("adventure-site")&&args[1].equals("place")&&(args[2].equals("camp")||args[2].equals("champion")))return matching(List.of("4","16","64"),args[3]);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==3&&args[0].equals("adventure-site")&&Set.of("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==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();}
|
||||
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 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;}
|
||||
private static java.math.BigDecimal percent(String value){if(!value.matches("(?:0|[1-9][0-9]*)(?:\\.[0-9]{1,6})?"))throw new IllegalArgumentException();var result=new java.math.BigDecimal(value);if(result.compareTo(java.math.BigDecimal.valueOf(100))>0)throw new IllegalArgumentException();return result;}
|
||||
private static BigInteger positive(String value){var result=nonnegative(value);if(result.signum()==0)throw new IllegalArgumentException();return result;}
|
||||
private static BigInteger nonnegative(String value){if(!value.matches("(?:0|[1-9][0-9]*)"))throw new IllegalArgumentException();var result=new BigInteger(value);if(result.compareTo(BigInteger.valueOf(Integer.MAX_VALUE))>0)throw new IllegalArgumentException();return result;}
|
||||
private static int bounded(String value,int minimum,int maximum){var parsed=nonnegative(value);if(parsed.compareTo(BigInteger.valueOf(minimum))<0||parsed.compareTo(BigInteger.valueOf(maximum))>0)throw new IllegalArgumentException();return parsed.intValueExact();}
|
||||
private static List<String> fishingOptionCompletion(String[] args){String current=args[args.length-1];if(args.length>=2){String previous=args[args.length-2];if(previous.equals("--mode"))return matching(modeValues(),current);if(previous.equals("--radius"))return matching(List.of("4","16","64"),current);if(previous.equals("--chance"))return matching(List.of("0","20","100"),current);if(previous.equals("--required"))return matching(List.of("1","5","2147483647"),current);if(previous.equals("--xp")||previous.equals("--coins"))return matching(List.of("0","10","100","2147483647"),current);}var used=Arrays.stream(args).filter(value->value.startsWith("--")).collect(java.util.stream.Collectors.toSet());return matching(List.of("--chance","--coins","--item","--mode","--name","--radius","--required","--xp").stream().filter(value->!used.contains(value)).toList(),current);}
|
||||
private Optional<AdventureQuestState.Site> site(UUID id){return directory.findSite(id).filter(value->value.type()!=AdventureQuestState.SiteType.DUNGEON);}
|
||||
private static AdventureAdminTeleport.Target target(AdventureQuestState.Site site){return new AdventureAdminTeleport.Target(site.id(),site.displayName(),site.type()==AdventureQuestState.SiteType.CAMP?"monster camp":"champion",site.world(),site.x(),site.y(),site.z(),0,0,true);}
|
||||
private static AdventureAdminTeleport.Target target(AdventureQuestState.Site site){return new AdventureAdminTeleport.Target(site.id(),site.displayName(),switch(site.type()){case CAMP->"monster camp";case CHAMPION->"champion";case FISHING->"fishing site";case DUNGEON->"dungeon entrance";},site.world(),site.x(),site.y(),site.z(),0,0,true);}
|
||||
private boolean authorized(CommandSender sender){if(sender.hasPermission(PERMISSION))return true;sender.sendMessage("Permission denied: you need fantasystick.admin to administer Fantasy Stick.");return false;}
|
||||
private void respond(CommandSender sender,Runnable response){try{nextTick.accept(()->{if(sender.hasPermission(PERMISSION)&&(!(sender instanceof Player player)||player.isOnline()))response.run();});}catch(RuntimeException ignored){}}
|
||||
private record Placement(String name,AdventureQuestState.Arena arena){}
|
||||
private static Placement placement(String[] args){if(args.length==3)return new Placement("",null);Integer first=whole(args[3]);if(first==null)return looksNumeric(args[3])?null:new Placement(join(args,3),null);if(args.length>4){Integer second=whole(args[4]);if(second!=null)return first>=8&&first<=128&&second>=8&&second<=128?new Placement(join(args,5),AdventureQuestState.Arena.rectangle(first,second)):null;if(looksNumeric(args[4]))return null;}return first>=4&&first<=64?new Placement(join(args,4),AdventureQuestState.Arena.circle(first)):null;}
|
||||
private static Placement placement(String[] args,int start){if(args.length==start)return new Placement("",null);if(Arrays.stream(args,start,args.length).anyMatch(value->value.startsWith("--")))return null;Integer first=whole(args[start]);if(first==null)return looksNumeric(args[start])||args[start].startsWith("--")?null:new Placement(join(args,start),null);if(args.length>start+1){Integer second=whole(args[start+1]);if(second!=null)return first>=8&&first<=128&&second>=8&&second<=128?new Placement(join(args,start+2),AdventureQuestState.Arena.rectangle(first,second)):null;if(looksNumeric(args[start+1]))return null;}return first>=4&&first<=64?new Placement(join(args,start+1),AdventureQuestState.Arena.circle(first)):null;}
|
||||
private static List<String> modeValues(){return Arrays.stream(AdventureQuestState.ParticipationMode.values()).map(AdventureQuestState.ParticipationMode::value).toList();}
|
||||
private static Integer whole(String value){try{return Integer.valueOf(value);}catch(NumberFormatException invalid){return null;}}
|
||||
private static boolean looksNumeric(String value){return value.matches("[+-]?(?:\\d.*|\\.\\d+)");}
|
||||
private static String join(String[] args,int start){return start>=args.length?"":String.join(" ",Arrays.copyOfRange(args,start,args.length));}
|
||||
private static UUID parse(String value){try{var id=UUID.fromString(value);return id.toString().equals(value)?id:null;}catch(IllegalArgumentException invalid){return null;}}
|
||||
private static int section(double coordinate){return((int)Math.floor(coordinate))>>4;}
|
||||
private static List<String> matching(Collection<String> values,String prefix){return values.stream().filter(v->v.startsWith(prefix)).distinct().sorted().toList();}
|
||||
private static void usageSite(CommandSender sender){sender.sendMessage("Usage: /fantasystick adventure-site <place <camp|champion> [<radius>|<x-blocks> <z-blocks>] [name...]|remove <site-uuid>|teleport <site-uuid>|list>");}
|
||||
private static void usageGiver(CommandSender sender){sender.sendMessage("Usage: /fantasystick quest-giver <place [name...]|remove <giver-uuid>|list>");}
|
||||
private static void usageSite(CommandSender sender){sender.sendMessage("Usage: /fstickadmin adventure-site <place <camp|champion> [--mode <mode>] [<radius>|<x-blocks> <z-blocks>] [name...]|place fishing --name <name> --item <item> --required <quantity> --chance <percent> --xp <xp> --coins <coins> [--radius <blocks>] [--mode <mode>]|fishing <site-uuid> [options]|mode <site-uuid> [<mode>]|remove <site-uuid>|teleport <site-uuid>|list>");}
|
||||
private static void usageGiver(CommandSender sender){sender.sendMessage("Usage: /fstickadmin quest-giver <place [name...]|remove <giver-uuid>|list>");}
|
||||
}
|
||||
@@ -34,7 +34,8 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
|
||||
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
|
||||
private final AdventureQuestNavigation navigation;private final EquipmentSessions sessions;private final PartyRoster parties;private final CharacterStorage characters;private final ProgressionRules progression;
|
||||
private final Consumer<Runnable> nextTick;private final Clock clock;private final java.util.random.RandomGenerator random;
|
||||
private final NamespacedKey typeKey,idKey,siteKey,catalogKey,menuSiteKey,menuCatalogKey,menuPageKey;
|
||||
private final NamespacedKey typeKey,idKey,siteKey,catalogKey,attemptKey,encounterKindKey,menuSiteKey,menuCatalogKey,menuPageKey,menuFishingKey;
|
||||
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<>();
|
||||
private final List<AdventureResetParticipant> resetParticipants=new ArrayList<>();private CompletableFuture<Boolean> resetWork;
|
||||
@@ -43,34 +44,37 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
|
||||
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;}}
|
||||
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");menuSiteKey=new NamespacedKey(plugin,"quest_offer_site");menuCatalogKey=new NamespacedKey(plugin,"quest_offer_catalog");menuPageKey=new NamespacedKey(plugin,"quest_offer_page");}
|
||||
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 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);}
|
||||
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.max(9,((count+8)/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 (maximum)";};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?"Maximum reward: ":"Reward: ";meta.lore(List.of(net.kyori.adventure.text.Component.text(kind),net.kyori.adventure.text.Component.text(rewardLabel+rewardXp+" XP, "+rewardCoins+" coins"),net.kyori.adventure.text.Component.text("Expires: "+catalog.expiresAt()),net.kyori.adventure.text.Component.text(state)));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"));}player.openInventory(inventory);}));}
|
||||
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 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;}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;}if(context.partyId!=null&&!context.leader){player.sendMessage("Only the active party leader may accept a party quest.");return;}var type=context.partyId==null?AdventureQuestState.UnitType.SOLO:AdventureQuestState.UnitType.PARTY;var unit=context.partyId==null?context.character:context.partyId;directory.accept(catalog,site,type,unit,context.members).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 location=directory.findSite(site).orElseThrow();refreshTracking(player);player.sendMessage("Accepted "+location.displayName()+"; expires "+result.quest().orElseThrow().expiresAt()+". Follow the Quest Compass.");}));}
|
||||
@EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof MenuHolder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null)return;var data=event.getCurrentItem().getPersistentDataContainer();Integer page=data.get(menuPageKey,PersistentDataType.INTEGER);if(page!=null){open(player,page);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.");}));}
|
||||
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();}
|
||||
public void tracking(Player player,Boolean enabled){var context=context(player);if(context==null){player.sendMessage("Quest tracking refused: actively play a living fantasy character first.");return;}var quest=directory.activeFor(context.character,context.partyId).filter(value->value.snapshot().contains(context.character)).orElse(null);if(quest==null){player.sendMessage("Quest tracking refused: this character has no eligible active quest.");refreshTracking(player);return;}if(enabled==null){player.sendMessage("Quest tracking is "+(directory.trackingEnabled(quest.id(),context.character)?"on":"off")+" for the current adventure quest.");return;}directory.setTracking(quest.id(),context.character,enabled).whenComplete((status,failure)->schedule(()->{if(failure!=null||status==AdventureQuestDirectory.TrackingStatus.INELIGIBLE){player.sendMessage("Quest tracking change failed; retained state is unchanged.");return;}refreshTracking(player);player.sendMessage("Quest tracking is now "+(enabled?"on.":"off."));}));}
|
||||
public void inspect(Player player){var context=context(player);if(context==null){player.sendMessage("No active fantasy character.");return;}var quest=directory.activeFor(context.character,context.partyId).orElse(null);if(quest==null){player.sendMessage("No active adventure quest.");return;}var site=directory.findSite(quest.site()).orElse(null);if(site==null){player.sendMessage("Active quest site is unavailable.");return;}player.sendMessage(site.displayName()+"; state="+(attempts.containsKey(site.id())?"active":"waiting")+"; expires "+quest.expiresAt()+"; Quest Compass guidance="+(directory.trackingEnabled(quest.id(),context.character)?"on":"off")+".");}
|
||||
private void pulse(){if(closed)return;directory.ensureToday().whenComplete((catalog,failure)->{if(failure==null)schedule(()->{if(directory.snapshot().reset()!=null)publishPendingReset();});});if(directory.snapshot().reset()!=null){publishPendingReset();return;}directory.expireParties(parties::exists);refreshTrackingPlayers(true);showArenaBoundaries();for(var attempt:new ArrayList<>(attempts.values())){var catalog=directory.snapshot().catalog();if(catalog==null||!catalog.revision().equals(attempt.catalog)||!clock.instant().isBefore(catalog.expiresAt())){cleanup(attempt);attempts.remove(attempt.site);readyAt.remove(attempt.site);continue;}contain(attempt);syncBossBar(attempt);boolean present=eligibleInside(attempt.site,attempt.arena).findAny().isPresent();if(present)attempt.absentSince=null;else if(attempt.absentSince==null)attempt.absentSince=clock.instant();else if(!clock.instant().isBefore(attempt.absentSince.plusSeconds(attempt.resetSeconds))){cleanup(attempt);attempts.remove(attempt.site);readyAt.put(attempt.site,clock.instant());}}for(var quest:directory.active()){if(directory.findSite(quest.site()).map(AdventureQuestState.Site::type).orElse(null)==AdventureQuestState.SiteType.DUNGEON)continue;if(attempts.containsKey(quest.site())||starting.contains(quest.site()))continue;var ready=readyAt.get(quest.site());if(ready!=null&&clock.instant().isBefore(ready))continue;if(eligibleInside(quest.site()).findAny().isPresent())start(quest.site(),quest.catalog());}}
|
||||
private void pulse(){if(closed)return;directory.ensureToday().whenComplete((catalog,failure)->{if(failure==null)schedule(()->{if(directory.snapshot().reset()!=null)publishPendingReset();});});if(directory.snapshot().reset()!=null){publishPendingReset();return;}directory.expireParties(parties::exists);refreshTrackingPlayers(true);showArenaBoundaries();for(var attempt:new ArrayList<>(attempts.values())){var catalog=directory.snapshot().catalog();if(catalog==null||!catalog.revision().equals(attempt.catalog)||!clock.instant().isBefore(catalog.expiresAt())){cleanup(attempt);attempts.remove(attempt.site);readyAt.remove(attempt.site);continue;}contain(attempt);syncBossBar(attempt);boolean present=eligibleInside(attempt.site,attempt.arena).findAny().isPresent();if(present)attempt.absentSince=null;else if(attempt.absentSince==null)attempt.absentSince=clock.instant();else if(!clock.instant().isBefore(attempt.absentSince.plusSeconds(attempt.resetSeconds))){cleanup(attempt);attempts.remove(attempt.site);readyAt.put(attempt.site,clock.instant());}}for(var quest:directory.active()){var type=directory.findSite(quest.site()).map(AdventureQuestState.Site::type).orElse(null);if(type==AdventureQuestState.SiteType.DUNGEON||type==AdventureQuestState.SiteType.FISHING)continue;if(attempts.containsKey(quest.site())||starting.contains(quest.site()))continue;var ready=readyAt.get(quest.site());if(ready!=null&&clock.instant().isBefore(ready))continue;if(eligibleInside(quest.site()).findAny().isPresent())start(quest.site(),quest.catalog());}}
|
||||
private void showArenaBoundaries(){for(UUID siteId:directory.active().stream().map(AdventureQuestState.Quest::site).distinct().toList()){var site=directory.findSite(siteId).orElse(null);if(site==null||site.type()==AdventureQuestState.SiteType.DUNGEON)continue;var world=plugin.getServer().getWorld(site.world());if(world==null)continue;var center=new Location(world,site.x(),site.y(),site.z());var points=perimeter(arena(site));for(var player:plugin.getServer().getOnlinePlayers()){if(eligible(player,siteId)==null)continue;var viewer=player.getLocation();if(viewer.getWorld()==null||!viewer.getWorld().getUID().equals(site.world())||viewer.distanceSquared(center)>64d*64d)continue;for(var point:points)particles.viewer(player,Particle.END_ROD,new Location(world,site.x()+point[0],site.y()+0.2,site.z()+point[1]),1,0,0,0,0);}}}
|
||||
private static List<double[]> perimeter(AdventureQuestState.Arena arena){var result=new ArrayList<double[]>();if(arena.shape()==AdventureQuestState.ArenaShape.CIRCLE){int count=Math.min(256,Math.max(16,(int)Math.ceil(Math.PI*arena.xBlocks())));for(int index=0;index<count;index++){double angle=2*Math.PI*index/count;result.add(new double[]{Math.cos(angle)*arena.xBlocks(),Math.sin(angle)*arena.xBlocks()});}}else{double halfX=arena.xBlocks()/2d,halfZ=arena.zBlocks()/2d;edge(result,-halfX,halfX,-halfZ,true);edge(result,-halfX,halfX,halfZ,true);edge(result,-halfZ,halfZ,-halfX,false);edge(result,-halfZ,halfZ,halfX,false);}return result.size()<=256?List.copyOf(result):List.copyOf(result.subList(0,256));}
|
||||
private static void edge(List<double[]> result,double from,double to,double fixed,boolean xAxis){for(double value=from;value<to;value+=2)result.add(xAxis?new double[]{value,fixed}:new double[]{fixed,value});result.add(xAxis?new double[]{to,fixed}:new double[]{fixed,to});}
|
||||
@@ -78,17 +82,17 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
|
||||
private java.util.stream.Stream<Player> eligibleInside(UUID site){var value=directory.findSite(site).orElse(null);return value==null?java.util.stream.Stream.empty():eligibleInside(site,arena(value));}
|
||||
private java.util.stream.Stream<Player> eligibleInside(UUID site,AdventureQuestState.Arena arena){var value=directory.findSite(site).orElse(null);if(value==null)return java.util.stream.Stream.empty();return plugin.getServer().getOnlinePlayers().stream().map(Player.class::cast).filter(player->eligible(player,site)!=null).filter(player->inside(player.getLocation(),value,arena));}
|
||||
private AdventureQuestState.Quest eligible(Player player,UUID site){var context=context(player);if(context==null)return null;var quest=directory.activeFor(context.character,context.partyId).orElse(null);return quest!=null&&quest.site().equals(site)&&quest.snapshot().contains(context.character)?quest:null;}
|
||||
private void start(UUID site,UUID catalog){var value=directory.findSite(site).orElse(null);if(value==null||value.type()==AdventureQuestState.SiteType.DUNGEON)return;if(value.type()==AdventureQuestState.SiteType.CHAMPION){startChampion(value,catalog);return;}starting.add(site);directory.roster(site).whenComplete((roster,failure)->schedule(()->{starting.remove(site);if(failure!=null||closed||attempts.containsKey(site))return;var current=directory.findSite(site).orElse(null);if(current==null)return;var world=plugin.getServer().getWorld(current.world());if(world==null||!world.isChunkLoaded(section(current.x()),section(current.z())))return;var rules=settings.rules();var attempt=new Attempt(site,catalog,current.type(),arena(current),rules.resetSeconds());attempts.put(site,attempt);for(int index=0;index<roster.size();index++)spawnEnemy(world,current,roster.get(index),attempt,index);}));}
|
||||
private void start(UUID site,UUID catalog){var value=directory.findSite(site).orElse(null);if(value==null||value.type()==AdventureQuestState.SiteType.DUNGEON||value.type()==AdventureQuestState.SiteType.FISHING)return;if(value.type()==AdventureQuestState.SiteType.CHAMPION){startChampion(value,catalog);return;}starting.add(site);directory.roster(site).whenComplete((roster,failure)->schedule(()->{starting.remove(site);if(failure!=null||closed||attempts.containsKey(site))return;var current=directory.findSite(site).orElse(null);if(current==null)return;var world=plugin.getServer().getWorld(current.world());if(world==null||!world.isChunkLoaded(section(current.x()),section(current.z())))return;var rules=settings.rules();var attempt=new Attempt(site,catalog,current.type(),arena(current),rules.resetSeconds());attempts.put(site,attempt);for(int index=0;index<roster.size();index++)spawnEnemy(world,current,roster.get(index),attempt,index);}));}
|
||||
private void startChampion(AdventureQuestState.Site site,UUID catalog){if(closed||attempts.containsKey(site.id())||starting.contains(site.id()))return;var world=plugin.getServer().getWorld(site.world());if(world==null||!world.isChunkLoaded(section(site.x()),section(site.z())))return;starting.add(site.id());try{var rules=settings.rules();var attempt=new Attempt(site.id(),catalog,site.type(),arena(site),rules.resetSeconds());attempt.bossBar=plugin.getServer().createBossBar("Daybreak Champion",BarColor.RED,BarStyle.SOLID);attempts.put(site.id(),attempt);world.spawn(new Location(world,site.x(),site.y(),site.z()),Vindicator.class,champion->{configureEnemy(champion,attempt);var maximum=Objects.requireNonNull(champion.getAttribute(Attribute.MAX_HEALTH));maximum.setBaseValue(rules.championHealth());champion.setHealth(rules.championHealth());Objects.requireNonNull(champion.getAttribute(Attribute.ATTACK_DAMAGE)).setBaseValue(rules.championDamage());champion.customName(net.kyori.adventure.text.Component.text("Daybreak Champion"));champion.setCustomNameVisible(true);attempt.champion=champion;});syncBossBar(attempt);}catch(RuntimeException failure){var attempt=attempts.remove(site.id());if(attempt!=null)cleanup(attempt);plugin.getLogger().log(java.util.logging.Level.WARNING,"Champion attempt could not start at site "+site.id(),failure);}finally{starting.remove(site.id());}}
|
||||
private 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.setCanPickupItems(false);mob.setRemoveWhenFarAway(false);mob.setPersistent(true);mob.setFireTicks(0);if(mob instanceof Husk husk){husk.setShouldBurnInDay(false);husk.setCanBreakDoors(false);}if(mob instanceof Raider raider){raider.setPatrolLeader(false);raider.setCanJoinRaid(false);}attempt.entities.add(mob.getUniqueId());}
|
||||
private void configureEnemy(Mob mob,Attempt attempt){mob.getPersistentDataContainer().set(typeKey,PersistentDataType.STRING,ENEMY);mob.getPersistentDataContainer().set(siteKey,PersistentDataType.STRING,attempt.site.toString());mob.getPersistentDataContainer().set(catalogKey,PersistentDataType.STRING,attempt.catalog.toString());mob.getPersistentDataContainer().set(attemptKey,PersistentDataType.STRING,attempt.id.toString());var kind=attempt.type==AdventureQuestState.SiteType.CHAMPION?games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CHAMPION:games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CAMP;mob.getPersistentDataContainer().set(encounterKindKey,PersistentDataType.STRING,kind.name());mob.setCanPickupItems(false);mob.setRemoveWhenFarAway(false);mob.setPersistent(true);mob.setFireTicks(0);if(mob instanceof Husk husk){husk.setShouldBurnInDay(false);husk.setCanBreakDoors(false);}if(mob instanceof Raider raider){raider.setPatrolLeader(false);raider.setCanJoinRaid(false);}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.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);}
|
||||
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);} }
|
||||
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){var unit=new RewardAllocator.Unit<UUID>(quest.unit(),recipients);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);var rewards=new LinkedHashMap<UUID,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 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();}));}
|
||||
@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());}}
|
||||
@@ -111,7 +115,7 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
|
||||
private void refreshTrackingPlayers(){refreshTrackingPlayers(false);}
|
||||
private void refreshTrackingPlayers(boolean actionBar){for(var value:plugin.getServer().getOnlinePlayers())refreshTracking(value,actionBar);}
|
||||
private void refreshTracking(Player player){refreshTracking(player,false);}
|
||||
private void refreshTracking(Player player,boolean actionBar){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return;var record=sessions.currentRecord(active).orElse(null);if(record==null||record.dead()||player.isDead()){sessions.restoreBook(active);return;}var party=parties.partyOf(player.getUniqueId()).orElse(null);UUID partyId=party==null?null:party.id();var quest=directory.activeFor(record.id(),partyId).filter(value->value.snapshot().contains(record.id())).orElse(null);var site=quest==null?null:directory.findSite(quest.site()).filter(directory::available).orElse(null);if(quest==null||site==null||!directory.trackingEnabled(quest.id(),record.id())){sessions.restoreBook(active);return;}var location=player.getLocation().clone();boolean arrived=site.type()==AdventureQuestState.SiteType.DUNGEON?dungeonRoomPresence.test(site.id(),location):inside(location,site,arena(site));boolean published=sessions.presentBook(active,book->navigation.compass(book,quest,site,location));if(published&&actionBar)navigation.guidance(player,site,arrived).ifPresent(player::sendActionBar);}
|
||||
private void refreshTracking(Player player,boolean actionBar){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return;var record=sessions.currentRecord(active).orElse(null);if(record==null||record.dead()||player.isDead()||directory.snapshot().reset()!=null){sessions.restoreBook(active);return;}var party=parties.partyOf(player.getUniqueId()).orElse(null);UUID partyId=party==null?null:party.id();var quest=directory.activeFor(record.id(),partyId).filter(value->value.snapshot().contains(record.id())).orElse(null);var site=quest==null?null:directory.findSite(quest.site()).filter(directory::available).orElse(null);if(quest==null||site==null){sessions.restoreBook(active);return;}var location=player.getLocation().clone();boolean arrived=site.type()==AdventureQuestState.SiteType.DUNGEON?dungeonRoomPresence.test(site.id(),location):inside(location,site,site.type()==AdventureQuestState.SiteType.FISHING?AdventureQuestState.Arena.circle(quest.fishing().radius()):arena(site));if(site.type()==AdventureQuestState.SiteType.FISHING&&arrived){sessions.presentBook(active,book->navigation.fishingRod(book,quest,site));return;}if(!directory.trackingEnabled(quest.id(),record.id())){sessions.restoreBook(active);return;}boolean published=sessions.presentBook(active,book->navigation.compass(book,quest,site,location));if(published&&actionBar)navigation.guidance(player,site,arrived).ifPresent(player::sendActionBar);}
|
||||
private void schedule(Runnable task){if(closed)return;try{nextTick.accept(()->{if(!closed)task.run();});}catch(RuntimeException ignored){}}
|
||||
private static boolean inside(Location location,AdventureQuestState.Site site,AdventureQuestState.Arena arena){return location.getWorld()!=null&&location.getWorld().getUID().equals(site.world())&&arena.contains(location.getX()-site.x(),location.getZ()-site.z());}
|
||||
private Location location(AdventureQuestState.Giver value){return new Location(plugin.getServer().getWorld(value.world()),value.x(),value.y(),value.z(),value.yaw(),value.pitch());}
|
||||
|
||||
@@ -12,28 +12,40 @@ import org.bukkit.entity.EntityType;
|
||||
|
||||
/** Serialized durable daily catalog, acceptance, roster and completion authority. */
|
||||
public final class AdventureQuestDirectory implements AutoCloseable {
|
||||
public enum Acceptance { APPLIED, STALE, UNAVAILABLE, CONFLICT, CLAIMED }
|
||||
public enum Acceptance { APPLIED, STALE, UNAVAILABLE, POLICY, CONFLICT, CLAIMED }
|
||||
public enum ModeStatus { APPLIED, UNCHANGED, STALE, NOT_FOUND }
|
||||
public enum FishingUpdateStatus { APPLIED, UNCHANGED, STALE, NOT_FOUND }
|
||||
public enum RerollStatus { APPLIED, STALE }
|
||||
public enum TrackingStatus { APPLIED, UNCHANGED, INELIGIBLE }
|
||||
public record AcceptResult(Acceptance status,Optional<AdventureQuestState.Quest> quest) { }
|
||||
public record ModeResult(ModeStatus status,Optional<AdventureQuestState.Site> site) { }
|
||||
public record FishingUpdateResult(FishingUpdateStatus status,Optional<AdventureQuestState.Site> site) { }
|
||||
public record RerollResult(RerollStatus status,Optional<AdventureQuestState.Catalog> catalog) { }
|
||||
public record Reward(java.math.BigInteger xp,java.math.BigInteger coins) {public Reward{Objects.requireNonNull(xp);Objects.requireNonNull(coins);if(xp.signum()<0||coins.signum()<0)throw new IllegalArgumentException("Invalid reward");}}
|
||||
public record Reward(java.math.BigInteger xp,java.math.BigInteger coins) {public Reward{Objects.requireNonNull(xp);Objects.requireNonNull(coins);if(xp.signum()<0||coins.signum()<0)throw new IllegalArgumentException("Invalid reward");}public static Reward empty(){return new Reward(BigInteger.ZERO,BigInteger.ZERO);}}
|
||||
public record LegacyDungeonReward(Reward reward,UUID bossRoom,boolean defeated){public LegacyDungeonReward{Objects.requireNonNull(reward);Objects.requireNonNull(bossRoom);}}
|
||||
private final YamlAdventureQuestRepository repository;private final Clock clock;private final Supplier<AdventureQuestSettings.Rules> settings;private final java.util.random.RandomGenerator random;
|
||||
private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-adventure-persistence"));
|
||||
private volatile AdventureQuestState snapshot;private volatile java.util.function.Function<AdventureQuestState.Site,Reward> dungeonRewards=ignored->new Reward(java.math.BigInteger.ZERO,java.math.BigInteger.ZERO);private volatile java.util.function.Predicate<AdventureQuestState.Site> dungeonAvailable=ignored->true;private volatile java.util.function.Supplier<? extends Collection<AdventureQuestState.Site>> dungeonSites=List::of;private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
public AdventureQuestDirectory(YamlAdventureQuestRepository repository,Clock clock,Supplier<AdventureQuestSettings.Rules> settings,java.util.random.RandomGenerator random)throws IOException{this.repository=Objects.requireNonNull(repository);this.clock=Objects.requireNonNull(clock);this.settings=Objects.requireNonNull(settings);this.random=Objects.requireNonNull(random);snapshot=repository.load();}
|
||||
public AdventureQuestState snapshot(){return snapshot;}
|
||||
public int defaultRadius(){return settings.get().radius();}
|
||||
public void registerDungeonRewards(java.util.function.Function<AdventureQuestState.Site,Reward> rewards){dungeonRewards=Objects.requireNonNull(rewards);}
|
||||
public CompletableFuture<Boolean> migrateLegacyDungeonRewards(java.util.function.Function<UUID,Optional<LegacyDungeonReward>> rewards){Objects.requireNonNull(rewards);return mutate(state->{boolean[] changed={false};var claims=new HashSet<>(state.characterClaims());var quests=new ArrayList<AdventureQuestState.Quest>();for(var quest:state.quests()){var migration=rewards.apply(quest.site());if(migration.isEmpty()||quest.fishing()!=null){quests.add(quest);continue;}var value=migration.orElseThrow();UUID source=UUID.nameUUIDFromBytes((quest.catalog()+":"+quest.site()+":"+value.bossRoom()+":"+quest.id()).getBytes(java.nio.charset.StandardCharsets.UTF_8));var allocations=state.rewards().allocations().stream().filter(allocation->allocation.quest().equals(quest.id())&&allocation.source().equals(source)).toList();if(!allocations.isEmpty()){allocations.forEach(allocation->claims.add(new AdventureQuestState.CharacterClaim(quest.catalog(),quest.site(),allocation.character())));changed[0]=true;continue;}if(value.defeated()){quests.add(quest);continue;}var reward=value.reward();if(quest.rewardXp().equals(reward.xp())&&quest.rewardCoins().equals(reward.coins()))quests.add(quest);else{quests.add(new AdventureQuestState.Quest(quest.id(),quest.catalog(),quest.site(),quest.unitType(),quest.unit(),quest.snapshot(),quest.expiresAt(),reward.xp(),reward.coins(),quest.participationMode()));changed[0]=true;}}var migrated=new AdventureQuestState(state.sites(),state.givers(),state.catalog(),quests,state.unitClaims(),claims,state.rosters(),validTracking(state.tracking(),quests),state.rewards(),state.reset());return result(migrated,changed[0]);});}
|
||||
public void registerDungeonAvailability(java.util.function.Predicate<AdventureQuestState.Site> available){dungeonAvailable=Objects.requireNonNull(available);}
|
||||
public void registerDungeonSites(java.util.function.Supplier<? extends Collection<AdventureQuestState.Site>> sites){dungeonSites=Objects.requireNonNull(sites);}
|
||||
public boolean available(AdventureQuestState.Site site){return site.type()!=AdventureQuestState.SiteType.DUNGEON||dungeonAvailable.test(site);}
|
||||
public boolean offerOpen(UUID site){var catalog=snapshot.catalog();return catalog!=null&&catalog.sites().contains(site)&&!catalog.closed().contains(site);}
|
||||
public Reward reward(AdventureQuestState.Site site){var rules=settings.get();return switch(site.type()){case CAMP->new Reward(rules.campRewardXp(),rules.campRewardCoins());case CHAMPION->new Reward(rules.championRewardXp(),rules.championRewardCoins());case DUNGEON->dungeonRewards.apply(site);};}
|
||||
public Reward reward(AdventureQuestState.Site site){var rules=settings.get();return switch(site.type()){case CAMP->new Reward(rules.campRewardXp(),rules.campRewardCoins());case CHAMPION->new Reward(rules.championRewardXp(),rules.championRewardCoins());case DUNGEON->dungeonRewards.apply(site);case FISHING->new Reward(site.fishing().rewardXp(),site.fishing().rewardCoins());};}
|
||||
public Optional<AdventureQuestState.Site> findSite(UUID id){return site(snapshot,id);}
|
||||
public Optional<AdventureQuestState.Giver> findGiver(UUID id){return snapshot.givers().stream().filter(v->v.id().equals(id)).findFirst();}
|
||||
public Optional<AdventureQuestState.Quest> findQuest(UUID id){return snapshot.quests().stream().filter(v->v.id().equals(id)).findFirst();}
|
||||
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z){return placeSite(type,name,world,x,y,z,null);}
|
||||
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z,AdventureQuestState.Arena arena){var value=new AdventureQuestState.Site(UUID.randomUUID(),type,name,world,x,y,z,arena);return mutate(state->{var sites=new ArrayList<>(state.sites());sites.add(value);return result(copy(state,List.copyOf(sites),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
|
||||
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z){return placeSite(type,name,world,x,y,z,null,AdventureQuestState.ParticipationMode.PARTY_AVAILABLE);}
|
||||
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z,AdventureQuestState.Arena arena){return placeSite(type,name,world,x,y,z,arena,AdventureQuestState.ParticipationMode.PARTY_AVAILABLE);}
|
||||
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z,AdventureQuestState.Arena arena,AdventureQuestState.ParticipationMode mode){if(type==AdventureQuestState.SiteType.FISHING)throw new IllegalArgumentException("Use complete fishing definition");var value=new AdventureQuestState.Site(UUID.randomUUID(),type,name,world,x,y,z,arena,mode);return mutate(state->{var sites=new ArrayList<>(state.sites());sites.add(value);return result(copy(state,List.copyOf(sites),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
|
||||
public CompletableFuture<AdventureQuestState.Site> placeFishingSite(String name,String itemName,UUID world,double x,double y,double z,int radius,java.math.BigDecimal chance,BigInteger required,BigInteger rewardXp,BigInteger rewardCoins,AdventureQuestState.ParticipationMode mode){var fishing=new AdventureQuestState.FishingDefinition(UUID.randomUUID(),itemName,required,chance,rewardXp,rewardCoins,radius);var value=new AdventureQuestState.Site(UUID.randomUUID(),AdventureQuestState.SiteType.FISHING,name,world,x,y,z,AdventureQuestState.Arena.circle(radius),mode,fishing);return mutate(state->{var sites=new ArrayList<>(state.sites());sites.add(value);return result(copy(state,List.copyOf(sites),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
|
||||
public CompletableFuture<FishingUpdateResult> updateFishing(UUID id,AdventureQuestState.FishingDefinition expected,AdventureQuestState.FishingDefinition requested){var current=findSite(id).orElse(null);if(current==null||current.type()!=AdventureQuestState.SiteType.FISHING)return CompletableFuture.completedFuture(new FishingUpdateResult(FishingUpdateStatus.NOT_FOUND,Optional.empty()));if(!current.fishing().equals(expected))return CompletableFuture.completedFuture(new FishingUpdateResult(FishingUpdateStatus.STALE,Optional.of(current)));return updateFishingSite(id,current,current.name(),requested,current.participationMode());}
|
||||
public CompletableFuture<FishingUpdateResult> updateFishingSite(UUID id,AdventureQuestState.Site expected,String name,AdventureQuestState.FishingDefinition requested,AdventureQuestState.ParticipationMode mode){Objects.requireNonNull(expected);Objects.requireNonNull(requested);Objects.requireNonNull(mode);return mutate(state->{var found=state.sites().stream().filter(value->value.id().equals(id)&&value.type()==AdventureQuestState.SiteType.FISHING).findFirst();if(found.isEmpty())return result(state,new FishingUpdateResult(FishingUpdateStatus.NOT_FOUND,Optional.empty()));var current=found.orElseThrow();if(!current.equals(expected))return result(state,new FishingUpdateResult(FishingUpdateStatus.STALE,Optional.of(current)));if(!current.fishing().itemId().equals(requested.itemId()))throw new IllegalArgumentException("Fishing item identity cannot change");var changed=new AdventureQuestState.Site(current.id(),current.type(),name,current.world(),current.x(),current.y(),current.z(),AdventureQuestState.Arena.circle(requested.radius()),mode,requested);if(changed.equals(current))return result(state,new FishingUpdateResult(FishingUpdateStatus.UNCHANGED,Optional.of(current)));var sites=state.sites().stream().map(value->value.id().equals(id)?changed:value).toList();return result(copy(state,sites,state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),new FishingUpdateResult(FishingUpdateStatus.APPLIED,Optional.of(changed)));});}
|
||||
public CompletableFuture<ModeResult> setSiteMode(UUID id,AdventureQuestState.ParticipationMode expected,AdventureQuestState.ParticipationMode requested){Objects.requireNonNull(expected);Objects.requireNonNull(requested);return mutate(state->{var found=state.sites().stream().filter(value->value.id().equals(id)&&value.type()!=AdventureQuestState.SiteType.DUNGEON).findFirst();if(found.isEmpty())return result(state,new ModeResult(ModeStatus.NOT_FOUND,Optional.empty()));var current=found.orElseThrow();if(current.participationMode()!=expected)return result(state,new ModeResult(ModeStatus.STALE,Optional.of(current)));if(current.participationMode()==requested)return result(state,new ModeResult(ModeStatus.UNCHANGED,Optional.of(current)));var changed=new AdventureQuestState.Site(current.id(),current.type(),current.name(),current.world(),current.x(),current.y(),current.z(),current.arena(),requested,current.fishing());var sites=state.sites().stream().map(value->value.id().equals(id)?changed:value).toList();return result(copy(state,sites,state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),new ModeResult(ModeStatus.APPLIED,Optional.of(changed)));});}
|
||||
public CompletableFuture<Optional<AdventureQuestState.Site>> removeSite(UUID id){return mutate(state->{var found=state.sites().stream().filter(v->v.id().equals(id)&&v.type()!=AdventureQuestState.SiteType.DUNGEON).findFirst();if(found.isEmpty())return result(state,Optional.empty());var sites=state.sites().stream().filter(v->!v.id().equals(id)).toList();var quests=state.quests().stream().filter(v->!v.site().equals(id)).toList();var rosters=new HashMap<>(state.rosters());rosters.remove(id);return result(copy(state,sites,state.givers(),state.catalog(),quests,state.unitClaims(),state.characterClaims(),rosters),found);});}
|
||||
public CompletableFuture<Optional<AdventureQuestState.Site>> removeDungeonSite(UUID id){return mutate(state->{var found=state.sites().stream().filter(v->v.id().equals(id)&&v.type()==AdventureQuestState.SiteType.DUNGEON).findFirst();if(found.isEmpty())return result(state,Optional.empty());var sites=state.sites().stream().filter(v->!v.id().equals(id)).toList();return result(copy(state,sites,state.givers(),state.catalog(),state.quests().stream().filter(v->!v.site().equals(id)).toList(),state.unitClaims(),state.characterClaims(),state.rosters()),found);});}
|
||||
public CompletableFuture<AdventureQuestState.Giver> placeGiver(String name,UUID world,double x,double y,double z,float yaw,float pitch){var value=new AdventureQuestState.Giver(UUID.randomUUID(),name,world,x,y,z,yaw,pitch);return mutate(state->{var values=new ArrayList<>(state.givers());values.add(value);return result(copy(state,state.sites(),List.copyOf(values),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
|
||||
@@ -44,7 +56,8 @@ public final class AdventureQuestDirectory implements AutoCloseable {
|
||||
public CompletableFuture<RerollResult> rerollToday(){var current=snapshot.catalog();return rerollToday(current==null?null:current.revision());}
|
||||
public CompletableFuture<RerollResult> rerollToday(UUID expectedRevision){return mutate(original->{var state=expire(original);var current=state.catalog();UUID actual=current==null?null:current.revision();if(state.reset()!=null||!Objects.equals(expectedRevision,actual))return result(state,new RerollResult(RerollStatus.STALE,Optional.ofNullable(current)));var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));boolean sameDay=current!=null&¤t.day().equals(today);var catalog=roll(state,today,sameDay?Set.copyOf(current.sites()):Set.of());var reset=new AdventureQuestState.Reset(UUID.randomUUID(),catalog.revision(),clock.instant());var next=copyReset(state,state.sites(),state.givers(),catalog,List.of(),Set.of(),Set.of(),Map.of(),reset);return result(next,new RerollResult(RerollStatus.APPLIED,Optional.of(catalog)));});}
|
||||
public CompletableFuture<Boolean> completeReset(UUID operation){return mutate(state->{if(state.reset()==null||!state.reset().operation().equals(operation))return result(state,false);return result(copyReset(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters(),null),true);});}
|
||||
public CompletableFuture<AcceptResult> accept(UUID catalog,UUID site,AdventureQuestState.UnitType type,UUID unit,Set<UUID> members){return mutate(original->{var state=expire(original);var current=state.catalog();if(state.reset()!=null||current==null||!current.day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC)))||!current.revision().equals(catalog))return result(state,new AcceptResult(Acceptance.STALE,Optional.empty()));if(!current.sites().contains(site)||current.closed().contains(site)||site(state,site).filter(this::available).isEmpty())return result(state,new AcceptResult(Acceptance.UNAVAILABLE,Optional.empty()));boolean unitClaimed=state.unitClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.type()==type&&claim.unit().equals(unit));boolean characterClaimed=members.stream().anyMatch(member->state.characterClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.character().equals(member)));if(unitClaimed||characterClaimed)return result(state,new AcceptResult(Acceptance.CLAIMED,Optional.empty()));boolean conflict=state.quests().stream().anyMatch(q->q.unit().equals(unit)||type==AdventureQuestState.UnitType.PARTY&&q.unitType()==AdventureQuestState.UnitType.SOLO&&members.contains(q.unit()));if(conflict)return result(state,new AcceptResult(Acceptance.CONFLICT,Optional.empty()));var offered=site(state,site).orElseThrow();var reward=reward(offered);var quest=new AdventureQuestState.Quest(UUID.randomUUID(),catalog,site,type,unit,members,current.expiresAt(),reward.xp(),reward.coins());var quests=new ArrayList<>(state.quests());quests.add(quest);var tracking=new HashSet<>(state.tracking());for(UUID character:members)tracking.add(new AdventureQuestState.Tracking(quest.id(),character,true));return result(copyTracking(state,state.sites(),state.givers(),current,List.copyOf(quests),state.unitClaims(),state.characterClaims(),state.rosters(),tracking),new AcceptResult(Acceptance.APPLIED,Optional.of(quest)));});}
|
||||
public CompletableFuture<AcceptResult> accept(UUID catalog,UUID site,AdventureQuestState.UnitType type,UUID unit,Set<UUID> members){var offered=findSite(site).orElse(null);return offered==null?CompletableFuture.completedFuture(new AcceptResult(Acceptance.UNAVAILABLE,Optional.empty())):accept(catalog,site,type,unit,members,offered.participationMode());}
|
||||
public CompletableFuture<AcceptResult> accept(UUID catalog,UUID site,AdventureQuestState.UnitType type,UUID unit,Set<UUID> members,AdventureQuestState.ParticipationMode expectedMode){Objects.requireNonNull(expectedMode);var requestedMembers=Set.copyOf(members);return mutate(original->{var state=expire(original);var current=state.catalog();if(state.reset()!=null||current==null||!current.day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC)))||!current.revision().equals(catalog))return result(state,new AcceptResult(Acceptance.STALE,Optional.empty()));var offered=site(state,site).filter(this::available).orElse(null);if(!current.sites().contains(site)||current.closed().contains(site)||offered==null)return result(state,new AcceptResult(Acceptance.UNAVAILABLE,Optional.empty()));if(offered.participationMode()!=expectedMode)return result(state,new AcceptResult(Acceptance.STALE,Optional.empty()));if(!offered.participationMode().permits(type))return result(state,new AcceptResult(Acceptance.POLICY,Optional.empty()));boolean unitClaimed=state.unitClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.type()==type&&claim.unit().equals(unit));boolean characterClaimed=requestedMembers.stream().anyMatch(member->state.characterClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.character().equals(member)));if(unitClaimed||characterClaimed)return result(state,new AcceptResult(Acceptance.CLAIMED,Optional.empty()));boolean conflict=state.quests().stream().anyMatch(q->q.unit().equals(unit)||!Collections.disjoint(q.snapshot(),requestedMembers));if(conflict)return result(state,new AcceptResult(Acceptance.CONFLICT,Optional.empty()));var reward=reward(offered);var quest=new AdventureQuestState.Quest(UUID.randomUUID(),catalog,site,type,unit,requestedMembers,current.expiresAt(),reward.xp(),reward.coins(),offered.participationMode(),offered.fishing()==null?null:offered.fishing().terms());var quests=new ArrayList<>(state.quests());quests.add(quest);var tracking=new HashSet<>(state.tracking());for(UUID character:requestedMembers)tracking.add(new AdventureQuestState.Tracking(quest.id(),character,true));return result(copyTracking(state,state.sites(),state.givers(),current,List.copyOf(quests),state.unitClaims(),state.characterClaims(),state.rosters(),tracking),new AcceptResult(Acceptance.APPLIED,Optional.of(quest)));});}
|
||||
public CompletableFuture<Integer> expireOfferQuests(UUID site){return mutate(state->{var quests=state.quests().stream().filter(value->!value.site().equals(site)).toList();int removed=state.quests().size()-quests.size();return removed==0?result(state,0):result(copy(state,state.sites(),state.givers(),state.catalog(),quests,state.unitClaims(),state.characterClaims(),state.rosters()),removed);});}
|
||||
public CompletableFuture<Boolean> abandon(UUID quest,UUID unit){return mutate(state->{var found=state.quests().stream().filter(q->q.id().equals(quest)&&q.unit().equals(unit)).findFirst();if(found.isEmpty())return result(state,false);return result(copy(state,state.sites(),state.givers(),state.catalog(),state.quests().stream().filter(q->!q.id().equals(quest)).toList(),state.unitClaims(),state.characterClaims(),state.rosters()),true);});}
|
||||
public CompletableFuture<List<EntityType>> roster(UUID site){return mutate(state->{state=expire(state);if(state.reset()!=null||state.catalog()==null||!state.catalog().day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC)))||!state.catalog().sites().contains(site)||find(state.sites(),site).filter(v->v.type()==AdventureQuestState.SiteType.CAMP).isEmpty())throw new IllegalArgumentException("Unavailable camp");var existing=state.rosters().get(site);if(existing!=null)return result(state,existing);var rolled=CampRoster.roll(settings.get().campEnemies(),random);var rosters=new HashMap<>(state.rosters());rosters.put(site,rolled);return result(copy(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),rosters),rolled);});}
|
||||
@@ -59,8 +72,10 @@ public final class AdventureQuestDirectory implements AutoCloseable {
|
||||
public CompletableFuture<Boolean> bankAndComplete(UUID quest,UUID source,Map<UUID,Reward> rewards){return bank(quest,source,rewards,true);}
|
||||
private CompletableFuture<Boolean> bank(UUID questId,UUID source,Map<UUID,Reward> rewards,boolean complete){Objects.requireNonNull(source);var requested=Map.copyOf(rewards);return mutate(original->{var state=expire(original);var quest=state.quests().stream().filter(value->value.id().equals(questId)).findFirst().orElse(null);if(quest==null||!quest.snapshot().containsAll(requested.keySet())||complete&&requested.isEmpty())return result(state,false);var allocations=new ArrayList<>(state.rewards().allocations());for(var entry:requested.entrySet()){var reward=Objects.requireNonNull(entry.getValue());if(reward.xp().signum()==0&&reward.coins().signum()==0)continue;UUID id=rewardId(quest,source,entry.getKey());var existing=allocations.stream().filter(value->value.id().equals(id)).findFirst().orElse(null);if(existing!=null){if(!existing.xp().equals(reward.xp())||!existing.coins().equals(reward.coins()))throw new IllegalStateException("Conflicting adventure reward allocation");continue;}allocations.add(new AdventureQuestState.RewardAllocation(id,quest.catalog(),quest.site(),quest.id(),source,entry.getKey(),reward.xp(),reward.coins()));}var quests=state.quests();var units=state.unitClaims();var characters=state.characterClaims();if(complete){quests=state.quests().stream().filter(value->!value.id().equals(quest.id())).toList();var changedUnits=new HashSet<>(units);changedUnits.add(new AdventureQuestState.UnitClaim(quest.catalog(),quest.site(),quest.unitType(),quest.unit()));units=Set.copyOf(changedUnits);var changedCharacters=new HashSet<>(characters);for(UUID character:requested.keySet())changedCharacters.add(new AdventureQuestState.CharacterClaim(quest.catalog(),quest.site(),character));characters=Set.copyOf(changedCharacters);}var ledger=new AdventureQuestState.RewardLedger(allocations,state.rewards().operations(),state.rewards().receipts());return result(copyRewards(state,state.sites(),state.givers(),state.catalog(),quests,units,characters,state.rosters(),ledger),true);});}
|
||||
public Reward unclaimed(UUID catalog,UUID site,UUID character){BigInteger xp=BigInteger.ZERO,coins=BigInteger.ZERO;for(var value:snapshot.rewards().allocations())if(value.catalog().equals(catalog)&&value.site().equals(site)&&value.character().equals(character)){xp=xp.add(value.xp());coins=coins.add(value.coins());}return new Reward(xp,coins);}
|
||||
public Reward unclaimedAll(UUID character){BigInteger xp=BigInteger.ZERO,coins=BigInteger.ZERO;for(var value:snapshot.rewards().allocations())if(value.character().equals(character)){xp=xp.add(value.xp());coins=coins.add(value.coins());}return new Reward(xp,coins);}
|
||||
public boolean rewardClaimed(UUID catalog,UUID site,UUID character){return snapshot.rewards().receipts().contains(new AdventureQuestState.RewardReceipt(catalog,site,character));}
|
||||
public Optional<AdventureQuestState.RewardClaim> pendingClaim(UUID character){return snapshot.rewards().operations().stream().filter(value->value.character().equals(character)).findFirst();}
|
||||
public CompletableFuture<Optional<AdventureQuestState.RewardClaim>> beginLegacyClaim(UUID character){return mutate(original->{var state=expire(original);var pending=state.rewards().operations().stream().filter(value->value.character().equals(character)).findFirst();if(pending.isPresent())return result(state,pending);var allocations=state.rewards().allocations().stream().filter(value->value.character().equals(character)).sorted(Comparator.comparing(AdventureQuestState.RewardAllocation::id)).toList();if(allocations.isEmpty())return result(state,Optional.empty());BigInteger xp=allocations.stream().map(AdventureQuestState.RewardAllocation::xp).reduce(BigInteger.ZERO,BigInteger::add),coins=allocations.stream().map(AdventureQuestState.RewardAllocation::coins).reduce(BigInteger.ZERO,BigInteger::add);var ids=allocations.stream().map(AdventureQuestState.RewardAllocation::id).collect(java.util.stream.Collectors.toSet());var first=allocations.getFirst();UUID operation=UUID.nameUUIDFromBytes((character+":"+ids.stream().sorted().map(UUID::toString).collect(java.util.stream.Collectors.joining(","))).getBytes(java.nio.charset.StandardCharsets.UTF_8));var claim=new AdventureQuestState.RewardClaim(operation,first.catalog(),first.site(),character,ids,xp,coins);var operations=new ArrayList<>(state.rewards().operations());operations.add(claim);var ledger=new AdventureQuestState.RewardLedger(state.rewards().allocations(),operations,state.rewards().receipts());return result(copyRewards(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters(),ledger),Optional.of(claim));});}
|
||||
public CompletableFuture<Optional<AdventureQuestState.RewardClaim>> beginClaim(UUID catalog,UUID site,UUID character){return mutate(original->{var state=expire(original);var current=state.catalog();if(current==null||!current.revision().equals(catalog)||!current.sites().contains(site)||!clock.instant().isBefore(current.expiresAt()))return result(state,Optional.empty());var pending=state.rewards().operations().stream().filter(value->value.catalog().equals(catalog)&&value.site().equals(site)&&value.character().equals(character)).findFirst();if(pending.isPresent())return result(state,pending);var allocations=state.rewards().allocations().stream().filter(value->value.catalog().equals(catalog)&&value.site().equals(site)&&value.character().equals(character)).toList();if(allocations.isEmpty())return result(state,Optional.empty());BigInteger xp=allocations.stream().map(AdventureQuestState.RewardAllocation::xp).reduce(BigInteger.ZERO,BigInteger::add),coins=allocations.stream().map(AdventureQuestState.RewardAllocation::coins).reduce(BigInteger.ZERO,BigInteger::add);var ids=allocations.stream().map(AdventureQuestState.RewardAllocation::id).collect(java.util.stream.Collectors.toSet());UUID operation=UUID.nameUUIDFromBytes((catalog+":"+site+":"+character+":"+ids.stream().sorted().map(UUID::toString).collect(java.util.stream.Collectors.joining(","))).getBytes(java.nio.charset.StandardCharsets.UTF_8));var claim=new AdventureQuestState.RewardClaim(operation,catalog,site,character,ids,xp,coins);var operations=new ArrayList<>(state.rewards().operations());operations.add(claim);var ledger=new AdventureQuestState.RewardLedger(state.rewards().allocations(),operations,state.rewards().receipts());return result(copyRewards(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters(),ledger),Optional.of(claim));});}
|
||||
public CompletableFuture<Boolean> finishClaim(UUID operation){return mutate(state->{var claim=state.rewards().operations().stream().filter(value->value.id().equals(operation)).findFirst().orElse(null);if(claim==null)return result(state,false);var allocations=state.rewards().allocations().stream().filter(value->!claim.allocations().contains(value.id())).toList();var operations=state.rewards().operations().stream().filter(value->!value.id().equals(operation)).toList();var receipts=new HashSet<>(state.rewards().receipts());receipts.add(new AdventureQuestState.RewardReceipt(claim.catalog(),claim.site(),claim.character()));var ledger=new AdventureQuestState.RewardLedger(allocations,operations,receipts);return result(copyRewards(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters(),ledger),true);});}
|
||||
public CompletableFuture<Integer> expireParties(java.util.function.Predicate<UUID> exists){return mutate(state->{var quests=state.quests().stream().filter(q->q.unitType()!=AdventureQuestState.UnitType.PARTY||exists.test(q.unit())).toList();int removed=state.quests().size()-quests.size();return removed==0?result(state,0):result(copy(state,state.sites(),state.givers(),state.catalog(),quests,state.unitClaims(),state.characterClaims(),state.rosters()),removed);});}
|
||||
@@ -75,7 +90,7 @@ public final class AdventureQuestDirectory implements AutoCloseable {
|
||||
private static AdventureQuestState copyTracking(AdventureQuestState original,List<AdventureQuestState.Site> sites,List<AdventureQuestState.Giver> givers,AdventureQuestState.Catalog catalog,List<AdventureQuestState.Quest> quests,Set<AdventureQuestState.UnitClaim> unitClaims,Set<AdventureQuestState.CharacterClaim> characterClaims,Map<UUID,List<EntityType>> rosters,Set<AdventureQuestState.Tracking> tracking){return new AdventureQuestState(sites,givers,catalog,quests,unitClaims,characterClaims,rosters,validTracking(tracking,quests),original.rewards(),original.reset());}
|
||||
private static AdventureQuestState copyRewards(AdventureQuestState original,List<AdventureQuestState.Site> sites,List<AdventureQuestState.Giver> givers,AdventureQuestState.Catalog catalog,List<AdventureQuestState.Quest> quests,Set<AdventureQuestState.UnitClaim> unitClaims,Set<AdventureQuestState.CharacterClaim> characterClaims,Map<UUID,List<EntityType>> rosters,AdventureQuestState.RewardLedger rewards){return new AdventureQuestState(sites,givers,catalog,quests,unitClaims,characterClaims,rosters,validTracking(original.tracking(),quests),rewards,original.reset());}
|
||||
private static AdventureQuestState copyReset(AdventureQuestState original,List<AdventureQuestState.Site> sites,List<AdventureQuestState.Giver> givers,AdventureQuestState.Catalog catalog,List<AdventureQuestState.Quest> quests,Set<AdventureQuestState.UnitClaim> unitClaims,Set<AdventureQuestState.CharacterClaim> characterClaims,Map<UUID,List<EntityType>> rosters,AdventureQuestState.Reset reset){return new AdventureQuestState(sites,givers,catalog,quests,unitClaims,characterClaims,rosters,validTracking(original.tracking(),quests),pendingRewards(original.rewards()),reset);}
|
||||
private static AdventureQuestState.RewardLedger pendingRewards(AdventureQuestState.RewardLedger rewards){var retainedIds=rewards.operations().stream().flatMap(value->value.allocations().stream()).collect(java.util.stream.Collectors.toSet());return new AdventureQuestState.RewardLedger(rewards.allocations().stream().filter(value->retainedIds.contains(value.id())).toList(),rewards.operations(),Set.of());}
|
||||
private static AdventureQuestState.RewardLedger pendingRewards(AdventureQuestState.RewardLedger rewards){return rewards;}
|
||||
private static Set<AdventureQuestState.Tracking> validTracking(Set<AdventureQuestState.Tracking> tracking,List<AdventureQuestState.Quest> quests){var active=quests.stream().collect(java.util.stream.Collectors.toMap(AdventureQuestState.Quest::id,value->value));return tracking.stream().filter(value->{var quest=active.get(value.quest());return quest!=null&&quest.snapshot().contains(value.character());}).collect(java.util.stream.Collectors.toUnmodifiableSet());}
|
||||
private static UUID rewardId(AdventureQuestState.Quest quest,UUID source,UUID character){return UUID.nameUUIDFromBytes((quest.catalog()+":"+quest.site()+":"+quest.id()+":"+source+":"+character).getBytes(java.nio.charset.StandardCharsets.UTF_8));}
|
||||
private record Result<T>(AdventureQuestState state,T value){}
|
||||
|
||||
@@ -3,6 +3,7 @@ package games.dmg.fantasystick.adventure;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
@@ -11,10 +12,13 @@ import org.bukkit.World;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.CompassMeta;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
|
||||
/** Pure presentation of one accepted quest through the authentic bound book control. */
|
||||
public final class AdventureQuestNavigation {
|
||||
private static final String[] BEARINGS={"N","NE","E","SE","S","SW","W","NW"};
|
||||
public static final org.bukkit.NamespacedKey FISHING_QUEST=new org.bukkit.NamespacedKey("fantasystick","fishing_quest");
|
||||
public static final org.bukkit.NamespacedKey FISHING_SITE=new org.bukkit.NamespacedKey("fantasystick","fishing_site");
|
||||
private final Server server;
|
||||
|
||||
public AdventureQuestNavigation(Server server){this.server=Objects.requireNonNull(server);}
|
||||
@@ -42,6 +46,9 @@ public final class AdventureQuestNavigation {
|
||||
return item;
|
||||
}
|
||||
|
||||
public ItemStack fishingRod(ItemStack ordinaryBook,AdventureQuestState.Quest quest,AdventureQuestState.Site site){Objects.requireNonNull(ordinaryBook);Objects.requireNonNull(quest);Objects.requireNonNull(site);if(quest.fishing()==null||site.type()!=AdventureQuestState.SiteType.FISHING)throw new IllegalArgumentException("Fishing quest required");var item=ordinaryBook.clone();item.setType(Material.FISHING_ROD);var meta=item.getItemMeta();meta.displayName(Component.text("Quest Fishing Rod"));meta.lore(List.of(Component.text(site.displayName()),Component.text(quest.fishing().itemName()+": "+quest.fishing().required()),Component.text("Right-click: Cast / reel"),Component.text("Sneak-right-click: Character book")));meta.setMaxStackSize(1);for(var enchantment:List.copyOf(meta.getEnchants().keySet()))meta.removeEnchant(enchantment);meta.getPersistentDataContainer().set(FISHING_QUEST,PersistentDataType.STRING,quest.id().toString());meta.getPersistentDataContainer().set(FISHING_SITE,PersistentDataType.STRING,site.id().toString());item.setItemMeta(meta);return item;}
|
||||
public static UUID fishingQuest(ItemStack item){try{String value=item==null?null:item.getItemMeta().getPersistentDataContainer().get(FISHING_QUEST,PersistentDataType.STRING);return value==null?null:UUID.fromString(value);}catch(RuntimeException invalid){return null;}}
|
||||
|
||||
public Optional<Component> guidance(Player player,AdventureQuestState.Site site,boolean arrived){
|
||||
Objects.requireNonNull(player);Objects.requireNonNull(site);
|
||||
if(arrived)return Optional.empty();
|
||||
@@ -56,6 +63,6 @@ public final class AdventureQuestNavigation {
|
||||
|
||||
static long distance(Location viewer,AdventureQuestState.Site site){return Math.round(Math.hypot(site.x()-viewer.getX(),site.z()-viewer.getZ()));}
|
||||
static String bearing(Location viewer,AdventureQuestState.Site site){double angle=Math.atan2(site.x()-viewer.getX(),-(site.z()-viewer.getZ()));int index=(int)Math.round(angle/(Math.PI/4));return BEARINGS[Math.floorMod(index,8)];}
|
||||
private static String type(AdventureQuestState.SiteType type){return switch(type){case CAMP->"Monster camp";case CHAMPION->"Champion";case DUNGEON->"Dungeon entrance";};}
|
||||
private static String type(AdventureQuestState.SiteType type){return switch(type){case CAMP->"Monster camp";case CHAMPION->"Champion";case DUNGEON->"Dungeon entrance";case FISHING->"Fishing site";};}
|
||||
private static String worldName(World world){return world==null||world.getName()==null||world.getName().isBlank()?"unavailable world":world.getName();}
|
||||
}
|
||||
@@ -10,9 +10,24 @@ import org.bukkit.entity.EntityType;
|
||||
public record AdventureQuestState(List<Site> sites,List<Giver> givers,Catalog catalog,List<Quest> quests,
|
||||
Set<UnitClaim> unitClaims,Set<CharacterClaim> characterClaims,
|
||||
Map<UUID,List<EntityType>> rosters,Set<Tracking> tracking,RewardLedger rewards,Reset reset) {
|
||||
public enum SiteType { CAMP, CHAMPION, DUNGEON }
|
||||
public enum SiteType { CAMP, CHAMPION, DUNGEON, FISHING }
|
||||
public enum UnitType { SOLO, PARTY }
|
||||
public enum ParticipationMode {
|
||||
INDIVIDUAL_ONLY("individual-only"), PARTY_AVAILABLE("party-available"), PARTY_REQUIRED("party-required");
|
||||
private final String value;
|
||||
ParticipationMode(String value){this.value=value;}
|
||||
public String value(){return value;}
|
||||
public static ParticipationMode parse(String value){return Arrays.stream(values()).filter(mode->mode.value.equals(value)).findFirst().orElseThrow(()->new IllegalArgumentException("Invalid participation mode"));}
|
||||
public boolean permits(UnitType type){return switch(this){case INDIVIDUAL_ONLY->type==UnitType.SOLO;case PARTY_AVAILABLE->true;case PARTY_REQUIRED->type==UnitType.PARTY;};}
|
||||
}
|
||||
public enum ArenaShape { CIRCLE, RECTANGLE }
|
||||
public record FishingTerms(UUID itemId,String itemName,BigInteger required,java.math.BigDecimal chancePercent,BigInteger rewardXp,BigInteger rewardCoins,int radius) {
|
||||
public FishingTerms {Objects.requireNonNull(itemId);itemName=validName(itemName);Objects.requireNonNull(required);Objects.requireNonNull(chancePercent);Objects.requireNonNull(rewardXp);Objects.requireNonNull(rewardCoins);chancePercent=chancePercent.stripTrailingZeros();if(itemName.isBlank()||required.signum()<=0||required.compareTo(BigInteger.valueOf(Integer.MAX_VALUE))>0||chancePercent.scale()>6||chancePercent.compareTo(java.math.BigDecimal.ZERO)<0||chancePercent.compareTo(java.math.BigDecimal.valueOf(100))>0||rewardXp.signum()<0||rewardCoins.signum()<0||rewardXp.compareTo(BigInteger.valueOf(Integer.MAX_VALUE))>0||rewardCoins.compareTo(BigInteger.valueOf(Integer.MAX_VALUE))>0||radius<4||radius>64)throw new IllegalArgumentException("Invalid fishing terms");}
|
||||
}
|
||||
public record FishingDefinition(UUID itemId,String itemName,BigInteger required,java.math.BigDecimal chancePercent,BigInteger rewardXp,BigInteger rewardCoins,int radius) {
|
||||
public FishingDefinition {new FishingTerms(itemId,itemName,required,chancePercent,rewardXp,rewardCoins,radius);itemName=validName(itemName);chancePercent=chancePercent.stripTrailingZeros();}
|
||||
public FishingTerms terms(){return new FishingTerms(itemId,itemName,required,chancePercent,rewardXp,rewardCoins,radius);}
|
||||
}
|
||||
public record Arena(ArenaShape shape,int xBlocks,int zBlocks) {
|
||||
public Arena {
|
||||
Objects.requireNonNull(shape);
|
||||
@@ -26,10 +41,12 @@ public record AdventureQuestState(List<Site> sites,List<Giver> givers,Catalog ca
|
||||
public boolean contains(double xOffset,double zOffset){return shape==ArenaShape.CIRCLE?xOffset*xOffset+zOffset*zOffset<=xBlocks*(double)xBlocks:Math.abs(xOffset)<=xBlocks/2d&&Math.abs(zOffset)<=zBlocks/2d;}
|
||||
public String display(){return shape==ArenaShape.CIRCLE?"circle radius="+xBlocks:"rectangle x="+xBlocks+" z="+zBlocks;}
|
||||
}
|
||||
public record Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z,Arena arena) {
|
||||
public Site { Objects.requireNonNull(id);Objects.requireNonNull(type);name=validName(name);Objects.requireNonNull(world);finite(x,y,z); }
|
||||
public Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z){this(id,type,name,world,x,y,z,null);}
|
||||
public String displayName(){return name.isBlank()?switch(type){case CAMP->"Monster Camp";case CHAMPION->"Champion Site";case DUNGEON->"Dungeon";}:name;}
|
||||
public record Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z,Arena arena,ParticipationMode participationMode,FishingDefinition fishing) {
|
||||
public Site { Objects.requireNonNull(id);Objects.requireNonNull(type);name=validName(name);Objects.requireNonNull(world);finite(x,y,z);Objects.requireNonNull(participationMode);if((type==SiteType.FISHING)!=(fishing!=null)||fishing!=null&&(!Arena.circle(fishing.radius()).equals(arena)))throw new IllegalArgumentException("Invalid fishing site"); }
|
||||
public Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z,Arena arena,ParticipationMode participationMode){this(id,type,name,world,x,y,z,arena,participationMode,null);}
|
||||
public Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z,Arena arena){this(id,type,name,world,x,y,z,arena,ParticipationMode.PARTY_AVAILABLE,null);}
|
||||
public Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z){this(id,type,name,world,x,y,z,null,ParticipationMode.PARTY_AVAILABLE,null);}
|
||||
public String displayName(){return name.isBlank()?switch(type){case CAMP->"Monster Camp";case CHAMPION->"Champion Site";case DUNGEON->"Dungeon";case FISHING->"Fishing Site";}:name;}
|
||||
public Arena arenaOrDefault(int radius){return arena==null?Arena.circle(radius):arena;}
|
||||
}
|
||||
public record Giver(UUID id,String name,UUID world,double x,double y,double z,float yaw,float pitch) {
|
||||
@@ -42,8 +59,10 @@ public record AdventureQuestState(List<Site> sites,List<Giver> givers,Catalog ca
|
||||
public Instant expiresAt(){return day.plusDays(1).atStartOfDay(java.time.ZoneOffset.UTC).toInstant();}
|
||||
}
|
||||
public record Quest(UUID id,UUID catalog,UUID site,UnitType unitType,UUID unit,Set<UUID> snapshot,
|
||||
Instant expiresAt,BigInteger rewardXp,BigInteger rewardCoins) {
|
||||
public Quest { Objects.requireNonNull(id);Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(unitType);Objects.requireNonNull(unit);snapshot=Set.copyOf(snapshot);Objects.requireNonNull(expiresAt);Objects.requireNonNull(rewardXp);Objects.requireNonNull(rewardCoins);if(snapshot.isEmpty()||rewardXp.signum()<0||rewardCoins.signum()<0||unitType==UnitType.SOLO&&(!snapshot.equals(Set.of(unit))))throw new IllegalArgumentException("Invalid quest"); }
|
||||
Instant expiresAt,BigInteger rewardXp,BigInteger rewardCoins,ParticipationMode participationMode,FishingTerms fishing) {
|
||||
public Quest { Objects.requireNonNull(id);Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(unitType);Objects.requireNonNull(unit);snapshot=Set.copyOf(snapshot);Objects.requireNonNull(expiresAt);Objects.requireNonNull(rewardXp);Objects.requireNonNull(rewardCoins);Objects.requireNonNull(participationMode);if(snapshot.isEmpty()||rewardXp.signum()<0||rewardCoins.signum()<0||unitType==UnitType.SOLO&&(!snapshot.equals(Set.of(unit)))||!participationMode.permits(unitType)||fishing!=null&&(!fishing.rewardXp().equals(rewardXp)||!fishing.rewardCoins().equals(rewardCoins)))throw new IllegalArgumentException("Invalid quest"); }
|
||||
public Quest(UUID id,UUID catalog,UUID site,UnitType unitType,UUID unit,Set<UUID> snapshot,Instant expiresAt,BigInteger rewardXp,BigInteger rewardCoins,ParticipationMode participationMode){this(id,catalog,site,unitType,unit,snapshot,expiresAt,rewardXp,rewardCoins,participationMode,null);}
|
||||
public Quest(UUID id,UUID catalog,UUID site,UnitType unitType,UUID unit,Set<UUID> snapshot,Instant expiresAt,BigInteger rewardXp,BigInteger rewardCoins){this(id,catalog,site,unitType,unit,snapshot,expiresAt,rewardXp,rewardCoins,ParticipationMode.PARTY_AVAILABLE,null);}
|
||||
}
|
||||
public record UnitClaim(UUID catalog,UUID site,UnitType type,UUID unit) {
|
||||
public UnitClaim {Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(type);Objects.requireNonNull(unit);}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package games.dmg.fantasystick.adventure;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.persistence.*;
|
||||
import games.dmg.fantasystick.progression.*;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.entity.*;
|
||||
import org.bukkit.event.*;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.event.player.PlayerFishEvent;
|
||||
import org.bukkit.inventory.*;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
/** Owned quest-rod fishing, virtual Inventory, recoverable submission and non-expiring claims. */
|
||||
public final class FishingController implements Listener,AutoCloseable,AdventureResetParticipant {
|
||||
public record Failure(UUID operation,UUID character,String phase,Throwable cause){}
|
||||
private record Context(EquipmentSessions.Active active,CharacterRecord record,UUID party,Set<UUID> members){}
|
||||
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;
|
||||
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);}
|
||||
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 void fish(PlayerFishEvent event){if(closed)return;var player=event.getPlayer();var item=player.getInventory().getItemInMainHand();if(!nativeUse(player,item))return;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;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 CAUGHT_ENTITY->{event.setCancelled(true);hooks.remove(player.getUniqueId());event.getHook().remove();player.sendMessage("Quest Fishing Rods cannot pull entities.");}case CAUGHT_FISH->{var claim=hooks.remove(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())||!(event.getCaught() instanceof Item caught)||!inside(player.getLocation(),quest)||!inside(event.getHook().getLocation(),quest)){event.setCancelled(true);event.setExpToDrop(0);if(event.getCaught()!=null)event.getCaught().remove();player.sendMessage("Quest catch refused: the owned hook and fishing area must remain valid.");return;}event.setCancelled(true);event.setExpToDrop(0);caught.remove();BigInteger xp=claim.sampledXp();boolean awarded=random.nextDouble(100d)<quest.fishing().chancePercent().doubleValue();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);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);});}default->{if(event.getState()!=PlayerFishEvent.State.BITE)hooks.remove(player.getUniqueId());}}}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void quit(org.bukkit.event.player.PlayerQuitEvent event){cleanup(event.getPlayer());}
|
||||
@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 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 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 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();}
|
||||
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);return new Context(active,record,party==null?null:party.id(),party==null?Set.of(record.id()):Set.copyOf(party.characters()));}
|
||||
private boolean inside(Location location,AdventureQuestState.Quest quest){var site=adventures.findSite(quest.site()).orElse(null);return site!=null&&site.type()==AdventureQuestState.SiteType.FISHING&&location.getWorld()!=null&&location.getWorld().getUID().equals(site.world())&&AdventureQuestState.Arena.circle(quest.fishing().radius()).contains(location.getX()-site.x(),location.getZ()-site.z());}
|
||||
private BigInteger sampleXp(){var rules=settings.rules();long minimum=rules.fishingCatchXpMin().longValueExact(),maximum=rules.fishingCatchXpMax().longValueExact();return BigInteger.valueOf(minimum+(maximum==minimum?0:random.nextLong(maximum-minimum+1)));}
|
||||
private UUID site(ItemStack item){try{String value=item.getItemMeta().getPersistentDataContainer().get(AdventureQuestNavigation.FISHING_SITE,PersistentDataType.STRING);return value==null?null:UUID.fromString(value);}catch(RuntimeException invalid){return null;}}
|
||||
private ItemStack item(Material material,String name,String action,List<String> lore){var item=new ItemStack(material);var meta=item.getItemMeta();meta.displayName(net.kyori.adventure.text.Component.text(name));meta.lore(lore.stream().map(net.kyori.adventure.text.Component::text).toList());if(action!=null)meta.getPersistentDataContainer().set(actionKey,PersistentDataType.STRING,action);item.setItemMeta(meta);return item;}
|
||||
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();}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package games.dmg.fantasystick.adventure;
|
||||
|
||||
import games.dmg.fantasystick.persistence.YamlFishingRepository;
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
|
||||
/** Serialized save-before-publication authority for fishing stock, submissions and earned shares. */
|
||||
public final class FishingDirectory implements AutoCloseable {
|
||||
public enum PrepareStatus { APPLIED, EXISTING, DUPLICATE, INSUFFICIENT, COMPLETED }
|
||||
public enum CompleteStatus { APPLIED, DUPLICATE, NOT_FOUND }
|
||||
public enum CommitStatus { APPLIED, DUPLICATE, INSUFFICIENT, NOT_FOUND }
|
||||
public enum TrophyPrepareStatus { APPLIED, EXISTING, REDEEMED }
|
||||
public enum TrophyRedeemStatus { APPLIED, REDEEMED, NOT_FOUND, WRONG_CARRIER }
|
||||
public enum TrophyCommitStatus { APPLIED, DUPLICATE, NOT_FOUND }
|
||||
private final YamlFishingRepository repository;
|
||||
private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-fishing-persistence"));
|
||||
private volatile FishingState snapshot;private volatile java.util.function.Function<FishingState.ItemStack,String> labels=FishingState.ItemStack::name;private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
public FishingDirectory(YamlFishingRepository repository)throws IOException{this.repository=Objects.requireNonNull(repository);snapshot=repository.load();}
|
||||
public FishingState snapshot(){return snapshot;}
|
||||
public void registerLabels(java.util.function.Function<FishingState.ItemStack,String> labels){this.labels=Objects.requireNonNull(labels);}
|
||||
public List<FishingState.ItemStack> inventory(UUID character){var resolver=labels;return snapshot.inventory().stream().filter(value->value.character().equals(character)).map(value->{String name=resolver.apply(value);return name.equals(value.name())?value:new FishingState.ItemStack(value.character(),value.item(),value.site(),name,value.amount());}).sorted(Comparator.comparing(FishingState.ItemStack::name).thenComparing(FishingState.ItemStack::item)).toList();}
|
||||
public BigInteger quantity(UUID character,UUID item){return snapshot.inventory().stream().filter(value->value.character().equals(character)&&value.item().equals(item)).map(FishingState.ItemStack::amount).findFirst().orElse(BigInteger.ZERO);}
|
||||
public Optional<FishingState.Trophy> trophy(UUID id){return snapshot.trophies().stream().filter(value->value.id().equals(id)).findFirst();}
|
||||
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 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<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<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));}
|
||||
public FishingState.Reward unclaimed(UUID character){return snapshot.allocations().stream().filter(value->value.character().equals(character)).map(FishingState.Allocation::reward).reduce(FishingState.Reward.empty(),FishingState.Reward::add);}
|
||||
public Optional<FishingState.Claim> pendingClaim(UUID character){return snapshot.claims().stream().filter(value->value.character().equals(character)).findFirst();}
|
||||
public CompletableFuture<Optional<FishingState.Claim>> beginClaim(UUID character){return mutate(state->{var existing=state.claims().stream().filter(value->value.character().equals(character)).findFirst();if(existing.isPresent())return result(state,existing);var allocations=state.allocations().stream().filter(value->value.character().equals(character)).toList();if(allocations.isEmpty())return result(state,Optional.empty());var ids=allocations.stream().map(FishingState.Allocation::id).collect(java.util.stream.Collectors.toSet());BigInteger xp=allocations.stream().map(FishingState.Allocation::xp).reduce(BigInteger.ZERO,BigInteger::add),coins=allocations.stream().map(FishingState.Allocation::coins).reduce(BigInteger.ZERO,BigInteger::add);UUID id=UUID.nameUUIDFromBytes((character+":"+ids.stream().sorted().map(UUID::toString).collect(java.util.stream.Collectors.joining(","))).getBytes(StandardCharsets.UTF_8));var claim=new FishingState.Claim(id,character,ids,xp,coins);var claims=new ArrayList<>(state.claims());claims.add(claim);return result(copy(state,state.inventory(),state.catches(),state.catchReceipts(),state.submissions(),state.completions(),state.allocations(),claims),Optional.of(claim));});}
|
||||
public CompletableFuture<Boolean> finishClaim(UUID operation){return mutate(state->{var claim=state.claims().stream().filter(value->value.id().equals(operation)).findFirst().orElse(null);if(claim==null)return result(state,false);var allocations=state.allocations().stream().filter(value->!claim.allocations().contains(value.id())).toList();var claims=state.claims().stream().filter(value->!value.id().equals(operation)).toList();return result(copy(state,state.inventory(),state.catches(),state.catchReceipts(),state.submissions(),state.completions(),allocations,claims),true);});}
|
||||
private static boolean sameTrophyCompletion(FishingState.Trophy left,FishingState.Trophy right){return left.id().equals(right.id())&&left.type()==right.type()&&left.carrier().equals(right.carrier())&&left.catalog().equals(right.catalog())&&left.site().equals(right.site())&&left.quest().equals(right.quest())&&left.name().equals(right.name())&&left.origin().equals(right.origin())&&left.rewards().equals(right.rewards());}
|
||||
private static boolean sameSubmission(FishingState.Submission left,FishingState.Submission right){return left.id().equals(right.id())&&left.quest().equals(right.quest())&&left.catalog().equals(right.catalog())&&left.site().equals(right.site())&&left.submitter().equals(right.submitter())&&left.item().equals(right.item())&&left.quantity().equals(right.quantity())&&left.rewards().equals(right.rewards());}
|
||||
private static BigInteger quantity(FishingState state,UUID character,UUID item){return state.inventory().stream().filter(value->value.character().equals(character)&&value.item().equals(item)).map(FishingState.ItemStack::amount).findFirst().orElse(BigInteger.ZERO);}
|
||||
private static UUID allocationId(UUID submission,UUID character){return UUID.nameUUIDFromBytes(("fishing:"+submission+":"+character).getBytes(StandardCharsets.UTF_8));}
|
||||
private static UUID trophyAllocationId(UUID trophy,UUID character){return UUID.nameUUIDFromBytes(("trophy:"+trophy+":"+character).getBytes(StandardCharsets.UTF_8));}
|
||||
private static FishingState copy(FishingState original,List<FishingState.ItemStack> inventory,List<FishingState.Catch> catches,Set<UUID> receipts,List<FishingState.Submission> submissions,Set<FishingState.Completion> completions,List<FishingState.Allocation> allocations,List<FishingState.Claim> claims){return new FishingState(inventory,original.trophies(),original.trophyReceipts(),catches,receipts,submissions,completions,allocations,claims);}
|
||||
private static FishingState copyTrophies(FishingState original,List<FishingState.Trophy> trophies,Set<UUID> receipts,List<FishingState.Allocation> allocations){return new FishingState(original.inventory(),trophies,receipts,original.catches(),original.catchReceipts(),original.submissions(),original.completions(),allocations,original.claims());}
|
||||
private record Result<T>(FishingState state,T value){}
|
||||
private static <T>Result<T> result(FishingState state,T value){return new Result<>(state,value);}
|
||||
private <T>CompletableFuture<T> mutate(java.util.function.Function<FishingState,Result<T>> operation){var future=new CompletableFuture<T>();synchronized(this){if(closed){future.completeExceptionally(new IOException("Fishing directory closed"));return future;}pending++;}worker.execute(()->{try{var result=operation.apply(snapshot);if(result.state()!=snapshot){repository.save(result.state());snapshot=result.state();}future.complete(result.value());}catch(Throwable failure){future.completeExceptionally(failure);}finally{finished();}});return future;}
|
||||
private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}
|
||||
public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}
|
||||
@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package games.dmg.fantasystick.adventure;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.*;
|
||||
|
||||
/** Durable character-owned fishing stock, recoverable operations and non-expiring reward shares. */
|
||||
public record FishingState(List<ItemStack> inventory,List<Trophy> trophies,Set<UUID> trophyReceipts,
|
||||
List<Catch> catches,Set<UUID> catchReceipts,List<Submission> submissions,
|
||||
Set<Completion> completions,List<Allocation> allocations,List<Claim> claims) {
|
||||
public record Reward(BigInteger xp,BigInteger coins) {
|
||||
public Reward {Objects.requireNonNull(xp);Objects.requireNonNull(coins);if(xp.signum()<0||coins.signum()<0)throw new IllegalArgumentException("Invalid fishing reward");}
|
||||
public static Reward empty(){return new Reward(BigInteger.ZERO,BigInteger.ZERO);}
|
||||
public Reward add(Reward other){return new Reward(xp.add(other.xp),coins.add(other.coins));}
|
||||
}
|
||||
public record ItemStack(UUID character,UUID item,UUID site,String name,BigInteger amount) {
|
||||
public ItemStack {Objects.requireNonNull(character);Objects.requireNonNull(item);Objects.requireNonNull(site);name=validName(name);Objects.requireNonNull(amount);if(name.isBlank()||amount.signum()<=0)throw new IllegalArgumentException("Invalid fishing item stack");}
|
||||
}
|
||||
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 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 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 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);}
|
||||
}
|
||||
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 Reward reward(){return new Reward(xp,coins);}
|
||||
}
|
||||
public record Claim(UUID id,UUID character,Set<UUID> allocations,BigInteger xp,BigInteger coins) {
|
||||
public Claim {Objects.requireNonNull(id);Objects.requireNonNull(character);allocations=Set.copyOf(allocations);Objects.requireNonNull(xp);Objects.requireNonNull(coins);if(allocations.isEmpty()||xp.signum()<0||coins.signum()<0||xp.signum()==0&&coins.signum()==0)throw new IllegalArgumentException("Invalid fishing claim");}
|
||||
}
|
||||
public FishingState {
|
||||
inventory=List.copyOf(inventory);trophies=List.copyOf(trophies);trophyReceipts=Set.copyOf(trophyReceipts);catches=List.copyOf(catches);catchReceipts=Set.copyOf(catchReceipts);
|
||||
submissions=List.copyOf(submissions);completions=Set.copyOf(completions);allocations=List.copyOf(allocations);claims=List.copyOf(claims);
|
||||
unique(inventory.stream().map(value->value.character()+":"+value.item()).toList());
|
||||
unique(trophies.stream().map(Trophy::id).toList());unique(catches.stream().map(Catch::id).toList());unique(submissions.stream().map(Submission::id).toList());
|
||||
unique(allocations.stream().map(Allocation::id).toList());unique(claims.stream().map(Claim::id).toList());
|
||||
var redeemedTrophies=trophyReceipts;if(trophies.stream().anyMatch(value->redeemedTrophies.contains(value.id())))throw new IllegalArgumentException("Redeemed trophy remains in Inventory");
|
||||
var completedCatchReceipts=catchReceipts;if(catches.stream().anyMatch(value->completedCatchReceipts.contains(value.id())))throw new IllegalArgumentException("Pending catch already completed");
|
||||
var allocated=allocations.stream().collect(java.util.stream.Collectors.toMap(Allocation::id,value->value));
|
||||
var claiming=new HashSet<UUID>();
|
||||
for(var claim:claims){if(!claiming.add(claim.character()))throw new IllegalArgumentException("Duplicate fishing claim");BigInteger xp=BigInteger.ZERO,coins=BigInteger.ZERO;for(UUID id:claim.allocations()){var value=allocated.get(id);if(value==null||!value.character().equals(claim.character()))throw new IllegalArgumentException("Invalid fishing claim allocation");xp=xp.add(value.xp());coins=coins.add(value.coins());}if(!xp.equals(claim.xp())||!coins.equals(claim.coins()))throw new IllegalArgumentException("Invalid fishing claim total");}
|
||||
}
|
||||
public FishingState(List<ItemStack> inventory,List<Catch> catches,Set<UUID> catchReceipts,List<Submission> submissions,Set<Completion> completions,List<Allocation> allocations,List<Claim> claims){this(inventory,List.of(),Set.of(),catches,catchReceipts,submissions,completions,allocations,claims);}
|
||||
public static FishingState empty(){return new FishingState(List.of(),List.of(),Set.of(),List.of(),Set.of(),List.of(),Set.of(),List.of(),List.of());}
|
||||
private static String validName(String value){Objects.requireNonNull(value);value=value.strip();if(value.codePointCount(0,value.length())>64||value.codePoints().anyMatch(c->{int type=Character.getType(c);return type==Character.CONTROL||type==Character.FORMAT||type==Character.SURROGATE;}))throw new IllegalArgumentException("Invalid fishing item name");return value;}
|
||||
private static void unique(List<?> values){if(new HashSet<>(values).size()!=values.size())throw new IllegalArgumentException("Duplicate fishing identity");}
|
||||
}
|
||||
@@ -29,14 +29,17 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
private final games.dmg.fantasystick.ability.AbilityEngine abilityEngine;
|
||||
private final games.dmg.fantasystick.passive.PassiveRegistry passives;
|
||||
private final games.dmg.fantasystick.passive.PassiveEngine passiveEngine;
|
||||
private games.dmg.fantasystick.adventure.FishingDirectory fishing;
|
||||
private final org.bukkit.Server server;
|
||||
private final Map<UUID, Object> pending = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, Object> adminPending = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, AdminView> adminViews = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, SavedView> savedViews = new ConcurrentHashMap<>();
|
||||
private volatile boolean closed;
|
||||
private static final int VOLUME_SIZE = 30; // Reserve room for linked ability details, navigation and party pages.
|
||||
private static final net.kyori.adventure.key.Key PAGES = net.kyori.adventure.key.Key.key("fantasystick", "character_pages");
|
||||
private static final net.kyori.adventure.key.Key ADMIN_PAGES = net.kyori.adventure.key.Key.key("fantasystick", "admin_character_pages");
|
||||
private static final net.kyori.adventure.key.Key SAVED_PAGES = net.kyori.adventure.key.Key.key("fantasystick", "saved_character_pages");
|
||||
private static final net.kyori.adventure.key.Key STATS = net.kyori.adventure.key.Key.key("fantasystick", "character_stat");
|
||||
private static final net.kyori.adventure.key.Key ABILITY_UNLOCK = net.kyori.adventure.key.Key.key("fantasystick", "ability_unlock");
|
||||
private static final net.kyori.adventure.key.Key ABILITY_UNLEARN = net.kyori.adventure.key.Key.key("fantasystick", "ability_unlearn");
|
||||
@@ -49,6 +52,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
private record AdminView(String nonce, UUID administrator, org.bukkit.entity.Player target,
|
||||
games.dmg.fantasystick.session.EquipmentSessions.Active active,
|
||||
int previous, int next) { }
|
||||
private record SavedView(String nonce, UUID administrator, UUID character, int previous, int next) { }
|
||||
private record StatView(String nonce, ControlInput input, java.math.BigInteger revision) { }
|
||||
private record AbilityView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
|
||||
private record PassiveView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
|
||||
@@ -98,6 +102,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
this.partyControls = new PartyBookControls(parties, server, names, this::accept, this::report);
|
||||
}
|
||||
|
||||
public void attachFishingInventory(games.dmg.fantasystick.adventure.FishingDirectory fishing){if(closed||this.fishing!=null)throw new IllegalStateException("Fishing inventory already attached or books closed");this.fishing=Objects.requireNonNull(fishing);}
|
||||
|
||||
@Override public void accept(ControlInput input) { load(input, 0); }
|
||||
|
||||
public void openReadOnly(org.bukkit.entity.Player administrator, org.bukkit.entity.Player target) {
|
||||
@@ -123,6 +129,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
UUID targetId = target.getUniqueId();
|
||||
Object ticket = new Object();
|
||||
adminViews.remove(administratorId);
|
||||
savedViews.remove(administratorId);
|
||||
adminPending.put(administratorId, ticket);
|
||||
characters.find(active.character()).whenComplete((record, failure) -> {
|
||||
if (closed || adminPending.get(administratorId) != ticket) return;
|
||||
@@ -164,6 +171,55 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
});
|
||||
}
|
||||
|
||||
public void openSavedReadOnly(org.bukkit.entity.Player administrator, UUID character) {
|
||||
Objects.requireNonNull(administrator); Objects.requireNonNull(character);
|
||||
if (closed) return;
|
||||
if (!administrator.isOnline() || !administrator.hasPermission("fantasystick.admin")) {
|
||||
if (administrator.isOnline()) administrator.sendMessage("Saved character view refused: administrator permission is required.");
|
||||
return;
|
||||
}
|
||||
loadSavedReadOnly(administrator, character, 0);
|
||||
}
|
||||
|
||||
private void loadSavedReadOnly(org.bukkit.entity.Player administrator, UUID character, int offset) {
|
||||
if (closed) return;
|
||||
UUID administratorId = administrator.getUniqueId();
|
||||
Object ticket = new Object();
|
||||
adminViews.remove(administratorId);
|
||||
savedViews.remove(administratorId);
|
||||
adminPending.put(administratorId, ticket);
|
||||
characters.inspect(character).whenComplete((record, failure) -> {
|
||||
if (closed || adminPending.get(administratorId) != ticket) return;
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed || !adminPending.remove(administratorId, ticket)) return;
|
||||
if (!administrator.isOnline() || !administrator.hasPermission("fantasystick.admin")) {
|
||||
if (administrator.isOnline()) administrator.sendMessage("Saved character view refused: administrator permission changed.");
|
||||
return;
|
||||
}
|
||||
if (failure != null) {
|
||||
report(new Failure(administratorId, character, failure));
|
||||
administrator.sendMessage("Saved character view could not be loaded; no state was changed.");
|
||||
return;
|
||||
}
|
||||
if (record.isEmpty()) {
|
||||
administrator.sendMessage("Saved character view refused: character not found.");
|
||||
return;
|
||||
}
|
||||
try { administrator.openBook(renderSavedReadOnly(record.orElseThrow(), administrator, offset)); }
|
||||
catch (RuntimeException error) {
|
||||
savedViews.remove(administratorId);
|
||||
report(new Failure(administratorId, character, error));
|
||||
administrator.sendMessage("Saved character view could not be opened; no state was changed.");
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException rejected) {
|
||||
adminPending.remove(administratorId, ticket);
|
||||
report(new Failure(administratorId, character, rejected));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void load(ControlInput input, int offset) {
|
||||
if (closed || input.kind() != ControlInput.Kind.BOOK || input.gesture() != ControlInput.Gesture.USE
|
||||
|| !input.stillCurrent()) return;
|
||||
@@ -200,19 +256,24 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
}
|
||||
|
||||
private ItemStack render(CharacterRecord record, ControlInput input, int offset) {
|
||||
return renderBook(record, input.player(), input, offset, null, null);
|
||||
return renderBook(record, input.player(), input, offset, null, null, false);
|
||||
}
|
||||
|
||||
private ItemStack renderReadOnly(CharacterRecord record, org.bukkit.entity.Player administrator,
|
||||
org.bukkit.entity.Player target,
|
||||
games.dmg.fantasystick.session.EquipmentSessions.Active active,
|
||||
int offset) {
|
||||
return renderBook(record, target, null, offset, administrator, active);
|
||||
return renderBook(record, target, null, offset, administrator, active, false);
|
||||
}
|
||||
|
||||
private ItemStack renderSavedReadOnly(CharacterRecord record, org.bukkit.entity.Player administrator, int offset) {
|
||||
return renderBook(record, administrator, null, offset, administrator, null, true);
|
||||
}
|
||||
|
||||
private ItemStack renderBook(CharacterRecord record, org.bukkit.entity.Player subject,
|
||||
ControlInput input, int offset, org.bukkit.entity.Player administrator,
|
||||
games.dmg.fantasystick.session.EquipmentSessions.Active adminActive) {
|
||||
games.dmg.fantasystick.session.EquipmentSessions.Active adminActive,
|
||||
boolean savedState) {
|
||||
boolean interactive = input != null;
|
||||
var item = new ItemStack(Material.WRITTEN_BOOK);
|
||||
var meta = (BookMeta) item.getItemMeta();
|
||||
@@ -220,10 +281,12 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
meta.setAuthor("Fantasy Stick");
|
||||
|
||||
var statView = interactive ? new StatView(UUID.randomUUID().toString(), input, record.revision()) : null;
|
||||
var allOverview = overviewPages(record, subject, statView);
|
||||
var allOverview = overviewPages(record, subject, statView, savedState);
|
||||
int start = Math.min(offset, (allOverview.size() - 1) / VOLUME_SIZE * VOLUME_SIZE);
|
||||
int end = Math.min(start + VOLUME_SIZE, allOverview.size());
|
||||
var pages = new java.util.ArrayList<Component>(allOverview.subList(start, end));
|
||||
var pages = new java.util.ArrayList<Component>();
|
||||
if (savedState && start == 0) pages.addAll(savedSummaryPages(record));
|
||||
pages.addAll(allOverview.subList(start, end));
|
||||
if(end==allOverview.size())pages.add(gearPage(record));
|
||||
if (interactive && record.unspentPoints().signum() > 0 && start == 0)
|
||||
statViews.put(input.session().player(), statView);
|
||||
@@ -304,6 +367,14 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
.append(pageButton("Previous pages", view, 0));
|
||||
if (view.next() >= 0) navigation = navigation.append(Component.newline())
|
||||
.append(pageButton("Next pages", view, 1));
|
||||
} else if (savedState) {
|
||||
var view = new SavedView(UUID.randomUUID().toString(), administrator.getUniqueId(), record.id(),
|
||||
start > 0 ? start - VOLUME_SIZE : -1, end < allOverview.size() ? end : -1);
|
||||
savedViews.put(administrator.getUniqueId(), view);
|
||||
if (view.previous() >= 0) navigation = navigation.append(Component.newline())
|
||||
.append(savedPageButton("Previous pages", view, 0));
|
||||
if (view.next() >= 0) navigation = navigation.append(Component.newline())
|
||||
.append(savedPageButton("Next pages", view, 1));
|
||||
} else {
|
||||
var view = new AdminView(UUID.randomUUID().toString(), administrator.getUniqueId(),
|
||||
subject, adminActive, start > 0 ? start - VOLUME_SIZE : -1,
|
||||
@@ -316,18 +387,37 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
}
|
||||
pages.add(navigation);
|
||||
}
|
||||
if(end==allOverview.size())pages.addAll(inventoryPages(record.id()));
|
||||
if (end == allOverview.size()) pages.addAll(interactive
|
||||
? partyControls.pages(input) : partyControls.readOnlyPages(subject.getUniqueId()));
|
||||
? partyControls.pages(input) : savedState
|
||||
? partyControls.readOnlyCharacterPages(record.id())
|
||||
: partyControls.readOnlyPages(subject.getUniqueId()));
|
||||
meta.pages(pages);
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
private java.util.List<Component> inventoryPages(UUID character){var directory=fishing;if(directory==null)return java.util.List.of();var values=directory.inventory(character);var trophies=directory.trophies(character);var builder=new PageBuilder();builder.text("Inventory");builder.blank();if(values.isEmpty()&&trophies.isEmpty())builder.text("No quest items.");else{int shown=0;for(var value:values){if(shown++>=256)break;builder.text(value.name()+": "+value.amount());builder.text("Fishing origin: "+value.site());}for(var trophy:trophies){if(shown++>=256)break;builder.text(trophy.name()+": 1");builder.text("Trophy origin: "+trophy.origin());}if(values.size()+trophies.size()>256)builder.text("More items are retained.");}return builder.pages();}
|
||||
|
||||
private Component gearPage(CharacterRecord record){return Component.text("Gear\n\nHead: Cosmetic\nChestplate: "+tier(record.gear().chestplate())+"\nLeggings: "+tier(record.gear().leggings())+"\nBoots: "+tier(record.gear().boots()));}
|
||||
private static String tier(games.dmg.fantasystick.persistence.GearTier tier){String value=tier.name().toLowerCase(java.util.Locale.ROOT);return Character.toUpperCase(value.charAt(0))+value.substring(1);}
|
||||
|
||||
private java.util.List<Component> savedSummaryPages(CharacterRecord record) {
|
||||
var builder = new PageBuilder();
|
||||
boolean active = sessions.activeCharacter(record.id()).filter(sessions::current).isPresent();
|
||||
builder.text("Saved state");
|
||||
builder.blank();
|
||||
builder.text("Character UUID:");
|
||||
builder.text(record.id().toString());
|
||||
builder.text(active ? "Active" : "Inactive");
|
||||
builder.text(record.dead() ? "Dead" : "Alive");
|
||||
if (record.deathId() != null) builder.text("Death: " + record.deathId());
|
||||
if (record.diedAt() != null) builder.text("Died: " + record.diedAt());
|
||||
return builder.pages();
|
||||
}
|
||||
|
||||
private java.util.List<Component> overviewPages(CharacterRecord record, org.bukkit.entity.Player subject,
|
||||
StatView view) {
|
||||
StatView view, boolean savedState) {
|
||||
var builder = new PageBuilder();
|
||||
builder.text(record.name());
|
||||
builder.blank();
|
||||
@@ -343,8 +433,16 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
builder.text("XP: " + xp);
|
||||
builder.text("Unspent points: " + record.unspentPoints());
|
||||
builder.text("Coins: " + record.coins());
|
||||
builder.text("Health: " + java.math.BigDecimal.valueOf(subject.getHealth()).min(maximum).stripTrailingZeros().toPlainString()
|
||||
var health = savedState ? record.health() : java.math.BigDecimal.valueOf(subject.getHealth()).min(maximum);
|
||||
builder.text((savedState ? "Saved health: " : "Health: ") + health.stripTrailingZeros().toPlainString()
|
||||
+ "/" + maximum.stripTrailingZeros().toPlainString());
|
||||
if (savedState) {
|
||||
builder.blank();
|
||||
builder.text("Saved cooldowns");
|
||||
if (record.cooldowns().isEmpty()) builder.text("None");
|
||||
else record.cooldowns().entrySet().stream().sorted(java.util.Map.Entry.comparingByKey())
|
||||
.forEach(entry -> builder.text(entry.getKey() + ": " + entry.getValue()));
|
||||
}
|
||||
return builder.pages();
|
||||
}
|
||||
|
||||
@@ -585,6 +683,28 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + view.nonce() + ":" + direction + "\"")));
|
||||
}
|
||||
|
||||
private static Component savedPageButton(String label, SavedView view, int direction) {
|
||||
return Component.text("[" + label + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(SAVED_PAGES,
|
||||
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + view.nonce() + ":" + direction + "\"")));
|
||||
}
|
||||
|
||||
@org.bukkit.event.EventHandler
|
||||
public void savedPageAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
|
||||
if (closed || !SAVED_PAGES.equals(event.getIdentifier())
|
||||
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return;
|
||||
var administrator = connection.getPlayer();
|
||||
var view = savedViews.get(administrator.getUniqueId());
|
||||
if (view == null || event.getTag() == null || !view.administrator().equals(administrator.getUniqueId())) return;
|
||||
String raw = event.getTag().string();
|
||||
if (raw.length() > 64) return;
|
||||
var match = PAGE_PAYLOAD.matcher(raw);
|
||||
if (!match.matches() || !view.nonce().equals(match.group(1))) return;
|
||||
int offset = match.group(2).equals("0") ? view.previous() : view.next();
|
||||
if (offset < 0 || !savedViews.remove(administrator.getUniqueId(), view)) return;
|
||||
loadSavedReadOnly(administrator, view.character(), offset);
|
||||
}
|
||||
|
||||
@org.bukkit.event.EventHandler
|
||||
public void adminPageAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
|
||||
if (closed || !ADMIN_PAGES.equals(event.getIdentifier())
|
||||
@@ -784,6 +904,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
pending.remove(owner);
|
||||
adminPending.remove(owner);
|
||||
adminViews.remove(owner);
|
||||
savedViews.remove(owner);
|
||||
views.remove(owner);
|
||||
statViews.remove(owner);
|
||||
abilityViews.remove(owner);
|
||||
@@ -792,6 +913,6 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
closed = true; pending.clear(); adminPending.clear(); adminViews.clear(); views.clear(); statViews.clear(); abilityViews.clear(); passiveViews.clear(); partyControls.close();
|
||||
closed = true; pending.clear(); adminPending.clear(); adminViews.clear(); savedViews.clear(); views.clear(); statViews.clear(); abilityViews.clear(); passiveViews.clear(); partyControls.close();
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,11 @@ public final class PartyBookControls implements AutoCloseable {
|
||||
return rootPages(Objects.requireNonNull(account));
|
||||
}
|
||||
|
||||
public List<Component> readOnlyCharacterPages(UUID character) {
|
||||
if (closed) return List.of();
|
||||
return rootPages(parties.partyOfCharacter(Objects.requireNonNull(character)));
|
||||
}
|
||||
|
||||
private List<Component> render(ControlInput input, Menu menu, int offset) {
|
||||
if (closed || !input.stillCurrent()) return List.of();
|
||||
var build = new Pages(input);
|
||||
@@ -104,8 +109,11 @@ public final class PartyBookControls implements AutoCloseable {
|
||||
}
|
||||
|
||||
private List<Component> rootPages(UUID owner) {
|
||||
return rootPages(parties.partyOf(owner));
|
||||
}
|
||||
|
||||
private List<Component> rootPages(java.util.Optional<PartyRoster.Party> party) {
|
||||
var pages = new ArrayList<Component>();
|
||||
var party = parties.partyOf(owner);
|
||||
Component summary = Component.text("Party\n\n");
|
||||
if (party.isEmpty()) {
|
||||
pages.add(summary.append(Component.text("No party.\n\nVisit a Party\nRegistrar.\nCreate, join, or\nmanage one.")));
|
||||
|
||||
@@ -36,15 +36,15 @@ public final class CharacterAnvilNames implements Listener {
|
||||
public void onTaken(AnvilTakeResultEvent event) {
|
||||
var sheets = new CharacterSheets();
|
||||
var result = event.getResult();
|
||||
var id = sheets.identify(result);
|
||||
if (id.isEmpty()) return;
|
||||
var identity = sheets.identity(result);
|
||||
if (identity.isEmpty()) return;
|
||||
var input = event.getInventory().getItem(0);
|
||||
if (!id.equals(sheets.identify(input))) return;
|
||||
if (!identity.equals(sheets.identity(input))) return;
|
||||
String name = name(result);
|
||||
if (name.equals(name(input))) return;
|
||||
var player = event.getPlayer().getUniqueId();
|
||||
var character = id.orElseThrow();
|
||||
storage.renameCharacter(character, name).whenComplete((renamed, failure) -> {
|
||||
var character = identity.orElseThrow().character();
|
||||
storage.renameCharacter(character, name, identity.orElseThrow().generation()).whenComplete((renamed, failure) -> {
|
||||
if (failure != null || renamed.isEmpty()) { failures.accept(new Failure(player, character, failure)); return; }
|
||||
try { acknowledged.accept(renamed.orElseThrow()); }
|
||||
catch (RuntimeException | LinkageError error) { failures.accept(new Failure(player, character, error)); }
|
||||
|
||||
@@ -20,30 +20,31 @@ public final class DungeonCommands {
|
||||
private final Plugin plugin;private final DungeonDirectory directory;private final DungeonController controller;private final games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures;private final Consumer<Runnable> nextTick;private final games.dmg.fantasystick.adventure.AdventureLocationPresentation locations;private final Function<UUID,CompletableFuture<Optional<CharacterRecord>>> characters;private final Predicate<UUID> activeCharacter;private final Predicate<UUID> partyCharacter;private final CharacterSheets sheets=new CharacterSheets();private final AdventureAdminTeleport teleports;
|
||||
public DungeonCommands(Plugin plugin,DungeonDirectory directory,DungeonController controller,games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures,Consumer<Runnable> nextTick){this(plugin,directory,controller,adventures,nextTick,id->CompletableFuture.completedFuture(Optional.empty()),id->false,id->false);}
|
||||
public DungeonCommands(Plugin plugin,DungeonDirectory directory,DungeonController controller,games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures,Consumer<Runnable> nextTick,Function<UUID,CompletableFuture<Optional<CharacterRecord>>> characters,Predicate<UUID> activeCharacter,Predicate<UUID> partyCharacter){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.controller=Objects.requireNonNull(controller);this.adventures=Objects.requireNonNull(adventures);this.nextTick=Objects.requireNonNull(nextTick);this.characters=Objects.requireNonNull(characters);this.activeCharacter=Objects.requireNonNull(activeCharacter);this.partyCharacter=Objects.requireNonNull(partyCharacter);locations=new games.dmg.fantasystick.adventure.AdventureLocationPresentation(plugin.getServer());teleports=new AdventureAdminTeleport(plugin,nextTick);}
|
||||
public void execute(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usage(sender);return;}try{switch(args[1]){case"create"->create(sender,args);case"list"->list(sender,args);case"remove"->remove(sender,args);case"edit"->edit(sender,args);case"entrance"->entrance(sender,args);case"room"->room(sender,args);case"boss"->bossCommand(sender,args);case"teleport"->teleport(sender,args);case"enable"->enable(sender,args);case"disable"->disable(sender,args);case"open"->open(sender,args);case"close"->close(sender,args);default->usage(sender);}}catch(IllegalArgumentException invalid){sender.sendMessage("Dungeon command refused: "+invalid.getMessage()+".");}}
|
||||
private void create(CommandSender sender,String[] args){if(args.length<3||!(sender instanceof Player player)){usage(sender);return;}var location=player.getLocation();String name=join(args,2);if(name.isBlank()){usage(sender);return;}directory.create(name,Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Created disabled dungeon "+value.id()+" ("+value.name()+").":"Dungeon creation failed; desired state is unchanged.")));}
|
||||
private void list(CommandSender sender,String[] args){if(args.length!=2){usage(sender);return;}if(directory.snapshot().dungeons().isEmpty()){sender.sendMessage("No dungeons registered.");return;}directory.snapshot().dungeons().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(value->sender.sendMessage(value.id()+" "+value.name()+" at "+locations.format(value.world(),value.entrance().x(),value.entrance().y(),value.entrance().z())+"; "+(value.enabled()?"enabled":"disabled")+"; rooms="+value.rooms().size()+"; maximum="+value.maximumXp()+" XP, "+value.maximumCoins()+" coins."));}
|
||||
public void execute(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usage(sender);return;}try{switch(args[1]){case"create"->create(sender,args);case"list"->list(sender,args);case"mode"->mode(sender,args);case"remove"->remove(sender,args);case"edit"->edit(sender,args);case"entrance"->entrance(sender,args);case"room"->room(sender,args);case"reward"->reward(sender,args);case"boss"->bossCommand(sender,args);case"teleport"->teleport(sender,args);case"enable"->enable(sender,args);case"disable"->disable(sender,args);case"open"->open(sender,args);case"close"->close(sender,args);default->usage(sender);}}catch(IllegalArgumentException invalid){sender.sendMessage("Dungeon command refused: "+invalid.getMessage()+".");}}
|
||||
private void create(CommandSender sender,String[] args){if(args.length<3||!(sender instanceof Player player)){usage(sender);return;}int nameStart=2;var mode=games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;if(args[2].equals("--mode")){if(args.length<5){usage(sender);return;}try{mode=games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){usage(sender);return;}nameStart=4;}else if(args[2].startsWith("--")){usage(sender);return;}String name=join(args,nameStart);if(name.isBlank()){usage(sender);return;}var location=player.getLocation();directory.create(name,Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch(),mode).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Created disabled dungeon "+value.id()+" ("+value.name()+"); mode="+value.participationMode().value()+".":"Dungeon creation failed; desired state is unchanged.")));}
|
||||
private void list(CommandSender sender,String[] args){if(args.length!=2){usage(sender);return;}if(directory.snapshot().dungeons().isEmpty()){sender.sendMessage("No dungeons registered.");return;}directory.snapshot().dungeons().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(value->sender.sendMessage(value.id()+" "+value.name()+" at "+locations.format(value.world(),value.entrance().x(),value.entrance().y(),value.entrance().z())+"; "+(value.enabled()?"enabled":"disabled")+"; mode="+value.participationMode().value()+"; rooms="+value.rooms().size()+"; completion="+(value.rewardConfigured()?value.completionReward().xp()+" XP, "+value.completionReward().coins()+" coins":"not configured")+"."));}
|
||||
private void mode(CommandSender sender,String[] args){if(args.length<3||args.length>4){usage(sender);return;}UUID id=id(args[2]);var current=id==null?null:directory.find(id).orElse(null);if(current==null){sender.sendMessage("Dungeon not found.");return;}if(args.length==3){sender.sendMessage("Dungeon "+id+" mode="+current.participationMode().value()+".");return;}games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode requested;try{requested=games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){usage(sender);return;}directory.setMode(id,current.revision(),requested).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Updated dungeon "+id+" mode="+value.participationMode().value()+" for future acceptances.":"Dungeon mode update refused; the acknowledged definition is unchanged or stale.")));}
|
||||
private void remove(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}if(directory.custodyForDungeon(id).isPresent()){sender.sendMessage("Recover character boss custody before removing this dungeon.");return;}controller.closeOffer(id).thenCompose(ignored->adventures.removeDungeonSite(id)).thenCompose(ignored->directory.remove(id)).whenComplete((removed,failure)->respond(sender,()->sender.sendMessage(failure!=null?"Dungeon removal failed; desired state is retained.":removed.isPresent()?"Removed dungeon "+id+".":"Dungeon not found.")));}
|
||||
private void edit(CommandSender sender,String[] args){if(!(sender instanceof Player player)){usage(sender);return;}if(args.length==3&&args[2].equals("done")){sender.sendMessage(controller.done(player.getUniqueId())?"Dungeon editing ended; the dungeon remains disabled.":"No dungeon is being edited.");return;}if(args.length<4||args.length>5||!args[2].equals("start")||args.length==5&&!args[4].equals("confirm")){usage(sender);return;}UUID id=id(args[3]);if(id==null){usage(sender);return;}controller.start(player,id,args.length==5).whenComplete((status,failure)->respond(sender,()->{if(failure!=null){plugin.getLogger().log(java.util.logging.Level.WARNING,"Dungeon edit transition failed",failure);sender.sendMessage("Dungeon edit transition failed; retained state is authoritative.");return;}sender.sendMessage(switch(status){case STARTED->"Editing dungeon "+id+"; it is disabled.";case CONFIRM_REQUIRED->"Dungeon has offered, accepted or progressed work; repeat with exact confirm to close and expire it.";case BUSY->"Dungeon editing refused: the editor or dungeon already has an editing lease.";case NOT_FOUND->"Dungeon not found.";});}));}
|
||||
private void entrance(CommandSender sender,String[] args){if(args.length!=3||!args[2].equals("set")||!(sender instanceof Player player)){usage(sender);return;}UUID dungeon=editing(player);if(dungeon==null)return;var location=player.getLocation();if(location.getWorld()==null||!directory.find(dungeon).orElseThrow().world().equals(location.getWorld().getUID())){sender.sendMessage("Dungeon entrance must remain in its world.");return;}directory.setEntrance(dungeon,location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->saved(sender,failure,"Updated dungeon entrance."));}
|
||||
private void room(CommandSender sender,String[] args){if(!(sender instanceof Player player)||args.length<3){usage(sender);return;}UUID dungeon=editing(player);if(dungeon==null)return;switch(args[2]){case"create"->roomCreate(sender,player,dungeon,args);case"list"->{if(args.length!=3){usage(sender);return;}var value=directory.find(dungeon).orElseThrow();if(value.rooms().isEmpty())sender.sendMessage("No rooms defined.");else value.rooms().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.displayName()+" "+v.bounds()+"; mobs="+v.mobs().size()+"; effects="+v.effects().size()+"; reward="+v.reward().xp()+" XP, "+v.reward().coins()+" coins; boss="+(v.boss()==null?"none":v.boss().type().name().toLowerCase(Locale.ROOT))));}case"remove"->{if(args.length!=4){usage(sender);return;}UUID room=id(args[3]);if(room==null){usage(sender);return;}directory.removeRoom(dungeon,room).whenComplete((value,failure)->{if(failure==null&&value.isPresent())controller.definitionChanged(dungeon);respond(sender,()->sender.sendMessage(failure!=null?"Room removal refused: it would overlap, disconnect or could not be saved.":value.isPresent()?"Removed room "+room+".":"Room not found."));});}case"mob"->mob(sender,player,dungeon,args);case"effect"->effect(sender,player,dungeon,args);case"reward"->reward(sender,player,dungeon,args);case"boss"->boss(sender,player,dungeon,args);default->usage(sender);}}
|
||||
private void room(CommandSender sender,String[] args){if(!(sender instanceof Player player)||args.length<3){usage(sender);return;}UUID dungeon=editing(player);if(dungeon==null)return;switch(args[2]){case"create"->roomCreate(sender,player,dungeon,args);case"list"->{if(args.length!=3){usage(sender);return;}var value=directory.find(dungeon).orElseThrow();if(value.rooms().isEmpty())sender.sendMessage("No rooms defined.");else value.rooms().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.displayName()+" "+v.bounds()+"; mobs="+v.mobs().size()+"; effects="+v.effects().size()+"; boss="+(v.boss()==null?"none":v.boss().type().name().toLowerCase(Locale.ROOT))));}case"remove"->{if(args.length!=4){usage(sender);return;}UUID room=id(args[3]);if(room==null){usage(sender);return;}directory.removeRoom(dungeon,room).whenComplete((value,failure)->{if(failure==null&&value.isPresent())controller.definitionChanged(dungeon);respond(sender,()->sender.sendMessage(failure!=null?"Room removal refused: it would overlap, disconnect or could not be saved.":value.isPresent()?"Removed room "+room+".":"Room not found."));});}case"mob"->mob(sender,player,dungeon,args);case"effect"->effect(sender,player,dungeon,args);case"boss"->boss(sender,player,dungeon,args);default->usage(sender);}}
|
||||
private void roomCreate(CommandSender sender,Player player,UUID dungeon,String[] args){if(args.length<6){usage(sender);return;}var location=player.getLocation();int x=location.getBlockX(),y=location.getBlockY()-1,z=location.getBlockZ();if(args[3].equals("corner")){if(args.length<7){usage(sender);return;}Integer xs=integer(args[4]),zs=integer(args[5]),height=integer(args[6]);if(xs==null||zs==null||height==null){usage(sender);return;}directory.addCornerRoom(dungeon,join(args,7),x,y,z,xs,zs,height).whenComplete((value,failure)->roomSaved(sender,dungeon,value,failure));return;}if(args[3].equals("radius")){Integer radius=integer(args[4]),height=integer(args[5]);if(radius==null||height==null){usage(sender);return;}directory.addRadiusRoom(dungeon,join(args,6),x,y,z,radius,height).whenComplete((value,failure)->roomSaved(sender,dungeon,value,failure));return;}usage(sender);}
|
||||
private void roomSaved(CommandSender sender,UUID dungeon,DungeonState.Room value,Throwable failure){if(failure==null)controller.definitionChanged(dungeon);String message=failure==null?"Created room "+value.id()+" ("+value.displayName()+").":roomFailure(failure);respond(sender,()->sender.sendMessage(message));}
|
||||
private static String roomFailure(Throwable failure){Throwable cause=failure;while((cause instanceof java.util.concurrent.CompletionException||cause instanceof java.util.concurrent.ExecutionException)&&cause.getCause()!=null)cause=cause.getCause();String detail=cause.getMessage();if(cause instanceof IllegalArgumentException&&"Rooms overlap".equals(detail))return "Room creation refused: it overlaps an existing room.";if(cause instanceof IllegalArgumentException&&detail!=null&&detail.startsWith("Room exceeds configured link distance of ")){String blocks=detail.substring("Room exceeds configured link distance of ".length()).replace(" blocks","");return "Room creation refused: nearest room is farther than the configured "+blocks+"-block link distance.";}if(cause instanceof IllegalArgumentException&&(detail!=null&&(detail.equals("Room dimensions out of range")||detail.equals("Room limit reached"))))return "Room creation refused: dimensions or room limit are out of range.";if(cause instanceof java.io.IOException)return "Room creation failed: persistence is unavailable; desired state is unchanged.";return "Room creation refused: geometry validation failed; desired state is unchanged.";}
|
||||
private void mob(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length>=5&&args[3].equals("add")){var type=entity(args[4]);if(!ordinary(type)){sender.sendMessage("Unsupported living mob type.");return;}var location=player.getLocation();directory.addMob(dungeon,room.id(),type,join(args,5),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->saved(sender,failure,"Added mob slot "+(value==null?"":value.id())+"."));return;}if(args.length==5&&args[3].equals("remove")){UUID id=id(args[4]);if(id==null){usage(sender);return;}directory.removeMob(dungeon,room.id(),id).whenComplete((value,failure)->saved(sender,failure,value!=null&&value.isPresent()?"Removed mob slot "+id+".":"Mob slot not found."));return;}if(args.length==4&&args[3].equals("list")){if(room.mobs().isEmpty())sender.sendMessage("No ordinary mob slots in this room.");else room.mobs().forEach(value->sender.sendMessage(value.id()+" "+value.type().name().toLowerCase(Locale.ROOT)+" "+value.name()));return;}usage(sender);}
|
||||
private void effect(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length>=7&&args[3].equals("add")){DungeonState.EffectTarget target;try{target=DungeonState.EffectTarget.valueOf(args[4].toUpperCase(Locale.ROOT));}catch(IllegalArgumentException invalid){usage(sender);return;}String key=args[5].contains(":")?args[5]:"minecraft:"+args[5];var namespaced=NamespacedKey.fromString(key);Integer level=integer(args[6]);boolean particles=args.length<8||Boolean.parseBoolean(args[7]);if(namespaced==null||PotionEffectType.getByKey(namespaced)==null||level==null||args.length>8||args.length==8&&!Set.of("true","false").contains(args[7])){usage(sender);return;}directory.addEffect(dungeon,room.id(),target,key,level,particles).whenComplete((value,failure)->saved(sender,failure,"Added room effect "+(value==null?"":value.id())+"."));return;}if(args.length==5&&args[3].equals("remove")){UUID id=id(args[4]);if(id==null){usage(sender);return;}directory.removeEffect(dungeon,room.id(),id).whenComplete((value,failure)->saved(sender,failure,value!=null&&value.isPresent()?"Removed room effect "+id+".":"Room effect not found."));return;}usage(sender);}
|
||||
private void reward(CommandSender sender,Player player,UUID dungeon,String[] args){if(args.length!=6||!args[3].equals("set")){usage(sender);return;}var room=standing(sender,player);if(room==null)return;BigInteger xp=whole(args[4]),coins=whole(args[5]);if(xp==null||coins==null){usage(sender);return;}directory.setReward(dungeon,room.id(),xp,coins).whenComplete((value,failure)->saved(sender,failure,"Set room reward to "+xp+" XP and "+coins+" coins."));}
|
||||
private void reward(CommandSender sender,String[] args){if(args.length<3||args.length>5){usage(sender);return;}UUID dungeon=id(args[2]);var current=dungeon==null?null:directory.find(dungeon).orElse(null);if(current==null){sender.sendMessage("Dungeon not found.");return;}if(args.length==3){sender.sendMessage(current.rewardConfigured()?"Dungeon "+dungeon+" completion reward is "+current.completionReward().xp()+" XP and "+current.completionReward().coins()+" coins.":"Dungeon "+dungeon+" completion reward is not configured.");return;}if(current.enabled()||adventures.offerOpen(dungeon)||adventures.active().stream().anyMatch(quest->quest.site().equals(dungeon))){sender.sendMessage("Dungeon reward update requires disabled, closed dungeon state without accepted quests.");return;}if(controller.beingEdited(dungeon)&&(!(sender instanceof Player player)||!controller.editing(player.getUniqueId()).filter(dungeon::equals).isPresent())){sender.sendMessage("Dungeon reward update refused: another administrator owns the editing lease.");return;}BigInteger xp=args.length==5?whole(args[3]):null,coins=args.length==5?whole(args[4]):null;if(xp==null||coins==null){usage(sender);return;}directory.setCompletionReward(dungeon,xp,coins).whenComplete((value,failure)->saved(sender,failure,"Set dungeon completion reward to "+xp+" XP and "+coins+" coins."));}
|
||||
private void boss(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length==5&&args[3].equals("assign-sheet")){assignCharacterBoss(sender,player,dungeon,room,args[4]);return;}if(args.length>=5&&args[3].equals("mob")){var type=entity(args[4]);if(!ordinary(type)){sender.sendMessage("Unsupported living boss type.");return;}var location=player.getLocation();directory.setBoss(dungeon,room.id(),type,join(args,5),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->saved(sender,failure,"Configured native boss "+type.name().toLowerCase(Locale.ROOT)+"."));return;}if(args.length==4&&args[3].equals("clear")){directory.clearBoss(dungeon,room.id()).whenComplete((value,failure)->saved(sender,failure,"Cleared the room boss."));return;}usage(sender);}
|
||||
private void assignCharacterBoss(CommandSender sender,Player player,UUID dungeon,DungeonState.Room room,String rawMultiplier){BigDecimal multiplier;try{if(!rawMultiplier.matches("(?:0|[1-9][0-9]*)(?:\\.[0-9]{1,3})?"))throw new NumberFormatException();multiplier=new BigDecimal(rawMultiplier);}catch(NumberFormatException invalid){usage(sender);return;}var held=player.getInventory().getItemInMainHand();var character=sheets.identify(held).orElse(null);if(character==null||held.getAmount()!=1){sender.sendMessage("Hold exactly one authentic living character sheet in your main hand.");return;}if(physicalCopies(character)!=1){sender.sendMessage("Character boss assignment requires one unambiguous physical sheet.");return;}if(activeCharacter.test(character)){sender.sendMessage("That character has an active equipment session.");return;}if(partyCharacter.test(character)){sender.sendMessage("That character must leave its party before boss assignment.");return;}characters.apply(character).whenComplete((loaded,failure)->{if(failure!=null||loaded==null||loaded.isEmpty()||loaded.orElseThrow().dead()||loaded.orElseThrow().health().signum()<=0){respond(sender,()->sender.sendMessage("Character boss assignment refused: the saved living character could not be verified."));return;}var expected=loaded.orElseThrow();directory.assignCharacterBoss(dungeon,room.id(),expected,multiplier).whenComplete((custody,saveFailure)->{if(saveFailure!=null){respond(sender,()->sender.sendMessage("Character boss assignment failed before sheet custody changed."));return;}characters.apply(character).whenComplete((fresh,readFailure)->respond(sender,()->{if(readFailure!=null||fresh==null||fresh.isEmpty()||!fresh.orElseThrow().equals(expected)||activeCharacter.test(character)||partyCharacter.test(character)){directory.cancelAssignment(dungeon,custody.operation()).whenComplete((ignored,rollbackFailure)->respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the saved character changed.":"Character boss custody was saved but assignment rollback could not be confirmed; recover it before editing.")));return;}var current=player.getInventory().getItemInMainHand();if(!player.isOnline()||physicalCopies(character)!=1||current.getAmount()!=1||sheets.identify(current).filter(character::equals).isEmpty()){directory.cancelAssignment(dungeon,custody.operation()).whenComplete((ignored,rollbackFailure)->respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the held sheet changed.":"Character boss custody was saved but sheet consumption could not be confirmed; recover it before editing.")));return;}player.getInventory().setItemInMainHand(null);sender.sendMessage("Assigned character "+custody.character().name()+" as the dungeon boss at "+custody.multiplier().toPlainString()+"x stats; its sheet is now in durable custody.");}));});});}
|
||||
private void assignCharacterBoss(CommandSender sender,Player player,UUID dungeon,DungeonState.Room room,String rawMultiplier){BigDecimal multiplier;try{if(!rawMultiplier.matches("(?:0|[1-9][0-9]*)(?:\\.[0-9]{1,3})?"))throw new NumberFormatException();multiplier=new BigDecimal(rawMultiplier);}catch(NumberFormatException invalid){usage(sender);return;}var held=player.getInventory().getItemInMainHand();var sheetIdentity=sheets.identity(held).orElse(null);var character=sheetIdentity==null?null:sheetIdentity.character();if(character==null||held.getAmount()!=1){sender.sendMessage("Hold exactly one authentic living character sheet in your main hand.");return;}if(physicalCopies(character)!=1){sender.sendMessage("Character boss assignment requires one unambiguous physical sheet.");return;}if(activeCharacter.test(character)){sender.sendMessage("That character has an active equipment session.");return;}if(partyCharacter.test(character)){sender.sendMessage("That character must leave its party before boss assignment.");return;}characters.apply(character).whenComplete((loaded,failure)->{if(failure!=null||loaded==null||loaded.isEmpty()||loaded.orElseThrow().dead()||loaded.orElseThrow().health().signum()<=0){respond(sender,()->sender.sendMessage("Character boss assignment refused: the saved living character could not be verified."));return;}var expected=loaded.orElseThrow();if(!sheetIdentity.generation().equals(expected.sheetGeneration())||(sheetIdentity.legacy()&&expected.sheetGeneration().signum()!=0)){respond(sender,()->sender.sendMessage("Character boss assignment refused: that sheet generation is no longer authorized."));return;}directory.assignCharacterBoss(dungeon,room.id(),expected,multiplier).whenComplete((custody,saveFailure)->{if(saveFailure!=null){respond(sender,()->sender.sendMessage("Character boss assignment failed before sheet custody changed."));return;}characters.apply(character).whenComplete((fresh,readFailure)->respond(sender,()->{if(readFailure!=null||fresh==null||fresh.isEmpty()||!fresh.orElseThrow().equals(expected)||activeCharacter.test(character)||partyCharacter.test(character)){directory.cancelAssignment(dungeon,custody.operation()).whenComplete((ignored,rollbackFailure)->respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the saved character changed.":"Character boss custody was saved but assignment rollback could not be confirmed; recover it before editing.")));return;}var current=player.getInventory().getItemInMainHand();if(!player.isOnline()||physicalCopies(character)!=1||current.getAmount()!=1||!sheets.authorizes(current,expected)){directory.cancelAssignment(dungeon,custody.operation()).whenComplete((ignored,rollbackFailure)->respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the held sheet changed.":"Character boss custody was saved but sheet consumption could not be confirmed; recover it before editing.")));return;}player.getInventory().setItemInMainHand(null);sender.sendMessage("Assigned character "+custody.character().name()+" as the dungeon boss at "+custody.multiplier().toPlainString()+"x stats; its sheet is now in durable custody.");}));});});}
|
||||
private void bossCommand(CommandSender sender,String[] args){if(args.length!=4||!args[2].equals("recover-sheet")||!(sender instanceof Player player)){usage(sender);return;}UUID dungeon=id(args[3]);if(dungeon==null){usage(sender);return;}recoverCharacterBoss(sender,player,dungeon);}
|
||||
private void recoverCharacterBoss(CommandSender sender,Player player,UUID dungeon){var definition=directory.find(dungeon).orElse(null);var custody=directory.custodyForDungeon(dungeon).orElse(null);if(definition==null||custody==null){sender.sendMessage("Dungeon character boss custody was not found.");return;}var catalog=adventures.snapshot().catalog();boolean offered=catalog!=null&&adventures.offerOpen(dungeon);boolean accepted=adventures.active().stream().anyMatch(quest->quest.site().equals(dungeon));if(definition.enabled()||offered||accepted||controller.beingEdited(dungeon)||controller.editing(player.getUniqueId()).isPresent()||controller.occupied(dungeon)){sender.sendMessage("Recovery requires a disabled, closed, unedited and unoccupied dungeon with no accepted quests.");return;}var physical=plugin.getServer().getOnlinePlayers().stream().flatMap(holder->Arrays.stream(holder.getInventory().getContents()).filter(Objects::nonNull).filter(item->sheets.identify(item).filter(custody.character().id()::equals).isPresent()).map(item->Map.entry(holder,item))).toList();if(physical.size()>1||physical.size()==1&&physical.getFirst().getKey()!=player){sender.sendMessage("Recovery refused because existing physical-sheet custody is ambiguous.");return;}boolean delivered=!physical.isEmpty()||custody.recovery()!=null&&Arrays.stream(player.getInventory().getContents()).anyMatch(item->sheets.hasRecovery(item,custody.recovery().operation()));if(!delivered&&player.getInventory().firstEmpty()<0){sender.sendMessage("One empty inventory slot is required before recovery.");return;}directory.prepareRecovery(dungeon,player.getUniqueId()).whenComplete((prepared,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Character boss recovery could not be prepared; custody is retained.");return;}var operation=prepared.recovery().operation();org.bukkit.inventory.ItemStack sheet=null;for(var item:player.getInventory().getContents())if(sheets.hasRecovery(item,operation)||sheets.identify(item).filter(prepared.character().id()::equals).isPresent()){sheet=item;break;}if(sheet==null){int slot=player.getInventory().firstEmpty();if(slot<0){sender.sendMessage("Character boss recovery is prepared, but one empty inventory slot is required.");return;}sheet=sheets.create(prepared.character());sheets.markRecovery(sheet,operation);player.getInventory().setItem(slot,sheet);}var item=sheet;directory.completeRecovery(dungeon,operation).whenComplete((completed,completeFailure)->respond(sender,()->{if(completeFailure!=null||!completed){sender.sendMessage("Recovery sheet delivery is retained, but durable custody completion must be retried.");return;}sheets.clearRecovery(item);sender.sendMessage("Recovered character sheet for "+prepared.character().name()+" from dungeon "+dungeon+"; configure another boss before enabling it.");}));}));}
|
||||
private void teleport(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}if(!(sender instanceof Player)){sender.sendMessage("Dungeon teleport requires a player.");return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}var dungeon=directory.find(id).orElse(null);if(dungeon==null){sender.sendMessage("Dungeon not found.");return;}teleports.teleport(sender,"Dungeon",target(dungeon),()->directory.find(id).map(DungeonCommands::target));}
|
||||
private static AdventureAdminTeleport.Target target(DungeonState.Dungeon dungeon){var entrance=dungeon.entrance();return new AdventureAdminTeleport.Target(dungeon.id(),dungeon.name(),"dungeon entrance",dungeon.world(),entrance.x(),entrance.y(),entrance.z(),entrance.yaw(),entrance.pitch(),false);}
|
||||
private void enable(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.enable(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Enabled dungeon "+id+" for later adventure catalogs.":"Dungeon enable refused: validate rooms, boss, overlap and persistence.")));}
|
||||
private void enable(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.enable(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Enabled dungeon "+id+" for later adventure catalogs.":"Dungeon enable refused: validate completion reward, rooms, boss, overlap and persistence.")));}
|
||||
private void disable(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.disable(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure!=null?"Dungeon disable failed; retained state is authoritative.":value?"Disabled and closed dungeon "+id+".":"Dungeon not found.")));}
|
||||
private void open(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.open(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Opened dungeon "+id+" in generation "+value.revision()+".":"Dungeon open refused: it is invalid, disabled, defeated or the offer limit was reached.")));}
|
||||
private void close(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.closeOffer(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure!=null?"Dungeon close failed; retained state is authoritative.":value?"Closed dungeon "+id+" for this generation.":"Dungeon was not open.")));}
|
||||
public List<String> complete(CommandSender sender,String[] args){if(!sender.hasPermission(PERMISSION))return List.of();if(args.length==1)return matching(List.of("dungeon"),args[0]);if(args.length==2&&args[0].equals("dungeon"))return matching(sender instanceof Player?List.of("boss","close","create","disable","edit","enable","entrance","list","open","remove","room","teleport"):List.of("close","disable","enable","list","open","remove"),args[1]);if(!args[0].equals("dungeon"))return List.of();if(args.length==3&&args[1].equals("boss"))return matching(List.of("recover-sheet"),args[2]);if(args.length==4&&args[1].equals("boss")&&args[2].equals("recover-sheet"))return matching(directory.snapshot().dungeons().stream().filter(value->recoverable(value.id())).map(value->value.id().toString()).toList(),args[3]);if(args.length==3&&Set.of("close","disable","enable","open","remove","teleport").contains(args[1]))return matching(directory.snapshot().dungeons().stream().map(v->v.id().toString()).toList(),args[2]);if(args.length==4&&Set.of("close","disable","remove").contains(args[1]))return matching(List.of("confirm"),args[3]);if(args.length==3&&args[1].equals("edit"))return matching(List.of("done","start"),args[2]);if(args.length==4&&args[1].equals("edit")&&args[2].equals("start"))return matching(directory.snapshot().dungeons().stream().map(v->v.id().toString()).toList(),args[3]);if(args.length==5&&args[1].equals("edit")&&args[2].equals("start"))return matching(List.of("confirm"),args[4]);if(args.length==3&&args[1].equals("entrance"))return matching(List.of("set"),args[2]);if(args.length==3&&args[1].equals("room"))return matching(List.of("boss","create","effect","list","mob","remove","reward"),args[2]);if(args.length==4&&args[1].equals("room")&&args[2].equals("create"))return matching(List.of("corner","radius"),args[3]);if(args.length>=5&&args.length<=7&&args[1].equals("room")&&args[2].equals("create"))return matching(List.of("1","3","8","128","256"),args[args.length-1]);if(args.length==4&&args[1].equals("room")&&args[2].equals("remove")&&sender instanceof Player player){var id=controller.editing(player.getUniqueId()).orElse(null);return id==null?List.of():matching(directory.find(id).map(DungeonState.Dungeon::rooms).orElse(List.of()).stream().map(v->v.id().toString()).toList(),args[3]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("mob"))return matching(List.of("add","list","remove"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("mob")&&args[3].equals("add"))return matching(entityNames(),args[4]);if(args.length==5&&args[1].equals("room")&&args[2].equals("mob")&&args[3].equals("remove")&&sender instanceof Player player){var room=controller.standingRoom(player).orElse(null);return room==null?List.of():matching(room.mobs().stream().map(v->v.id().toString()).toList(),args[4]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("effect"))return matching(List.of("add","remove"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("both","mobs","players"),args[4]);if(args.length==6&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(effectNames(),args[5]);if(args.length==7&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("1","2","64","256"),args[6]);if(args.length==8&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("false","true"),args[7]);if(args.length==5&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("remove")&&sender instanceof Player player){var room=controller.standingRoom(player).orElse(null);return room==null?List.of():matching(room.effects().stream().map(v->v.id().toString()).toList(),args[4]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("reward"))return matching(List.of("set"),args[3]);if((args.length==5||args.length==6)&&args[1].equals("room")&&args[2].equals("reward")&&args[3].equals("set"))return matching(List.of("0","10","100","2147483647"),args[args.length-1]);if(args.length==4&&args[1].equals("room")&&args[2].equals("boss"))return matching(List.of("assign-sheet","clear","mob"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("boss")&&args[3].equals("mob"))return matching(entityNames(),args[4]);if(args.length==5&&args[1].equals("room")&&args[2].equals("boss")&&args[3].equals("assign-sheet"))return matching(List.of("0.1","1","1.5","10","100"),args[4]);return List.of();}
|
||||
public List<String> complete(CommandSender sender,String[] args){if(!sender.hasPermission(PERMISSION))return List.of();if(args.length==1)return matching(List.of("dungeon"),args[0]);if(args.length==2&&args[0].equals("dungeon"))return matching(sender instanceof Player?List.of("boss","close","create","disable","edit","enable","entrance","list","mode","open","remove","reward","room","teleport"):List.of("close","disable","enable","list","mode","open","remove","reward"),args[1]);if(!args[0].equals("dungeon"))return List.of();if(args.length==3&&args[1].equals("boss"))return matching(List.of("recover-sheet"),args[2]);if(args.length==4&&args[1].equals("boss")&&args[2].equals("recover-sheet"))return matching(directory.snapshot().dungeons().stream().filter(value->recoverable(value.id())).map(value->value.id().toString()).toList(),args[3]);if(args.length==3&&args[1].equals("create"))return matching(List.of("--mode"),args[2]);if(args.length==4&&args[1].equals("create")&&args[2].equals("--mode"))return matching(modeValues(),args[3]);if(args.length==3&&Set.of("close","disable","enable","mode","open","remove","reward","teleport").contains(args[1]))return matching(directory.snapshot().dungeons().stream().map(v->v.id().toString()).toList(),args[2]);if(args.length==4&&args[1].equals("mode"))return matching(modeValues(),args[3]);if((args.length==4||args.length==5)&&args[1].equals("reward")){UUID dungeon=id(args[2]);var current=dungeon==null?null:directory.find(dungeon).orElse(null);if(current==null)return List.of();var values=new ArrayList<>(List.of("0","10","100","2147483647"));values.add((args.length==4?current.completionReward().xp():current.completionReward().coins()).toString());return matching(values,args[args.length-1]);}if(args.length==4&&Set.of("close","disable","remove").contains(args[1]))return matching(List.of("confirm"),args[3]);if(args.length==3&&args[1].equals("edit"))return matching(List.of("done","start"),args[2]);if(args.length==4&&args[1].equals("edit")&&args[2].equals("start"))return matching(directory.snapshot().dungeons().stream().map(v->v.id().toString()).toList(),args[3]);if(args.length==5&&args[1].equals("edit")&&args[2].equals("start"))return matching(List.of("confirm"),args[4]);if(args.length==3&&args[1].equals("entrance"))return matching(List.of("set"),args[2]);if(args.length==3&&args[1].equals("room"))return matching(List.of("boss","create","effect","list","mob","remove"),args[2]);if(args.length==4&&args[1].equals("room")&&args[2].equals("create"))return matching(List.of("corner","radius"),args[3]);if(args.length>=5&&args.length<=7&&args[1].equals("room")&&args[2].equals("create"))return matching(List.of("1","3","8","128","256"),args[args.length-1]);if(args.length==4&&args[1].equals("room")&&args[2].equals("remove")&&sender instanceof Player player){var id=controller.editing(player.getUniqueId()).orElse(null);return id==null?List.of():matching(directory.find(id).map(DungeonState.Dungeon::rooms).orElse(List.of()).stream().map(v->v.id().toString()).toList(),args[3]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("mob"))return matching(List.of("add","list","remove"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("mob")&&args[3].equals("add"))return matching(entityNames(),args[4]);if(args.length==5&&args[1].equals("room")&&args[2].equals("mob")&&args[3].equals("remove")&&sender instanceof Player player){var room=controller.standingRoom(player).orElse(null);return room==null?List.of():matching(room.mobs().stream().map(v->v.id().toString()).toList(),args[4]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("effect"))return matching(List.of("add","remove"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("both","mobs","players"),args[4]);if(args.length==6&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(effectNames(),args[5]);if(args.length==7&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("1","2","64","256"),args[6]);if(args.length==8&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("false","true"),args[7]);if(args.length==5&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("remove")&&sender instanceof Player player){var room=controller.standingRoom(player).orElse(null);return room==null?List.of():matching(room.effects().stream().map(v->v.id().toString()).toList(),args[4]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("boss"))return matching(List.of("assign-sheet","clear","mob"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("boss")&&args[3].equals("mob"))return matching(entityNames(),args[4]);if(args.length==5&&args[1].equals("room")&&args[2].equals("boss")&&args[3].equals("assign-sheet"))return matching(List.of("0.1","1","1.5","10","100"),args[4]);return List.of();}
|
||||
private long physicalCopies(UUID character){return plugin.getServer().getOnlinePlayers().stream().flatMap(holder->Arrays.stream(holder.getInventory().getContents())).filter(Objects::nonNull).filter(item->sheets.identify(item).filter(character::equals).isPresent()).mapToLong(org.bukkit.inventory.ItemStack::getAmount).sum();}
|
||||
private boolean recoverable(UUID dungeon){var definition=directory.find(dungeon).orElse(null);var catalog=adventures.snapshot().catalog();return definition!=null&&!definition.enabled()&&directory.custodyForDungeon(dungeon).isPresent()&&!controller.beingEdited(dungeon)&&!controller.occupied(dungeon)&&(catalog==null||!adventures.offerOpen(dungeon))&&adventures.active().stream().noneMatch(quest->quest.site().equals(dungeon));}
|
||||
private UUID editing(Player player){var result=controller.editing(player.getUniqueId()).orElse(null);if(result==null)player.sendMessage("Start editing one dungeon first.");return result;}
|
||||
@@ -60,5 +61,6 @@ public final class DungeonCommands {
|
||||
private static BigInteger whole(String value){try{return new BigInteger(value);}catch(NumberFormatException invalid){return null;}}
|
||||
private static String join(String[] args,int from){return from>=args.length?"":String.join(" ",Arrays.copyOfRange(args,from,args.length));}
|
||||
private static List<String> matching(Collection<String> values,String prefix){return values.stream().filter(value->value.startsWith(prefix)).distinct().sorted().toList();}
|
||||
private static void usage(CommandSender sender){sender.sendMessage("Usage: /fantasystick dungeon <create|list|remove|edit|entrance|room|boss|enable|disable|open|close|teleport> ...");}
|
||||
private static List<String> modeValues(){return Arrays.stream(games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.values()).map(games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode::value).toList();}
|
||||
private static void usage(CommandSender sender){sender.sendMessage("Usage: /fstickadmin dungeon <create [--mode <mode>] <name...>|mode <dungeon-uuid> [<mode>]|list|remove|edit|entrance|reward|room|boss|enable|disable|open|close|teleport> ...");}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package games.dmg.fantasystick.dungeon;
|
||||
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestDirectory;
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestState;
|
||||
import games.dmg.fantasystick.adventure.FishingController;
|
||||
import games.dmg.fantasystick.adventure.FishingState;
|
||||
import games.dmg.fantasystick.ability.*;
|
||||
import games.dmg.fantasystick.passive.PassiveRegistry;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
@@ -30,18 +32,22 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
|
||||
private record Edit(UUID dungeon,Map<UUID,Color> colors){}
|
||||
private static final String ENEMY="dungeon_enemy",PROJECTILE="dungeon_projectile";
|
||||
private record AttemptKey(UUID dungeon,UUID room){}
|
||||
private record PendingKill(UUID dungeon,UUID room,UUID generation,UUID slot){}
|
||||
private static final class Attempt {final UUID dungeon,room,generation;final Map<UUID,UUID> entities=new HashMap<>();final Set<UUID> represented=new HashSet<>();final Map<UUID,Double> threat=new HashMap<>();final Map<String,Long> cooldowns=new HashMap<>();final Map<String,Long> effects=new HashMap<>();UUID boss,target;long lastCombat=System.nanoTime();Attempt(UUID dungeon,UUID room,UUID generation){this.dungeon=dungeon;this.room=room;this.generation=generation;}}
|
||||
private record Context(UUID character,UUID party,Set<UUID> members){}
|
||||
private record OwnedEffect(UUID dungeon,UUID player,PotionEffectType type,int amplifier,PotionEffect prior){}
|
||||
private final Plugin plugin;private final DungeonDirectory dungeons;private final AdventureQuestDirectory adventures;private final EquipmentSessions sessions;private final PartyRoster parties;private final CharacterStorage characters;private final ProgressionRules progression;private final Consumer<Runnable> nextTick;private final java.util.random.RandomGenerator random;private final AbilityRegistry abilities;private final PassiveRegistry passives;private final java.util.function.Supplier<games.dmg.fantasystick.session.OutOfCombatRecovery.Settings> recoverySettings;private final java.util.function.Function<games.dmg.fantasystick.persistence.CharacterRecord,BigInteger> recoveryBonus;private final java.util.function.Supplier<BigInteger> healthStealHealing;private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
|
||||
private final Plugin plugin;private final DungeonDirectory dungeons;private final AdventureQuestDirectory adventures;private final EquipmentSessions sessions;private final PartyRoster parties;private final CharacterStorage characters;private final ProgressionRules progression;private final Consumer<Runnable> nextTick;private final java.util.random.RandomGenerator random;private final AbilityRegistry abilities;private final PassiveRegistry passives;private final java.util.function.Supplier<games.dmg.fantasystick.session.OutOfCombatRecovery.Settings> recoverySettings;private final java.util.function.Function<games.dmg.fantasystick.persistence.CharacterRecord,BigInteger> recoveryBonus;private final java.util.function.Supplier<BigInteger> healthStealHealing;private final games.dmg.fantasystick.presentation.ParticleDelivery particles;private FishingController fishing;
|
||||
private final NamespacedKey typeKey,dungeonKey,roomKey,slotKey,generationKey,bossKey;
|
||||
private final Map<UUID,Edit> playerEdits=new HashMap<>();private final Map<UUID,UUID> dungeonEditors=new HashMap<>();private final Map<AttemptKey,Attempt> attempts=new HashMap<>();private final Set<String> startingRuns=new HashSet<>();private final Set<AttemptKey> completing=new HashSet<>();private final Set<UUID> defeatingDungeons=new HashSet<>();private final Set<CompletableFuture<?>> inFlight=ConcurrentHashMap.newKeySet();private final Map<String,OwnedEffect> ownedEffects=new HashMap<>();private final Set<Entity> relocating=Collections.newSetFromMap(new IdentityHashMap<>());private org.bukkit.scheduler.BukkitTask pulse;private boolean closed;private long ticks=9;
|
||||
private final Map<UUID,Edit> playerEdits=new HashMap<>();private final Map<UUID,UUID> dungeonEditors=new HashMap<>();private final Map<AttemptKey,Attempt> attempts=new HashMap<>();private final Set<String> startingRuns=new HashSet<>();private final Set<AttemptKey> completing=new HashSet<>();private final Set<UUID> defeatingDungeons=new HashSet<>();private final Set<CompletableFuture<?>> inFlight=ConcurrentHashMap.newKeySet();private final Map<String,OwnedEffect> ownedEffects=new HashMap<>();private final Map<UUID,PendingKill> pendingKills=new ConcurrentHashMap<>();private final Set<Entity> relocating=Collections.newSetFromMap(new IdentityHashMap<>());private org.bukkit.scheduler.BukkitTask pulse;private boolean closed;private long ticks=9;
|
||||
public DungeonController(Plugin plugin,DungeonDirectory dungeons,AdventureQuestDirectory adventures,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random){this(plugin,dungeons,adventures,sessions,parties,characters,progression,nextTick,random,new AbilityRegistry(),new PassiveRegistry(),games.dmg.fantasystick.session.OutOfCombatRecovery.Settings::defaults,ignored->BigInteger.ZERO,()->BigInteger.ONE);}
|
||||
public DungeonController(Plugin plugin,DungeonDirectory dungeons,AdventureQuestDirectory adventures,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random,AbilityRegistry abilities,PassiveRegistry passives){this(plugin,dungeons,adventures,sessions,parties,characters,progression,nextTick,random,abilities,passives,games.dmg.fantasystick.session.OutOfCombatRecovery.Settings::defaults,ignored->BigInteger.ZERO,()->BigInteger.ONE);}
|
||||
public DungeonController(Plugin plugin,DungeonDirectory dungeons,AdventureQuestDirectory adventures,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random,AbilityRegistry abilities,PassiveRegistry passives,java.util.function.Supplier<games.dmg.fantasystick.session.OutOfCombatRecovery.Settings> recoverySettings,java.util.function.Function<games.dmg.fantasystick.persistence.CharacterRecord,BigInteger> recoveryBonus,java.util.function.Supplier<BigInteger> healthStealHealing){this(plugin,dungeons,adventures,sessions,parties,characters,progression,nextTick,random,abilities,passives,recoverySettings,recoveryBonus,healthStealHealing,games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());}
|
||||
public DungeonController(Plugin plugin,DungeonDirectory dungeons,AdventureQuestDirectory adventures,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random,AbilityRegistry abilities,PassiveRegistry passives,java.util.function.Supplier<games.dmg.fantasystick.session.OutOfCombatRecovery.Settings> recoverySettings,java.util.function.Function<games.dmg.fantasystick.persistence.CharacterRecord,BigInteger> recoveryBonus,java.util.function.Supplier<BigInteger> healthStealHealing,games.dmg.fantasystick.presentation.ParticleDelivery particles){this.plugin=Objects.requireNonNull(plugin);this.dungeons=Objects.requireNonNull(dungeons);this.adventures=Objects.requireNonNull(adventures);this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.characters=Objects.requireNonNull(characters);this.progression=Objects.requireNonNull(progression);this.nextTick=Objects.requireNonNull(nextTick);this.random=Objects.requireNonNull(random);this.abilities=Objects.requireNonNull(abilities);this.passives=Objects.requireNonNull(passives);this.recoverySettings=Objects.requireNonNull(recoverySettings);this.recoveryBonus=Objects.requireNonNull(recoveryBonus);this.healthStealHealing=Objects.requireNonNull(healthStealHealing);this.particles=Objects.requireNonNull(particles);typeKey=new NamespacedKey(plugin,"managed_npc_type");dungeonKey=new NamespacedKey(plugin,"dungeon_id");roomKey=new NamespacedKey(plugin,"dungeon_room_id");slotKey=new NamespacedKey(plugin,"dungeon_spawn_id");generationKey=new NamespacedKey(plugin,"dungeon_generation_id");bossKey=new NamespacedKey(plugin,"dungeon_boss");}
|
||||
public void registerFishing(FishingController fishing){if(closed||this.fishing!=null)throw new IllegalStateException("Fishing already attached or dungeon controller closed");this.fishing=Objects.requireNonNull(fishing);}
|
||||
public void install(){synchronizeEnabled();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(managed(entity))entity.remove();pulse=plugin.getServer().getScheduler().runTaskTimer(plugin,this::tick,1,1);}
|
||||
public boolean managed(Entity entity){var data=entity.getPersistentDataContainer();if(data==null)return false;var type=data.get(typeKey,PersistentDataType.STRING);return ENEMY.equals(type)||PROJECTILE.equals(type);}
|
||||
public Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin> encounterOrigin(Entity entity){if(!managedEnemy(entity))return Optional.empty();UUID dungeon=id(entity,dungeonKey),room=id(entity,roomKey),generation=id(entity,generationKey);if(dungeon==null||room==null||generation==null)return Optional.empty();var attempt=attempts.get(new AttemptKey(dungeon,room));if(attempt!=null&&!attempt.generation.equals(generation))return Optional.empty();UUID slot=id(entity,slotKey);if(slot==null&&(!Boolean.TRUE.equals(entity.getPersistentDataContainer().get(bossKey,PersistentDataType.BOOLEAN))||dungeons.room(dungeon,room).map(value->value.boss()==null).orElse(true)))return Optional.empty();if(slot!=null&&(dungeons.room(dungeon,room).stream().flatMap(value->value.mobs().stream()).noneMatch(value->value.id().equals(slot))||dungeons.runView(dungeon,generation).map(run->run.defeatedSlots().contains(slot)||run.defeated()).orElse(true)))return Optional.empty();String encounter=slot==null?"boss":slot.toString();UUID source=UUID.nameUUIDFromBytes((dungeon+":"+room+":"+generation+":"+encounter).getBytes(java.nio.charset.StandardCharsets.UTF_8));if(slot!=null&&attempt==null&&!pendingKills.containsKey(source))return Optional.empty();return Optional.of(new games.dmg.fantasystick.progression.ManagedEncounterOrigin(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,source));}
|
||||
public void settleKill(games.dmg.fantasystick.progression.ManagedEncounterOrigin origin){if(origin.kind()!=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON)return;var pending=pendingKills.remove(origin.source());if(pending==null)return;dungeons.defeatSlot(pending.dungeon(),pending.generation(),pending.slot()).whenComplete((run,failure)->schedule(()->{if(failure!=null)return;var room=dungeons.room(pending.dungeon(),pending.room()).orElse(null);var attempt=attempts.get(new AttemptKey(pending.dungeon(),pending.room()));var dungeon=dungeons.find(pending.dungeon()).orElse(null);if(room!=null&&attempt!=null&&dungeon!=null&&room.boss()==null&&room.mobs().stream().allMatch(value->run.defeatedSlots().contains(value.id())))clearRoom(dungeon,room,attempt,false);}));}
|
||||
private void synchronizeEnabled(){for(var site:adventures.snapshot().sites())if(site.type()==AdventureQuestState.SiteType.DUNGEON)adventures.removeDungeonSite(site.id());}
|
||||
public CompletionStage<EditStatus> start(Player player,UUID dungeonId,boolean confirm){if(closed)return CompletableFuture.completedFuture(EditStatus.BUSY);UUID playerId=player.getUniqueId();var dungeon=dungeons.find(dungeonId).orElse(null);if(dungeon==null)return CompletableFuture.completedFuture(EditStatus.NOT_FOUND);synchronized(this){if(playerEdits.containsKey(playerId)||dungeonEditors.containsKey(dungeonId))return CompletableFuture.completedFuture(EditStatus.BUSY);}var catalog=adventures.snapshot().catalog();boolean offered=catalog!=null&&adventures.offerOpen(dungeonId);boolean work=offered||adventures.active().stream().anyMatch(q->q.site().equals(dungeonId))||dungeons.snapshot().runs().stream().anyMatch(run->run.dungeon().equals(dungeonId));if(work&&!confirm)return CompletableFuture.completedFuture(EditStatus.CONFIRM_REQUIRED);CompletionStage<?> transition=settleAwards().thenCompose(ignored->dungeons.disable(dungeonId)).thenCompose(ignored->adventures.closeOffer(dungeonId)).thenCompose(ignored->adventures.removeDungeonSite(dungeonId)).thenCompose(ignored->cleanupOnMain(dungeonId));return transition.thenApply(ignored->{synchronized(this){if(closed||playerEdits.containsKey(playerId)||dungeonEditors.containsKey(dungeonId))return EditStatus.BUSY;var colors=new HashMap<UUID,Color>();for(var room:dungeons.find(dungeonId).orElseThrow().rooms())colors.put(room.id(),randomColor());playerEdits.put(playerId,new Edit(dungeonId,colors));dungeonEditors.put(dungeonId,playerId);return EditStatus.STARTED;}});}
|
||||
public synchronized boolean done(UUID player){var edit=playerEdits.remove(player);if(edit==null)return false;dungeonEditors.remove(edit.dungeon(),player);return true;}
|
||||
@@ -118,9 +124,9 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void bossAccepted(EntityDamageByEntityEvent event){if(!(event.getDamager() instanceof Mob boss)||!managedEnemy(boss)||!(event.getEntity() instanceof Player player)||event.getFinalDamage()<=0)return;var room=dungeons.room(id(boss,dungeonKey),id(boss,roomKey)).orElse(null);var custody=room==null||room.boss()==null?null:room.boss().character();if(custody==null)return;if(custody.character().passives().contains("stick_knockback")&&passives.find("stick_knockback").isPresent()){double radians=Math.toRadians(boss.getLocation().getYaw()),x=Math.sin(radians),z=-Math.cos(radians);if(Double.isFinite(x)&&Double.isFinite(z))player.knockback(1,x,z);}}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void bossKill(org.bukkit.event.entity.PlayerDeathEvent event){if(!(event.getEntity().getLastDamageCause() instanceof EntityDamageByEntityEvent damage)||!(damage.getDamager() instanceof Mob boss)||!managedEnemy(boss))return;var room=dungeons.room(id(boss,dungeonKey),id(boss,roomKey)).orElse(null);var custody=room==null||room.boss()==null?null:room.boss().character();if(custody==null||!custody.character().passives().contains("health_steal")||passives.find("health_steal").isEmpty()||eligible(event.getPlayer(),id(boss,dungeonKey),id(boss,roomKey))==null)return;healBoss(boss,Math.min(Double.MAX_VALUE,healthStealHealing.get().doubleValue()));}
|
||||
public void accept(AbilityReport report){if(report.type()==AbilityReport.Type.DAMAGE)return;var active=sessions.activeCharacter(report.sourceCharacter()).filter(sessions::current).orElse(null);if(active==null)return;var player=plugin.getServer().getPlayer(active.player());if(player==null)return;double value=report.amount().doubleValue();if(!Double.isFinite(value)||value<=0)return;for(var entry:attempts.entrySet())if(eligible(player,entry.getKey().dungeon(),entry.getKey().room())!=null)entry.getValue().threat.merge(player.getUniqueId(),value*.5d,Double::sum);}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void death(EntityDeathEvent event){if(!managedEnemy(event.getEntity()))return;UUID dungeonId=id(event.getEntity(),dungeonKey),roomId=id(event.getEntity(),roomKey),generation=id(event.getEntity(),generationKey);if(dungeonId==null||roomId==null||generation==null)return;var dungeon=dungeons.find(dungeonId).orElse(null);var room=dungeons.room(dungeonId,roomId).orElse(null);var attempt=attempts.get(new AttemptKey(dungeonId,roomId));if(dungeon==null||room==null||attempt==null)return;boolean boss=Boolean.TRUE.equals(event.getEntity().getPersistentDataContainer().get(bossKey,PersistentDataType.BOOLEAN));if(boss){attempt.boss=null;defeatingDungeons.add(dungeonId);cleanupDungeon(dungeonId);clearRoom(dungeon,room,attempt,true);return;}UUID slot=id(event.getEntity(),slotKey);attempt.entities.remove(event.getEntity().getUniqueId());if(slot==null)return;dungeons.defeatSlot(dungeonId,generation,slot).whenComplete((run,failure)->schedule(()->{if(failure!=null)return;if(room.boss()==null&&room.mobs().stream().allMatch(value->run.defeatedSlots().contains(value.id())))clearRoom(dungeon,room,attempt,false);}));}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void death(EntityDeathEvent event){if(!managedEnemy(event.getEntity()))return;UUID dungeonId=id(event.getEntity(),dungeonKey),roomId=id(event.getEntity(),roomKey),generation=id(event.getEntity(),generationKey);if(dungeonId==null||roomId==null||generation==null)return;var dungeon=dungeons.find(dungeonId).orElse(null);var room=dungeons.room(dungeonId,roomId).orElse(null);var attempt=attempts.get(new AttemptKey(dungeonId,roomId));if(dungeon==null||room==null||attempt==null)return;boolean boss=Boolean.TRUE.equals(event.getEntity().getPersistentDataContainer().get(bossKey,PersistentDataType.BOOLEAN));if(boss){attempt.boss=null;defeatingDungeons.add(dungeonId);cleanupDungeon(dungeonId);clearRoom(dungeon,room,attempt,true);return;}UUID slot=id(event.getEntity(),slotKey);attempt.entities.remove(event.getEntity().getUniqueId());if(slot==null)return;encounterOrigin(event.getEntity()).ifPresent(origin->pendingKills.putIfAbsent(origin.source(),new PendingKill(dungeonId,roomId,generation,slot)));}
|
||||
private void clearRoom(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,boolean boss){var key=new AttemptKey(dungeon.id(),room.id());if(!completing.add(key))return;var quests=attempt.represented.stream().map(adventures::findQuest).flatMap(Optional::stream).toList();var awards=quests.stream().map(quest->awardQuest(dungeon,room,attempt.generation,quest,boss)).toArray(CompletableFuture[]::new);var completion=CompletableFuture.allOf(awards).thenCompose(ignored->dungeons.markCleared(dungeon.id(),attempt.generation,room.id())).thenCompose(run->boss?dungeons.defeat(dungeon.id(),attempt.generation):CompletableFuture.completedFuture(run)).thenCompose(run->boss?adventures.expireOfferQuests(dungeon.id()).thenApply(ignored->run):CompletableFuture.completedFuture(run)).whenComplete((run,failure)->schedule(()->{completing.remove(key);if(boss)defeatingDungeons.remove(dungeon.id());if(failure!=null){plugin.getLogger().log(java.util.logging.Level.WARNING,"Dungeon room completion failed",failure);return;}cleanupAttempt(key);if(boss)cleanupDungeon(dungeon.id());}));inFlight.add(completion);completion.whenComplete((ignored,failure)->inFlight.remove(completion));}
|
||||
private CompletableFuture<Void> awardQuest(DungeonState.Dungeon dungeon,DungeonState.Room room,UUID generation,AdventureQuestState.Quest quest,boolean completeQuest){var run=dungeons.runView(dungeon.id(),generation).orElse(null);if(run==null||run.unitClaims().contains(new DungeonState.UnitClaim(room.id(),quest.id())))return CompletableFuture.completedFuture(null);var recipients=recipients(dungeon,room,quest,run);if(recipients.isEmpty())return CompletableFuture.completedFuture(null);var unit=new RewardAllocator.Unit<UUID>(quest.unit(),recipients);var xp=RewardAllocator.allocate(room.reward().xp(),List.of(unit),random);var coins=RewardAllocator.allocate(room.reward().coins(),List.of(unit),random);var rewards=new LinkedHashMap<UUID,AdventureQuestDirectory.Reward>();for(UUID character:recipients)rewards.put(character,new AdventureQuestDirectory.Reward(xp.get(character),coins.get(character)));UUID source=UUID.nameUUIDFromBytes((generation+":"+dungeon.id()+":"+room.id()+":"+quest.id()).getBytes(StandardCharsets.UTF_8));return adventures.bank(quest.id(),source,rewards).thenCompose(banked->{if(!banked)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon reward banking refused"));return completeQuest?adventures.complete(quest.id(),Set.copyOf(recipients)):CompletableFuture.completedFuture(true);}).thenCompose(completed->{if(!completed)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon quest completion refused"));return dungeons.clearRoom(dungeon.id(),generation,room.id(),quest.id(),Set.copyOf(recipients));}).thenApply(ignored->null);}
|
||||
private CompletableFuture<Void> awardQuest(DungeonState.Dungeon dungeon,DungeonState.Room room,UUID generation,AdventureQuestState.Quest quest,boolean completeQuest){var run=dungeons.runView(dungeon.id(),generation).orElse(null);if(run==null||run.unitClaims().contains(new DungeonState.UnitClaim(room.id(),quest.id())))return CompletableFuture.completedFuture(null);var recipients=recipients(dungeon,room,quest,run);if(recipients.isEmpty())return CompletableFuture.completedFuture(null);if(!completeQuest)return dungeons.clearRoom(dungeon.id(),generation,room.id(),quest.id(),Set.copyOf(recipients)).thenApply(ignored->null);if(fishing==null)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon trophy persistence unavailable"));var unit=new RewardAllocator.Unit<UUID>(quest.unit(),recipients);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(UUID character:recipients)rewards.put(character,new FishingState.Reward(xp.get(character),coins.get(character)));UUID carrier=quest.unitType()==AdventureQuestState.UnitType.SOLO?quest.unit():recipients.stream().map(parties::partyOfCharacter).flatMap(Optional::stream).filter(value->value.id().equals(quest.unit())).map(PartyRoster.Party::leaderCharacter).findFirst().orElse(null);if(carrier==null)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon trophy carrier unavailable"));UUID trophy=UUID.nameUUIDFromBytes(("dungeon:"+quest.id()).getBytes(StandardCharsets.UTF_8));var plan=new FishingState.Trophy(trophy,FishingState.TrophyType.DUNGEON,carrier,quest.catalog(),dungeon.id(),quest.id(),generation,"Trophy of "+dungeon.name(),dungeon.name(),rewards);return fishing.earnTrophy(plan).thenCompose(earned->{if(!earned)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon trophy persistence refused"));return dungeons.clearRoom(dungeon.id(),generation,room.id(),quest.id(),Set.copyOf(recipients));}).thenApply(ignored->(Void)null).toCompletableFuture();}
|
||||
private List<UUID> recipients(DungeonState.Dungeon dungeon,DungeonState.Room room,AdventureQuestState.Quest quest,DungeonState.Run run){var result=new ArrayList<UUID>();for(UUID character:quest.snapshot()){if(run.characterClaims().contains(new DungeonState.CharacterClaim(room.id(),character)))continue;var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);if(active==null)continue;var player=plugin.getServer().getPlayer(active.player());if(player==null||!inside(player.getLocation(),dungeon,room))continue;if(quest.unitType()==AdventureQuestState.UnitType.PARTY){var party=parties.partyOf(player.getUniqueId()).orElse(null);if(party==null||!party.id().equals(quest.unit())||!party.characters().contains(character))continue;}result.add(character);}return result.stream().distinct().sorted().toList();}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void target(EntityTargetLivingEntityEvent event){if(managedEnemy(event.getEntity())&&event.getTarget()!=null){UUID dungeon=id(event.getEntity(),dungeonKey);UUID room=id(event.getEntity(),roomKey);var attempt=dungeon==null||room==null?null:attempts.get(new AttemptKey(dungeon,room));if(!(event.getTarget() instanceof Player player)||dungeon==null||room==null||eligible(player,dungeon,room)==null||attempt!=null&&attempt.target!=null&&!attempt.target.equals(player.getUniqueId()))event.setCancelled(true);}}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void projectile(ProjectileLaunchEvent event){if(event.getEntity().getShooter() instanceof Entity shooter&&managedEnemy(shooter)){var data=event.getEntity().getPersistentDataContainer();data.set(typeKey,PersistentDataType.STRING,PROJECTILE);copy(data,dungeonKey,shooter);copy(data,roomKey,shooter);copy(data,generationKey,shooter);}}
|
||||
|
||||
@@ -23,7 +23,9 @@ public final class DungeonDirectory implements AutoCloseable,AdventureResetParti
|
||||
public Optional<DungeonState.CharacterBoss> custodyForDungeon(UUID dungeon){return find(dungeon).stream().flatMap(value->value.rooms().stream()).map(DungeonState.Room::boss).filter(Objects::nonNull).map(DungeonState.Boss::character).filter(Objects::nonNull).findFirst();}
|
||||
public Optional<DungeonState.Room> room(UUID dungeon,UUID room){return find(dungeon).flatMap(value->value.rooms().stream().filter(v->v.id().equals(room)).findFirst());}
|
||||
public Optional<DungeonState.Room> roomAt(UUID dungeon,int x,int y,int z){return find(dungeon).flatMap(value->{var matches=value.rooms().stream().filter(room->room.bounds().contains(x,y,z)).toList();return matches.size()==1?Optional.of(matches.getFirst()):Optional.empty();});}
|
||||
public CompletableFuture<DungeonState.Dungeon> create(String name,UUID world,double x,double y,double z,float yaw,float pitch){var value=new DungeonState.Dungeon(UUID.randomUUID(),name,world,new DungeonState.Position(x,y,z,yaw,pitch),false,UUID.randomUUID(),List.of());return mutate(state->{var values=new ArrayList<>(state.dungeons());values.add(value);return result(new DungeonState(values,state.runs()),value);});}
|
||||
public CompletableFuture<DungeonState.Dungeon> create(String name,UUID world,double x,double y,double z,float yaw,float pitch){return create(name,world,x,y,z,yaw,pitch,AdventureQuestState.ParticipationMode.PARTY_AVAILABLE);}
|
||||
public CompletableFuture<DungeonState.Dungeon> create(String name,UUID world,double x,double y,double z,float yaw,float pitch,AdventureQuestState.ParticipationMode mode){var value=new DungeonState.Dungeon(UUID.randomUUID(),name,world,new DungeonState.Position(x,y,z,yaw,pitch),false,UUID.randomUUID(),List.of(),mode,DungeonState.Reward.empty(),false);return mutate(state->{var values=new ArrayList<>(state.dungeons());values.add(value);return result(new DungeonState(values,state.runs()),value);});}
|
||||
public CompletableFuture<DungeonState.Dungeon> setMode(UUID id,UUID expectedRevision,AdventureQuestState.ParticipationMode mode){Objects.requireNonNull(expectedRevision);Objects.requireNonNull(mode);return mutate(state->{var current=find(state,id).orElseThrow(()->new IllegalArgumentException("Dungeon not found"));if(!current.revision().equals(expectedRevision))throw new IllegalArgumentException("Dungeon changed");if(current.participationMode()==mode)return result(state,current);var changed=new DungeonState.Dungeon(current.id(),current.name(),current.world(),current.entrance(),current.enabled(),UUID.randomUUID(),current.rooms(),mode,current.completionReward(),current.rewardConfigured());return result(replace(state,changed),changed);});}
|
||||
public CompletableFuture<Optional<DungeonState.Dungeon>> remove(UUID id){return mutate(state->{var found=find(state,id);if(found.isEmpty())return result(state,Optional.empty());if(found.orElseThrow().rooms().stream().map(DungeonState.Room::boss).filter(Objects::nonNull).anyMatch(boss->boss.character()!=null))throw new IllegalArgumentException("Recover character boss custody first");return result(new DungeonState(state.dungeons().stream().filter(v->!v.id().equals(id)).toList(),state.runs().stream().filter(v->!v.dungeon().equals(id)).toList()),found);});}
|
||||
public CompletableFuture<DungeonState.Dungeon> setEntrance(UUID id,double x,double y,double z,float yaw,float pitch){return change(id,dungeon->definition(dungeon,dungeon.rooms(),new DungeonState.Position(x,y,z,yaw,pitch),false));}
|
||||
public CompletableFuture<DungeonState.Room> addCornerRoom(UUID id,String name,int x,int y,int z,int xSize,int zSize,int height){if(xSize==0||Math.abs((long)xSize)>256||zSize==0||Math.abs((long)zSize)>256||height<1||height>128)return CompletableFuture.failedFuture(new IllegalArgumentException("Room dimensions out of range"));int maxX=x+(xSize>0?xSize-1:0),minX=x+(xSize<0?xSize+1:0),maxZ=z+(zSize>0?zSize-1:0),minZ=z+(zSize<0?zSize+1:0);return addRoom(id,name,new DungeonState.Bounds(Math.min(x,minX),y,Math.min(z,minZ),Math.max(x,maxX),y+height-1,Math.max(z,maxZ)));}
|
||||
@@ -34,7 +36,7 @@ public final class DungeonDirectory implements AutoCloseable,AdventureResetParti
|
||||
public CompletableFuture<Optional<DungeonState.Spawn>> removeMob(UUID id,UUID roomId,UUID spawn){var found=room(id,roomId).flatMap(value->value.mobs().stream().filter(v->v.id().equals(spawn)).findFirst());if(found.isEmpty())return CompletableFuture.completedFuture(Optional.empty());return updateRoom(id,roomId,room->new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs().stream().filter(v->!v.id().equals(spawn)).toList(),room.effects(),room.reward(),room.boss()),found);}
|
||||
public CompletableFuture<DungeonState.Effect> addEffect(UUID id,UUID roomId,DungeonState.EffectTarget target,String effect,int level,boolean particles){var value=new DungeonState.Effect(UUID.randomUUID(),target,effect,level,particles);return updateRoom(id,roomId,room->{var effects=new ArrayList<>(room.effects());if(effects.size()>=32)throw new IllegalArgumentException("Effect limit reached");effects.add(value);return new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),effects,room.reward(),room.boss());},value);}
|
||||
public CompletableFuture<Optional<DungeonState.Effect>> removeEffect(UUID id,UUID roomId,UUID effect){var found=room(id,roomId).flatMap(value->value.effects().stream().filter(v->v.id().equals(effect)).findFirst());if(found.isEmpty())return CompletableFuture.completedFuture(Optional.empty());return updateRoom(id,roomId,room->new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects().stream().filter(v->!v.id().equals(effect)).toList(),room.reward(),room.boss()),found);}
|
||||
public CompletableFuture<DungeonState.Reward> setReward(UUID id,UUID roomId,BigInteger xp,BigInteger coins){var value=new DungeonState.Reward(xp,coins);return updateRoom(id,roomId,room->new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects(),value,room.boss()),value);}
|
||||
public CompletableFuture<DungeonState.Reward> setCompletionReward(UUID id,BigInteger xp,BigInteger coins){var value=new DungeonState.Reward(xp,coins);return mutate(state->{var dungeon=editable(state,id);var changed=new DungeonState.Dungeon(dungeon.id(),dungeon.name(),dungeon.world(),dungeon.entrance(),false,UUID.randomUUID(),dungeon.rooms(),dungeon.participationMode(),value,true);return result(replace(state,changed),value);});}
|
||||
public CompletableFuture<DungeonState.Boss> setBoss(UUID id,UUID roomId,EntityType type,String name,double x,double y,double z,float yaw,float pitch){var value=new DungeonState.Boss(type,name,new DungeonState.Position(x,y,z,yaw,pitch));return updateRoom(id,roomId,room->{if(room.boss()!=null&&room.boss().character()!=null)throw new IllegalArgumentException("Recover character boss custody first");return new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects(),room.reward(),value);},value);}
|
||||
public CompletableFuture<Boolean> clearBoss(UUID id,UUID roomId){return updateRoom(id,roomId,room->{if(room.boss()!=null&&room.boss().character()!=null)throw new IllegalArgumentException("Recover character boss custody first");return new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects(),room.reward(),null);},true);}
|
||||
public CompletableFuture<DungeonState.CharacterBoss> assignCharacterBoss(UUID id,UUID roomId,CharacterRecord character,BigDecimal multiplier){Objects.requireNonNull(character);var template=new DungeonState.CharacterBoss(UUID.randomUUID(),character,multiplier,null);return mutate(state->{var dungeon=editable(state,id);if(state.dungeons().stream().flatMap(v->v.rooms().stream()).map(DungeonState.Room::boss).filter(Objects::nonNull).map(DungeonState.Boss::character).filter(Objects::nonNull).anyMatch(custody->custody.character().id().equals(character.id())))throw new IllegalArgumentException("Character already assigned");var room=dungeon.rooms().stream().filter(v->v.id().equals(roomId)).findFirst().orElseThrow(()->new IllegalArgumentException("Room not found"));if(room.boss()==null||room.boss().character()!=null)throw new IllegalArgumentException("Configure one native boss room first");var value=new DungeonState.CharacterBoss(template.operation(),character,template.multiplier(),room.boss().type(),room.boss().name(),null);var boss=new DungeonState.Boss(EntityType.VINDICATOR,character.name(),room.boss().position(),value);var updated=new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects(),room.reward(),boss);var rooms=dungeon.rooms().stream().map(v->v.id().equals(roomId)?updated:v).toList();return result(replace(state,definition(dungeon,rooms,dungeon.entrance(),false)),value);});}
|
||||
@@ -57,9 +59,9 @@ public final class DungeonDirectory implements AutoCloseable,AdventureResetParti
|
||||
private static DungeonState.Dungeon editable(DungeonState state,UUID id){var value=find(state,id).orElseThrow(()->new IllegalArgumentException("Dungeon not found"));if(value.enabled())throw new IllegalArgumentException("Dungeon must be disabled");return value;}
|
||||
private static Optional<DungeonState.Dungeon> find(DungeonState state,UUID id){return state.dungeons().stream().filter(v->v.id().equals(id)).findFirst();}
|
||||
private static DungeonState replace(DungeonState state,DungeonState.Dungeon dungeon){var values=state.dungeons().stream().map(v->v.id().equals(dungeon.id())?dungeon:v).toList();return new DungeonState(values,state.runs());}
|
||||
private static DungeonState.Dungeon definition(DungeonState.Dungeon original,List<DungeonState.Room> rooms,DungeonState.Position entrance,boolean enabled){return new DungeonState.Dungeon(original.id(),original.name(),original.world(),entrance,enabled,UUID.randomUUID(),rooms);}
|
||||
private static DungeonState.Dungeon definition(DungeonState.Dungeon original,List<DungeonState.Room> rooms,DungeonState.Position entrance,boolean enabled){return new DungeonState.Dungeon(original.id(),original.name(),original.world(),entrance,enabled,UUID.randomUUID(),rooms,original.participationMode(),original.completionReward(),original.rewardConfigured());}
|
||||
public boolean eligibleForFutureOffer(UUID id){var dungeon=find(id).orElse(null);if(dungeon==null||!dungeon.enabled())return false;try{validate(dungeon);return true;}catch(IllegalArgumentException invalid){return false;}}
|
||||
private void validate(DungeonState.Dungeon dungeon){if(overlapping(dungeon.rooms()))throw new IllegalArgumentException("Dungeon rooms overlap");if(dungeon.rooms().stream().map(DungeonState.Room::boss).filter(Objects::nonNull).map(DungeonState.Boss::character).filter(Objects::nonNull).anyMatch(value->value.recovery()!=null))throw new IllegalArgumentException("Character recovery is pending");if(dungeon.rooms().isEmpty()||!connected(dungeon.rooms(),linkDistance()))throw new IllegalArgumentException("Dungeon rooms are incomplete");if(dungeon.rooms().stream().filter(v->v.boss()!=null).count()!=1)throw new IllegalArgumentException("Dungeon needs exactly one boss");}
|
||||
private void validate(DungeonState.Dungeon dungeon){if(!dungeon.rewardConfigured())throw new IllegalArgumentException("Dungeon completion reward is not configured");if(overlapping(dungeon.rooms()))throw new IllegalArgumentException("Dungeon rooms overlap");if(dungeon.rooms().stream().map(DungeonState.Room::boss).filter(Objects::nonNull).map(DungeonState.Boss::character).filter(Objects::nonNull).anyMatch(value->value.recovery()!=null))throw new IllegalArgumentException("Character recovery is pending");if(dungeon.rooms().isEmpty()||!connected(dungeon.rooms(),linkDistance()))throw new IllegalArgumentException("Dungeon rooms are incomplete");if(dungeon.rooms().stream().filter(v->v.boss()!=null).count()!=1)throw new IllegalArgumentException("Dungeon needs exactly one boss");}
|
||||
private int linkDistance(){int value=roomLinkDistance.getAsInt();if(value<1||value>256)throw new IllegalArgumentException("Invalid dungeon room link distance");return value;}
|
||||
private static boolean overlapping(List<DungeonState.Room> rooms){for(int left=0;left<rooms.size();left++)for(int right=left+1;right<rooms.size();right++)if(rooms.get(left).bounds().overlaps(rooms.get(right).bounds()))return true;return false;}
|
||||
static boolean connected(List<DungeonState.Room> rooms,int maximum){if(rooms.size()<2)return true;var reached=new HashSet<UUID>();var queue=new ArrayDeque<DungeonState.Room>();queue.add(rooms.getFirst());reached.add(rooms.getFirst().id());while(!queue.isEmpty()){var current=queue.remove();for(var room:rooms)if(!reached.contains(room.id())&¤t.bounds().withinDistance(room.bounds(),maximum)){reached.add(room.id());queue.add(room);}}return reached.size()==rooms.size();}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package games.dmg.fantasystick.dungeon;
|
||||
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestState;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.*;
|
||||
@@ -45,10 +46,13 @@ public record DungeonState(List<Dungeon> dungeons,List<Run> runs) {
|
||||
public Room {Objects.requireNonNull(id);name=validName(name);Objects.requireNonNull(bounds);mobs=List.copyOf(mobs);effects=List.copyOf(effects);Objects.requireNonNull(reward);unique(mobs.stream().map(Spawn::id).toList());unique(effects.stream().map(Effect::id).toList());if(mobs.size()>256||effects.size()>32)throw new IllegalArgumentException("Room limit exceeded");}
|
||||
public String displayName(){return name.isBlank()?"Room":name;}
|
||||
}
|
||||
public record Dungeon(UUID id,String name,UUID world,Position entrance,boolean enabled,UUID revision,List<Room> rooms) {
|
||||
public Dungeon {Objects.requireNonNull(id);name=validName(name);if(name.isBlank())throw new IllegalArgumentException("Dungeon name required");Objects.requireNonNull(world);Objects.requireNonNull(entrance);Objects.requireNonNull(revision);rooms=List.copyOf(rooms);unique(rooms.stream().map(Room::id).toList());if(rooms.size()>128)throw new IllegalArgumentException("Dungeon room limit exceeded");}
|
||||
public BigInteger maximumXp(){return rooms.stream().map(v->v.reward().xp()).reduce(BigInteger.ZERO,BigInteger::add);}
|
||||
public BigInteger maximumCoins(){return rooms.stream().map(v->v.reward().coins()).reduce(BigInteger.ZERO,BigInteger::add);}
|
||||
public record Dungeon(UUID id,String name,UUID world,Position entrance,boolean enabled,UUID revision,List<Room> rooms,AdventureQuestState.ParticipationMode participationMode,Reward completionReward,boolean rewardConfigured) {
|
||||
public Dungeon {Objects.requireNonNull(id);name=validName(name);if(name.isBlank())throw new IllegalArgumentException("Dungeon name required");Objects.requireNonNull(world);Objects.requireNonNull(entrance);Objects.requireNonNull(revision);rooms=List.copyOf(rooms);Objects.requireNonNull(participationMode);Objects.requireNonNull(completionReward);unique(rooms.stream().map(Room::id).toList());if(rooms.size()>128)throw new IllegalArgumentException("Dungeon room limit exceeded");}
|
||||
public Dungeon(UUID id,String name,UUID world,Position entrance,boolean enabled,UUID revision,List<Room> rooms,AdventureQuestState.ParticipationMode mode){this(id,name,world,entrance,enabled,revision,rooms,mode,legacyReward(rooms),true);}
|
||||
public Dungeon(UUID id,String name,UUID world,Position entrance,boolean enabled,UUID revision,List<Room> rooms){this(id,name,world,entrance,enabled,revision,rooms,AdventureQuestState.ParticipationMode.PARTY_AVAILABLE);}
|
||||
public BigInteger maximumXp(){return completionReward.xp();}
|
||||
public BigInteger maximumCoins(){return completionReward.coins();}
|
||||
private static Reward legacyReward(List<Room> rooms){return rooms.stream().filter(room->room.boss()!=null).map(Room::reward).findFirst().orElse(Reward.empty());}
|
||||
}
|
||||
public record UnitClaim(UUID room,UUID quest) {public UnitClaim {Objects.requireNonNull(room);Objects.requireNonNull(quest);}}
|
||||
public record CharacterClaim(UUID room,UUID character) {public CharacterClaim {Objects.requireNonNull(room);Objects.requireNonNull(character);}}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package games.dmg.fantasystick.items;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
@@ -17,6 +18,7 @@ public final class CharacterSheets {
|
||||
public static final NamespacedKey MODEL = new NamespacedKey("fantasystick", "character_sheet");
|
||||
private static final NamespacedKey VERSION = new NamespacedKey("fantasystick", "sheet_version");
|
||||
private static final NamespacedKey CHARACTER = new NamespacedKey("fantasystick", "character_id");
|
||||
private static final NamespacedKey GENERATION = new NamespacedKey("fantasystick", "sheet_generation");
|
||||
private static final NamespacedKey RECOVERY = new NamespacedKey("fantasystick", "boss_recovery_operation");
|
||||
private static final List<Component> INSTRUCTION = List.of(Component.text("Character sheet:"),
|
||||
Component.text("Place in off hand"), Component.text("to enter fantasy world."));
|
||||
@@ -28,6 +30,7 @@ public final class CharacterSheets {
|
||||
var data = meta.getPersistentDataContainer();
|
||||
data.set(VERSION, PersistentDataType.INTEGER, 1);
|
||||
data.set(CHARACTER, PersistentDataType.STRING, character.id().toString());
|
||||
data.set(GENERATION, PersistentDataType.STRING, character.sheetGeneration().toString());
|
||||
meta.setItemModel(MODEL);
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
@@ -65,6 +68,27 @@ public final class CharacterSheets {
|
||||
return true;
|
||||
}
|
||||
|
||||
public record Identity(UUID character, BigInteger generation, boolean legacy) { }
|
||||
|
||||
public Optional<Identity> identity(ItemStack item) {
|
||||
var character = identify(item);
|
||||
if (character.isEmpty()) return Optional.empty();
|
||||
var data = item.getItemMeta().getPersistentDataContainer();
|
||||
if (!data.has(GENERATION, PersistentDataType.STRING))
|
||||
return Optional.of(new Identity(character.orElseThrow(), BigInteger.ZERO, true));
|
||||
try {
|
||||
var generation = new BigInteger(data.get(GENERATION, PersistentDataType.STRING));
|
||||
if (generation.signum() < 0) return Optional.empty();
|
||||
return Optional.of(new Identity(character.orElseThrow(), generation, false));
|
||||
} catch (RuntimeException invalid) { return Optional.empty(); }
|
||||
}
|
||||
|
||||
public boolean authorizes(ItemStack item, CharacterRecord record) {
|
||||
return identity(item).filter(value -> value.character().equals(record.id())
|
||||
&& value.generation().equals(record.sheetGeneration())
|
||||
&& (!value.legacy() || record.sheetGeneration().signum() == 0)).isPresent();
|
||||
}
|
||||
|
||||
/** A reference, not authorization to activate: the caller must load saved state and claim the session. */
|
||||
public Optional<UUID> identify(ItemStack item) {
|
||||
if (item == null || item.getType() != Material.PAPER || !item.hasItemMeta()) return Optional.empty();
|
||||
|
||||
@@ -145,7 +145,7 @@ public final class SessionControls {
|
||||
|
||||
private boolean validKind(Material material, int slot, PersistentDataContainer data) {
|
||||
String kind = data.get(KIND, PersistentDataType.STRING);
|
||||
if (slot == 0) return kind.equals("book") && (material == Material.BOOK || material == Material.COMPASS) && !data.has(ABILITY);
|
||||
if (slot == 0) return kind.equals("book") && (material == Material.BOOK || material == Material.COMPASS || material == Material.FISHING_ROD) && !data.has(ABILITY);
|
||||
if (slot == 1) return kind.equals("basic_attack") && material == Material.STICK && !data.has(ABILITY);
|
||||
if (kind.equals("empty")) return material == Material.GRAY_DYE && !data.has(ABILITY);
|
||||
if (!data.has(ABILITY, PersistentDataType.STRING)) return false;
|
||||
|
||||
@@ -16,6 +16,7 @@ import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
@@ -74,6 +75,8 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
private final java.util.concurrent.ConcurrentLinkedQueue<UUID> retries = new java.util.concurrent.ConcurrentLinkedQueue<>();
|
||||
private org.bukkit.scheduler.BukkitTask retryTask;
|
||||
private java.util.function.Predicate<org.bukkit.entity.Entity> excluded = ignored -> false;
|
||||
private java.util.function.Function<LivingEntity,java.util.Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin>> managedOrigins=ignored->java.util.Optional.empty();
|
||||
private Consumer<games.dmg.fantasystick.progression.ManagedEncounterOrigin> managedSettlement=ignored->{};
|
||||
private boolean closed;
|
||||
|
||||
private final class PendingAward {
|
||||
@@ -84,7 +87,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
final CombatRewardRules.KillPosition restricted;
|
||||
final CombatRewardRules rewardRules;
|
||||
BigInteger expectedRevision;
|
||||
boolean running;
|
||||
boolean running;final CompletableFuture<Void> settled=new CompletableFuture<>();
|
||||
PendingAward(UUID id, UUID character, BigInteger experience, BigInteger coins,
|
||||
CombatRewardRules.KillPosition restricted, CombatRewardRules rewardRules) {
|
||||
this.id = id; this.character = character; this.experience = experience;
|
||||
@@ -131,6 +134,11 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
excluded = Objects.requireNonNull(predicate);
|
||||
}
|
||||
|
||||
public void attachManagedOrigins(java.util.function.Function<LivingEntity,java.util.Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin>> classifier){
|
||||
if(closed)throw new IllegalStateException("Combat experience is closed");managedOrigins=Objects.requireNonNull(classifier);
|
||||
}
|
||||
public void attachManagedSettlement(Consumer<games.dmg.fantasystick.progression.ManagedEncounterOrigin> settlement){if(closed)throw new IllegalStateException("Combat experience is closed");managedSettlement=Objects.requireNonNull(settlement);}
|
||||
|
||||
public void install(org.bukkit.plugin.Plugin plugin) {
|
||||
if (closed || retryTask != null) throw new IllegalStateException("Combat experience is already installed or closed");
|
||||
retryTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::retryPending, 20, 20);
|
||||
@@ -204,27 +212,31 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
private void award(LivingEntity target, CombatRewardRules.Target kind, int level, UUID defeatedCharacter,
|
||||
BigInteger defeatedCoins) {
|
||||
Instant now = clock.instant();
|
||||
java.util.Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin> managed;try{managed=managedOrigins.apply(target);}catch(RuntimeException invalid){managed=java.util.Optional.empty();}
|
||||
var direct = new ArrayList<>(contributions.consumeDirect(target.getUniqueId(), now));
|
||||
if (defeatedCharacter != null) direct.remove(defeatedCharacter);
|
||||
if (direct.isEmpty()) return;
|
||||
if (direct.isEmpty()) {managed.ifPresent(this::settled);return;}
|
||||
var units = rewardUnits(direct);
|
||||
BigInteger coinPool = defeatedCharacter == null
|
||||
? coinRules.get().mob(coinTarget(target), random) : defeatedCoins;
|
||||
if (units.isEmpty()) return; // The defeat transition has already persisted any PvP balance loss.
|
||||
if (units.isEmpty()) {managed.ifPresent(this::settled);return;} // The defeat transition has already persisted any PvP balance loss.
|
||||
var capturedRewards = rewards;
|
||||
var xp = games.dmg.fantasystick.progression.RewardAllocator.allocate(capturedRewards.pool(kind, level), units, random);
|
||||
var coins = games.dmg.fantasystick.progression.RewardAllocator.allocate(coinPool, units, random);
|
||||
CombatRewardRules.KillPosition restricted = kind == CombatRewardRules.Target.PASSIVE
|
||||
CombatRewardRules.KillPosition restricted = managed.isPresent()||kind == CombatRewardRules.Target.PASSIVE
|
||||
|| 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");
|
||||
var settlements=new ArrayList<CompletableFuture<Void>>();
|
||||
for (UUID character : xp.keySet()) {
|
||||
UUID receipt = UUID.nameUUIDFromBytes((targetReward + ":" + character + ":" + now).getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
var operation = new PendingAward(receipt, character, xp.get(character), coins.get(character), restricted,
|
||||
capturedRewards);
|
||||
pending.put(operation.id, operation);
|
||||
attempt(operation);
|
||||
String receiptAuthority=managed.isPresent()?authority:targetReward+":"+now+":"+authority;
|
||||
UUID receipt = UUID.nameUUIDFromBytes((receiptAuthority+":"+character).getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
var proposed = new PendingAward(receipt, character, xp.get(character), coins.get(character), restricted,capturedRewards);
|
||||
var existing=pending.putIfAbsent(receipt,proposed);var operation=existing==null?proposed:existing;settlements.add(operation.settled);attempt(operation);
|
||||
}
|
||||
managed.ifPresent(origin->CompletableFuture.allOf(settlements.toArray(CompletableFuture[]::new)).thenRun(()->settled(origin)));
|
||||
}
|
||||
private void settled(games.dmg.fantasystick.progression.ManagedEncounterOrigin origin){try{managedSettlement.accept(origin);}catch(RuntimeException ignored){}}
|
||||
|
||||
private java.util.List<games.dmg.fantasystick.progression.RewardAllocator.Unit<UUID>> rewardUnits(
|
||||
java.util.List<UUID> direct) {
|
||||
@@ -272,7 +284,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
characters.find(operation.character).whenComplete((record, failure) -> {
|
||||
synchronized (operation) { operation.running = false; }
|
||||
if (failure != null) { fail(operation, failure); return; }
|
||||
if (record.isEmpty() || record.orElseThrow().dead()) { pending.remove(operation.id); return; }
|
||||
if (record.isEmpty() || record.orElseThrow().dead()) { complete(operation); return; }
|
||||
operation.expectedRevision = record.orElseThrow().revision();
|
||||
attempt(operation);
|
||||
});
|
||||
@@ -285,13 +297,14 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
if (award.status() == CharacterStorage.CombatAwardStatus.STALE && award.record().isPresent()) {
|
||||
operation.expectedRevision = award.record().orElseThrow().revision(); attempt(operation); return;
|
||||
}
|
||||
pending.remove(operation.id);
|
||||
complete(operation);
|
||||
if (award.record().isEmpty() || award.status() == CharacterStorage.CombatAwardStatus.UNAVAILABLE) return;
|
||||
try { nextTick.accept(() -> acknowledge(operation, award)); }
|
||||
catch (RuntimeException rejected) { report(operation.character, rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void complete(PendingAward operation){pending.remove(operation.id,operation);operation.settled.complete(null);}
|
||||
private void fail(PendingAward operation, Throwable failure) {
|
||||
report(operation.character, failure);
|
||||
if (!closed && pending.containsKey(operation.id)) retries.offer(operation.id);
|
||||
|
||||
@@ -20,6 +20,7 @@ public final class PartyRoster implements AutoCloseable {
|
||||
public int maximumSize(){int value=maximumSize.getAsInt();if(value<1)throw new IllegalStateException("Party maximum must be positive");return value;}
|
||||
public Optional<Party> create(UUID player){if(closed)return Optional.empty();if(durable!=null){if(!durable.ready())return Optional.empty();var character=character(player);if(character==null)return Optional.empty();var result=durable.create(character,"Party "+character.toString().substring(0,8)).join();return result.party().flatMap(this::project);}if(closed||!connected(player)||membership.containsKey(player))return Optional.empty();var party=new Party(UUID.randomUUID(),player,List.of(player));membership.put(player,party);return Optional.of(party);}
|
||||
public Optional<Party> partyOf(UUID player){if(durable!=null){if(!durable.ready())return Optional.empty();var character=character(player);return character==null?Optional.empty():durable.partyOf(character).flatMap(this::project);}return Optional.ofNullable(membership.get(player));}
|
||||
public Optional<Party> partyOfCharacter(UUID character){if(durable!=null)return durable.ready()?durable.partyOf(character).flatMap(this::project):Optional.empty();return Optional.ofNullable(membership.get(character));}
|
||||
public Optional<RewardMembership> rewardMembership(UUID player){if(durable!=null){if(!durable.ready())return Optional.empty();var character=character(player);if(character==null)return Optional.empty();return durable.partyOf(character).map(party->new RewardMembership(party.id(),activePlayers(party)));}var party=membership.get(player);return party==null?Optional.empty():Optional.of(new RewardMembership(party.id(),party.members().stream().filter(this::connected).toList()));}
|
||||
public List<Invitation> invitationsFor(UUID player){if(durable!=null){if(!durable.ready())return List.of();var character=character(player);if(character==null)return List.of();return durable.invitationsFor(character).stream().map(value->new Invitation(value.id(),value.party(),new UUID(0,0),player,Instant.MAX)).toList();}expire();return invitations.values().stream().filter(i->i.invitee().equals(player)).sorted(Comparator.comparing(Invitation::expires).thenComparing(Invitation::id)).toList();}
|
||||
public Optional<Invitation> invite(UUID leader,UUID party,UUID invitee){if(closed)return Optional.empty();if(durable!=null){if(!durable.ready())return Optional.empty();var source=character(leader);var target=character(invitee);if(source==null||target==null)return Optional.empty();var result=durable.invite(source,party,target).join();return result.invitation().map(value->new Invitation(value.id(),value.party(),leader,invitee,Instant.MAX));}expire();var group=managedBy(leader,party);if(group==null||group.members().size()>=maximumSize()||!connected(invitee)||membership.containsKey(invitee))return Optional.empty();var existing=invitations.values().stream().filter(i->i.party().equals(party)&&i.invitee().equals(invitee)).findFirst();if(existing.isPresent())return existing;var invitation=new Invitation(UUID.randomUUID(),party,leader,invitee,clock.instant().plusSeconds(60));invitations.put(invitation.id(),invitation);return Optional.of(invitation);}
|
||||
|
||||
@@ -20,21 +20,31 @@ public final class AdventureQuestSettings implements AutoCloseable {
|
||||
CAMP_ENEMIES="adventure-camp-enemies",
|
||||
CAMP_REWARD_XP="adventure-camp-reward-xp", CAMP_REWARD_COINS="adventure-camp-reward-coins",
|
||||
CHAMPION_HEALTH="adventure-champion-health", CHAMPION_DAMAGE="adventure-champion-damage",
|
||||
CHAMPION_REWARD_XP="adventure-champion-reward-xp", CHAMPION_REWARD_COINS="adventure-champion-reward-coins";
|
||||
CHAMPION_REWARD_XP="adventure-champion-reward-xp", CHAMPION_REWARD_COINS="adventure-champion-reward-coins",
|
||||
FISHING_CATCH_XP_MIN="fishing-catch-xp-min", FISHING_CATCH_XP_MAX="fishing-catch-xp-max";
|
||||
private static final Map<String,String> DEFAULTS=Map.ofEntries(
|
||||
Map.entry(DAILY_COUNT,"3"),Map.entry(RADIUS,"16"),Map.entry(RESET_SECONDS,"30"),
|
||||
Map.entry(DUNGEON_ROOM_LINK_DISTANCE,"10"),Map.entry(CAMP_ENEMIES,"8"),
|
||||
Map.entry(CAMP_REWARD_XP,"100"),Map.entry(CAMP_REWARD_COINS,"20"),Map.entry(CHAMPION_HEALTH,"100"),
|
||||
Map.entry(CHAMPION_DAMAGE,"8"),Map.entry(CHAMPION_REWARD_XP,"150"),Map.entry(CHAMPION_REWARD_COINS,"40"));
|
||||
Map.entry(CHAMPION_DAMAGE,"8"),Map.entry(CHAMPION_REWARD_XP,"150"),Map.entry(CHAMPION_REWARD_COINS,"40"),
|
||||
Map.entry(FISHING_CATCH_XP_MIN,"1"),Map.entry(FISHING_CATCH_XP_MAX,"3"));
|
||||
private static final List<String> KEYS=DEFAULTS.keySet().stream().sorted().toList();
|
||||
public record Rules(int dailySiteCount,int radius,int resetSeconds,int campEnemies,
|
||||
BigInteger campRewardXp,BigInteger campRewardCoins,int championHealth,int championDamage,
|
||||
BigInteger championRewardXp,BigInteger championRewardCoins,int dungeonRoomLinkDistance) {
|
||||
BigInteger championRewardXp,BigInteger championRewardCoins,int dungeonRoomLinkDistance,
|
||||
BigInteger fishingCatchXpMin,BigInteger fishingCatchXpMax) {
|
||||
public Rules {Objects.requireNonNull(fishingCatchXpMin);Objects.requireNonNull(fishingCatchXpMax);if(fishingCatchXpMin.signum()<0||fishingCatchXpMax.compareTo(BigInteger.valueOf(Integer.MAX_VALUE))>0||fishingCatchXpMin.compareTo(fishingCatchXpMax)>0)throw new IllegalArgumentException("Invalid fishing catch XP range");}
|
||||
public Rules(int dailySiteCount,int radius,int resetSeconds,int campEnemies,
|
||||
BigInteger campRewardXp,BigInteger campRewardCoins,int championHealth,int championDamage,
|
||||
BigInteger championRewardXp,BigInteger championRewardCoins) {
|
||||
this(dailySiteCount,radius,resetSeconds,campEnemies,campRewardXp,campRewardCoins,championHealth,
|
||||
championDamage,championRewardXp,championRewardCoins,10);
|
||||
championDamage,championRewardXp,championRewardCoins,10,BigInteger.ONE,BigInteger.valueOf(3));
|
||||
}
|
||||
public Rules(int dailySiteCount,int radius,int resetSeconds,int campEnemies,
|
||||
BigInteger campRewardXp,BigInteger campRewardCoins,int championHealth,int championDamage,
|
||||
BigInteger championRewardXp,BigInteger championRewardCoins,int dungeonRoomLinkDistance) {
|
||||
this(dailySiteCount,radius,resetSeconds,campEnemies,campRewardXp,campRewardCoins,championHealth,
|
||||
championDamage,championRewardXp,championRewardCoins,dungeonRoomLinkDistance,BigInteger.ONE,BigInteger.valueOf(3));
|
||||
}
|
||||
}
|
||||
public record Update(String key,String value,Map<String,String> values,long sequence) {
|
||||
@@ -64,7 +74,8 @@ public final class AdventureQuestSettings implements AutoCloseable {
|
||||
whole(values.get(CAMP_REWARD_XP)),whole(values.get(CAMP_REWARD_COINS)),
|
||||
bounded(values.get(CHAMPION_HEALTH),1,2048),bounded(values.get(CHAMPION_DAMAGE),1,2048),
|
||||
whole(values.get(CHAMPION_REWARD_XP)),whole(values.get(CHAMPION_REWARD_COINS)),
|
||||
bounded(values.get(DUNGEON_ROOM_LINK_DISTANCE),1,256));}
|
||||
bounded(values.get(DUNGEON_ROOM_LINK_DISTANCE),1,256),whole(values.getOrDefault(FISHING_CATCH_XP_MIN,DEFAULTS.get(FISHING_CATCH_XP_MIN))),
|
||||
whole(values.getOrDefault(FISHING_CATCH_XP_MAX,DEFAULTS.get(FISHING_CATCH_XP_MAX))));}
|
||||
catch(RuntimeException invalid){throw new IllegalArgumentException("Invalid adventure quest settings",invalid);}
|
||||
}
|
||||
private static int bounded(String raw,int minimum,int maximum){var value=new BigInteger(raw);if(value.compareTo(BigInteger.valueOf(minimum))<0||value.compareTo(BigInteger.valueOf(maximum))>0)throw new IllegalArgumentException();return value.intValueExact();}
|
||||
|
||||
@@ -19,7 +19,9 @@ public record CharacterRecord(
|
||||
Set<String> learned, List<String> loadout, List<String> passives, Map<String, Instant> cooldowns,
|
||||
Map<UUID, CombatRewardRules.KillPosition> restrictedKills,
|
||||
boolean dead, UUID deathId, Instant diedAt,
|
||||
BigInteger coins, boolean coinsInitialized, Set<UUID> rewardReceipts, BigInteger pvpCoinEscrow,CharacterGear gear) {
|
||||
BigInteger coins, boolean coinsInitialized, Set<UUID> rewardReceipts, BigInteger pvpCoinEscrow,
|
||||
CharacterGear gear, BigInteger sheetGeneration) {
|
||||
public CharacterRecord(UUID id,String name,BigInteger revision,Map<Stat,BigInteger> stats,int level,BigInteger experience,BigInteger unspentPoints,BigDecimal health,Set<String> learned,List<String> loadout,List<String> passives,Map<String,Instant> cooldowns,Map<UUID,CombatRewardRules.KillPosition> restrictedKills,boolean dead,UUID deathId,Instant diedAt,BigInteger coins,boolean coinsInitialized,Set<UUID> rewardReceipts,BigInteger pvpCoinEscrow,CharacterGear gear){this(id,name,revision,stats,level,experience,unspentPoints,health,learned,loadout,passives,cooldowns,restrictedKills,dead,deathId,diedAt,coins,coinsInitialized,rewardReceipts,pvpCoinEscrow,gear,BigInteger.ZERO);}
|
||||
public CharacterRecord(UUID id,String name,BigInteger revision,Map<Stat,BigInteger> stats,int level,BigInteger experience,BigInteger unspentPoints,BigDecimal health,Set<String> learned,List<String> loadout,List<String> passives,Map<String,Instant> cooldowns,Map<UUID,CombatRewardRules.KillPosition> restrictedKills,boolean dead,UUID deathId,Instant diedAt,BigInteger coins,boolean coinsInitialized,Set<UUID> rewardReceipts,BigInteger pvpCoinEscrow){this(id,name,revision,stats,level,experience,unspentPoints,health,learned,loadout,passives,cooldowns,restrictedKills,dead,deathId,diedAt,coins,coinsInitialized,rewardReceipts,pvpCoinEscrow,CharacterGear.none());}
|
||||
public CharacterRecord(UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
|
||||
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
|
||||
@@ -55,6 +57,16 @@ public record CharacterRecord(
|
||||
cooldowns, restrictedKills, dead, deathId, diedAt, BigInteger.ZERO, true, Set.of());
|
||||
}
|
||||
|
||||
public CharacterRecord(UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
|
||||
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
|
||||
Set<String> learned, List<String> loadout, List<String> passives, Map<String, Instant> cooldowns,
|
||||
Map<UUID, CombatRewardRules.KillPosition> restrictedKills,
|
||||
boolean dead, UUID deathId, Instant diedAt, BigInteger sheetGeneration) {
|
||||
this(id, name, revision, stats, level, experience, unspentPoints, health, learned, loadout, passives,
|
||||
cooldowns, restrictedKills, dead, deathId, diedAt, BigInteger.ZERO, true, Set.of(), BigInteger.ZERO,
|
||||
CharacterGear.none(), sheetGeneration);
|
||||
}
|
||||
|
||||
public CharacterRecord {
|
||||
Objects.requireNonNull(id);
|
||||
Objects.requireNonNull(name);
|
||||
@@ -65,6 +77,7 @@ public record CharacterRecord(
|
||||
Objects.requireNonNull(coins);
|
||||
Objects.requireNonNull(pvpCoinEscrow);
|
||||
Objects.requireNonNull(gear);
|
||||
Objects.requireNonNull(sheetGeneration);
|
||||
stats = Map.copyOf(stats);
|
||||
learned = Set.copyOf(learned);
|
||||
loadout = List.copyOf(loadout);
|
||||
@@ -85,7 +98,8 @@ public record CharacterRecord(
|
||||
|| cooldowns.keySet().stream().anyMatch(String::isBlank)
|
||||
|| restrictedKills.size() > 1024
|
||||
|| restrictedKills.entrySet().stream().anyMatch(entry -> !entry.getKey().equals(entry.getValue().world()))
|
||||
|| coins.signum() < 0 || pvpCoinEscrow.signum() < 0 || !coinsInitialized && coins.signum() != 0
|
||||
|| coins.signum() < 0 || pvpCoinEscrow.signum() < 0 || sheetGeneration.signum() < 0
|
||||
|| !coinsInitialized && coins.signum() != 0
|
||||
|| rewardReceipts.size() > 4096 || !dead && pvpCoinEscrow.signum() != 0
|
||||
|| (deathId == null) != (diedAt == null) || dead && deathId == null) {
|
||||
throw new IllegalArgumentException("Invalid character record");
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.math.BigInteger;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Durable fail-closed authority for one explicitly confirmed saved-sheet recovery. */
|
||||
final class CharacterRecoveryStore {
|
||||
private static final int MAX_FILE_BYTES = 16_384;
|
||||
enum Phase { PREPARED, COMMITTED, DELIVERED }
|
||||
record Operation(UUID operation, UUID character, UUID administrator, UUID recipient,
|
||||
BigInteger expectedRevision, BigInteger expectedGeneration,
|
||||
BigInteger replacementGeneration, boolean revive, UUID death, Phase phase) {
|
||||
Operation {
|
||||
Objects.requireNonNull(operation); Objects.requireNonNull(character);
|
||||
Objects.requireNonNull(administrator); Objects.requireNonNull(recipient);
|
||||
Objects.requireNonNull(expectedRevision); Objects.requireNonNull(expectedGeneration);
|
||||
Objects.requireNonNull(replacementGeneration); Objects.requireNonNull(phase);
|
||||
if (expectedRevision.signum() < 0 || expectedGeneration.signum() < 0
|
||||
|| !replacementGeneration.equals(expectedGeneration.add(BigInteger.ONE))
|
||||
|| revive != (death != null)) throw new IllegalArgumentException("Invalid recovery operation");
|
||||
}
|
||||
Operation phase(Phase value) {
|
||||
return new Operation(operation, character, administrator, recipient, expectedRevision,
|
||||
expectedGeneration, replacementGeneration, revive, death, value);
|
||||
}
|
||||
}
|
||||
|
||||
private final Path directory;
|
||||
CharacterRecoveryStore(Path pluginDataDirectory) {
|
||||
directory = Objects.requireNonNull(pluginDataDirectory).resolve("character-recoveries").toAbsolutePath();
|
||||
}
|
||||
|
||||
Optional<Operation> find(UUID character) throws IOException {
|
||||
if (Files.notExists(directory, LinkOption.NOFOLLOW_LINKS)) return Optional.empty();
|
||||
safeDirectory();
|
||||
Path path = file(character);
|
||||
if (Files.notExists(path, LinkOption.NOFOLLOW_LINKS)) return Optional.empty();
|
||||
if (Files.isSymbolicLink(path) || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)
|
||||
|| Files.size(path) > MAX_FILE_BYTES) throw new IOException("Recovery record is unsafe");
|
||||
return Optional.of(decode(Files.readString(path, StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
/** Mutation predicate: corrupt or unreadable proof is unresolved and therefore blocks. */
|
||||
boolean blocks(UUID character) {
|
||||
try { return find(character).map(operation -> operation.phase() != Phase.DELIVERED).orElse(false); }
|
||||
catch (IOException unsafe) { return true; }
|
||||
}
|
||||
|
||||
void prepare(Operation operation) throws IOException {
|
||||
withLock(() -> {
|
||||
var existing = find(operation.character());
|
||||
if (existing.isPresent() && existing.orElseThrow().phase() != Phase.DELIVERED)
|
||||
throw new IOException("Character recovery is unresolved");
|
||||
write(operation);
|
||||
});
|
||||
}
|
||||
|
||||
void advance(UUID character, UUID operation, Phase expected, Phase replacement) throws IOException {
|
||||
withLock(() -> {
|
||||
var current = find(character).orElseThrow(() -> new IOException("Recovery operation missing"));
|
||||
if (!current.operation().equals(operation) || current.phase() != expected)
|
||||
throw new IOException("Recovery operation changed");
|
||||
write(current.phase(replacement));
|
||||
});
|
||||
}
|
||||
|
||||
void cancelPrepared(UUID character, UUID operation) throws IOException {
|
||||
withLock(() -> {
|
||||
var current = find(character);
|
||||
if (current.isEmpty()) return;
|
||||
if (!current.orElseThrow().operation().equals(operation)
|
||||
|| current.orElseThrow().phase() != Phase.PREPARED)
|
||||
throw new IOException("Recovery operation cannot be cancelled");
|
||||
Files.delete(file(character));
|
||||
DurableFiles.forceDirectory(directory);
|
||||
});
|
||||
}
|
||||
|
||||
private void write(Operation operation) throws IOException {
|
||||
safeDirectory();
|
||||
byte[] bytes = encode(operation).getBytes(StandardCharsets.UTF_8);
|
||||
Path temporary = Files.createTempFile(directory, ".recovery-", ".tmp");
|
||||
try {
|
||||
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE)) {
|
||||
var buffer = ByteBuffer.wrap(bytes);
|
||||
while (buffer.hasRemaining()) output.write(buffer);
|
||||
output.force(true);
|
||||
}
|
||||
Files.move(temporary, file(operation.character()), StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING);
|
||||
DurableFiles.forceDirectory(directory);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
}
|
||||
|
||||
private void withLock(IoAction action) throws IOException {
|
||||
safeDirectory();
|
||||
Path lockPath = directory.resolve(".recoveries.lock");
|
||||
try (var channel = FileChannel.open(lockPath, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
|
||||
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
|
||||
if (lock == null) throw new IOException("Recovery store busy");
|
||||
action.run();
|
||||
} catch (OverlappingFileLockException failure) { throw new IOException("Recovery store busy", failure); }
|
||||
}
|
||||
|
||||
private void safeDirectory() throws IOException {
|
||||
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS))
|
||||
throw new IOException("Recovery directory is unsafe");
|
||||
} else Files.createDirectories(directory);
|
||||
}
|
||||
|
||||
private Path file(UUID character) { return directory.resolve(Objects.requireNonNull(character) + ".yml"); }
|
||||
|
||||
private static String encode(Operation value) {
|
||||
return "schema-version: 1\n"
|
||||
+ "operation: " + value.operation() + "\n"
|
||||
+ "character: " + value.character() + "\n"
|
||||
+ "administrator: " + value.administrator() + "\n"
|
||||
+ "recipient: " + value.recipient() + "\n"
|
||||
+ "expected-revision: " + value.expectedRevision() + "\n"
|
||||
+ "expected-generation: " + value.expectedGeneration() + "\n"
|
||||
+ "replacement-generation: " + value.replacementGeneration() + "\n"
|
||||
+ "revive: " + value.revive() + "\n"
|
||||
+ "death: " + (value.death() == null ? "" : value.death()) + "\n"
|
||||
+ "phase: " + value.phase() + "\n";
|
||||
}
|
||||
|
||||
private static Operation decode(String text) throws IOException {
|
||||
var values = new LinkedHashMap<String, String>();
|
||||
for (String line : text.split("\\n", -1)) {
|
||||
if (line.isEmpty()) continue;
|
||||
int split = line.indexOf(": ");
|
||||
if (split < 1 || values.putIfAbsent(line.substring(0, split), line.substring(split + 2)) != null)
|
||||
throw new IOException("Invalid recovery record");
|
||||
}
|
||||
try {
|
||||
if (!values.keySet().equals(java.util.Set.of("schema-version", "operation", "character",
|
||||
"administrator", "recipient", "expected-revision", "expected-generation",
|
||||
"replacement-generation", "revive", "death", "phase"))
|
||||
|| !values.get("schema-version").equals("1")) throw new IllegalArgumentException();
|
||||
boolean revive = switch (values.get("revive")) { case "true" -> true; case "false" -> false;
|
||||
default -> throw new IllegalArgumentException(); };
|
||||
String death = values.get("death");
|
||||
return new Operation(UUID.fromString(values.get("operation")), UUID.fromString(values.get("character")),
|
||||
UUID.fromString(values.get("administrator")), UUID.fromString(values.get("recipient")),
|
||||
new BigInteger(values.get("expected-revision")), new BigInteger(values.get("expected-generation")),
|
||||
new BigInteger(values.get("replacement-generation")), revive,
|
||||
death.isEmpty() ? null : UUID.fromString(death), Phase.valueOf(values.get("phase")));
|
||||
} catch (RuntimeException invalid) { throw new IOException("Invalid recovery record"); }
|
||||
}
|
||||
|
||||
@FunctionalInterface private interface IoAction { void run() throws IOException; }
|
||||
}
|
||||
@@ -26,6 +26,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
private final Clock clock;
|
||||
private final java.util.function.Supplier<games.dmg.fantasystick.progression.CoinRules> coinRules;
|
||||
private final java.util.random.RandomGenerator coinRandom;
|
||||
private final CharacterRecoveryStore recoveries;
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-character-persistence"));
|
||||
|
||||
@@ -41,6 +42,8 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.coinRules = Objects.requireNonNull(coinRules);
|
||||
this.coinRandom = Objects.requireNonNull(coinRandom);
|
||||
this.recoveries = new CharacterRecoveryStore(repository.pluginDataDirectory());
|
||||
repository.attachMutationUnavailable(recoveries::blocks);
|
||||
}
|
||||
|
||||
public void attachUnavailableCharacter(java.util.function.Predicate<UUID> predicate){repository.attachUnavailable(predicate);}
|
||||
@@ -205,35 +208,174 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
return new CraftCleanup(removed, retained);
|
||||
}
|
||||
|
||||
public record Catalog(List<CharacterRecord> records, int unreadable) {
|
||||
public Catalog { records = List.copyOf(records); }
|
||||
public boolean complete() { return unreadable == 0; }
|
||||
}
|
||||
|
||||
public CompletableFuture<Catalog> catalog() {
|
||||
return submit(() -> {
|
||||
var catalog = repository.catalog();
|
||||
return new Catalog(catalog.records(), catalog.unreadable());
|
||||
});
|
||||
}
|
||||
|
||||
/** Pure authoritative read for administration; unlike gameplay loading, this never performs legacy migration. */
|
||||
public CompletableFuture<Optional<CharacterRecord>> inspect(UUID id) {
|
||||
return submit(() -> repository.load(Objects.requireNonNull(id)));
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<CharacterRecord>> find(UUID id) {
|
||||
return submit(() -> load(id));
|
||||
}
|
||||
|
||||
public enum RecoveryStatus { APPLIED, UNAVAILABLE, STALE, BLOCKED }
|
||||
public record RecoveryRequest(UUID operation, UUID character, UUID administrator, UUID recipient,
|
||||
BigInteger expectedRevision, BigInteger expectedGeneration,
|
||||
boolean revive, UUID death, java.math.BigDecimal nativeLimit) {
|
||||
public RecoveryRequest {
|
||||
Objects.requireNonNull(operation); Objects.requireNonNull(character);
|
||||
Objects.requireNonNull(administrator); Objects.requireNonNull(recipient);
|
||||
Objects.requireNonNull(expectedRevision); Objects.requireNonNull(expectedGeneration);
|
||||
Objects.requireNonNull(nativeLimit);
|
||||
if (expectedRevision.signum() < 0 || expectedGeneration.signum() < 0
|
||||
|| nativeLimit.signum() <= 0 || revive != (death != null))
|
||||
throw new IllegalArgumentException("Invalid character recovery request");
|
||||
}
|
||||
}
|
||||
public record RecoveryCommit(RecoveryStatus status, Optional<CharacterRecord> record, UUID operation) { }
|
||||
|
||||
/** Durable generation/revival commit; native delivery happens only after APPLIED acknowledgment. */
|
||||
public CompletableFuture<RecoveryCommit> beginRecovery(RecoveryRequest request) {
|
||||
Objects.requireNonNull(request);
|
||||
return submit(() -> {
|
||||
var found = repository.load(request.character());
|
||||
if (found.isEmpty()) return new RecoveryCommit(RecoveryStatus.UNAVAILABLE, Optional.empty(), request.operation());
|
||||
var old = found.orElseThrow();
|
||||
reconcileRecovery(old);
|
||||
if (!old.revision().equals(request.expectedRevision())
|
||||
|| !old.sheetGeneration().equals(request.expectedGeneration()))
|
||||
return new RecoveryCommit(RecoveryStatus.STALE, Optional.of(old), request.operation());
|
||||
if (request.revive()) {
|
||||
if (!old.dead() || !Objects.equals(old.deathId(), request.death()))
|
||||
return new RecoveryCommit(RecoveryStatus.STALE, Optional.of(old), request.operation());
|
||||
} else if (old.dead()) return new RecoveryCommit(RecoveryStatus.STALE, Optional.of(old), request.operation());
|
||||
var existing = recoveries.find(old.id());
|
||||
if (existing.isPresent() && existing.orElseThrow().phase() != CharacterRecoveryStore.Phase.DELIVERED)
|
||||
return new RecoveryCommit(RecoveryStatus.BLOCKED, Optional.of(old), request.operation());
|
||||
BigInteger nextGeneration = old.sheetGeneration().add(BigInteger.ONE);
|
||||
var operation = new CharacterRecoveryStore.Operation(request.operation(), old.id(),
|
||||
request.administrator(), request.recipient(), old.revision(), old.sheetGeneration(),
|
||||
nextGeneration, request.revive(), request.death(), CharacterRecoveryStore.Phase.PREPARED);
|
||||
recoveries.prepare(operation);
|
||||
var health = request.revive() ? old.maximumHealth().min(request.nativeLimit()) : old.health();
|
||||
var replacement = 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.passives(),
|
||||
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(),
|
||||
old.coinsInitialized(), old.rewardReceipts(), request.revive() ? BigInteger.ZERO : old.pvpCoinEscrow(),
|
||||
old.gear(), nextGeneration);
|
||||
try { repository.replaceForRecovery(replacement, old.revision()); }
|
||||
catch (IOException failure) {
|
||||
try { recoveries.cancelPrepared(old.id(), request.operation()); }
|
||||
catch (IOException retained) { failure.addSuppressed(retained); }
|
||||
throw failure;
|
||||
}
|
||||
recoveries.advance(old.id(), request.operation(), CharacterRecoveryStore.Phase.PREPARED,
|
||||
CharacterRecoveryStore.Phase.COMMITTED);
|
||||
return new RecoveryCommit(RecoveryStatus.APPLIED, Optional.of(replacement), request.operation());
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Boolean> completeRecovery(UUID character, UUID operation) {
|
||||
Objects.requireNonNull(character); Objects.requireNonNull(operation);
|
||||
return submit(() -> {
|
||||
var record = repository.load(character).orElseThrow(() -> new IOException("Recovered character missing"));
|
||||
var proof = recoveries.find(character).orElseThrow(() -> new IOException("Recovery operation missing"));
|
||||
if (!proof.operation().equals(operation) || proof.phase() != CharacterRecoveryStore.Phase.COMMITTED
|
||||
|| !replacementMatches(record, proof))
|
||||
throw new IOException("Recovery completion proof conflicts with saved character");
|
||||
recoveries.advance(character, operation, CharacterRecoveryStore.Phase.COMMITTED,
|
||||
CharacterRecoveryStore.Phase.DELIVERED);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** Persist a legacy character's one-time configured migration roll before exposing it. */
|
||||
private Optional<CharacterRecord> load(UUID id) throws IOException {
|
||||
var found = repository.load(id);
|
||||
if (found.isEmpty() || found.orElseThrow().coinsInitialized()) return found;
|
||||
if (found.isEmpty()) {
|
||||
if (recoveries.find(id).filter(operation -> operation.phase() != CharacterRecoveryStore.Phase.DELIVERED).isPresent())
|
||||
throw new IOException("Recovery proof references a missing character");
|
||||
return found;
|
||||
}
|
||||
reconcileRecovery(found.orElseThrow());
|
||||
if (found.orElseThrow().coinsInitialized()) return found;
|
||||
var old = found.orElseThrow();
|
||||
var migrated = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(),
|
||||
old.cooldowns(), old.restrictedKills(), old.dead(), old.deathId(), old.diedAt(),
|
||||
coinRules.get().starting(coinRandom), true, old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
coinRules.get().starting(coinRandom), true, old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
|
||||
repository.replace(migrated, old.revision());
|
||||
return Optional.of(migrated);
|
||||
}
|
||||
|
||||
private void reconcileRecovery(CharacterRecord record) throws IOException {
|
||||
var found = recoveries.find(record.id());
|
||||
if (found.isEmpty()) return;
|
||||
var operation = found.orElseThrow();
|
||||
if (operation.phase() == CharacterRecoveryStore.Phase.PREPARED) {
|
||||
if (expectedMatches(record, operation)) {
|
||||
recoveries.cancelPrepared(record.id(), operation.operation());
|
||||
return;
|
||||
}
|
||||
if (replacementMatches(record, operation)) {
|
||||
recoveries.advance(record.id(), operation.operation(), CharacterRecoveryStore.Phase.PREPARED,
|
||||
CharacterRecoveryStore.Phase.COMMITTED);
|
||||
throw new IOException("Recovery committed before acknowledgment; custody is unresolved");
|
||||
}
|
||||
throw new IOException("Prepared recovery proof conflicts with saved character");
|
||||
}
|
||||
if (operation.phase() == CharacterRecoveryStore.Phase.COMMITTED) {
|
||||
if (!replacementMatches(record, operation))
|
||||
throw new IOException("Committed recovery proof conflicts with saved character");
|
||||
throw new IOException("Character has unresolved committed recovery custody");
|
||||
}
|
||||
if (!record.sheetGeneration().equals(operation.replacementGeneration())
|
||||
|| record.revision().compareTo(operation.expectedRevision().add(BigInteger.ONE)) < 0)
|
||||
throw new IOException("Delivered recovery proof conflicts with saved character");
|
||||
}
|
||||
|
||||
private static boolean expectedMatches(CharacterRecord record, CharacterRecoveryStore.Operation operation) {
|
||||
return record.revision().equals(operation.expectedRevision())
|
||||
&& record.sheetGeneration().equals(operation.expectedGeneration())
|
||||
&& (operation.revive() ? record.dead() && Objects.equals(record.deathId(), operation.death()) : !record.dead());
|
||||
}
|
||||
|
||||
private static boolean replacementMatches(CharacterRecord record, CharacterRecoveryStore.Operation operation) {
|
||||
return record.revision().equals(operation.expectedRevision().add(BigInteger.ONE))
|
||||
&& record.sheetGeneration().equals(operation.replacementGeneration())
|
||||
&& !record.dead() && (!operation.revive() || Objects.equals(record.deathId(), operation.death()));
|
||||
}
|
||||
|
||||
/** Persist an accepted native rename against the latest live record; never create a character from item metadata. */
|
||||
public CompletableFuture<Optional<CharacterRecord>> renameCharacter(UUID characterId, String name) {
|
||||
return renameCharacter(characterId, name, null);
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<CharacterRecord>> renameCharacter(
|
||||
UUID characterId, String name, BigInteger expectedSheetGeneration) {
|
||||
Objects.requireNonNull(characterId);
|
||||
Objects.requireNonNull(name);
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) return Optional.empty();
|
||||
var old = found.orElseThrow();
|
||||
if (expectedSheetGeneration != null && !old.sheetGeneration().equals(expectedSheetGeneration))
|
||||
return Optional.empty();
|
||||
if (old.name().equals(name)) return Optional.of(old);
|
||||
var renamed = new CharacterRecord(old.id(), name, old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
|
||||
repository.replace(renamed, old.revision());
|
||||
return Optional.of(renamed);
|
||||
});
|
||||
@@ -273,7 +415,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
int gained = Math.max(0, level - old.level());
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
level, experience, old.unspentPoints().add(BigInteger.valueOf(gained)), old.health(), old.learned(),
|
||||
old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
|
||||
repository.replace(saved, old.revision());
|
||||
return new ExperienceAward(ExperienceAwardStatus.APPLIED, Optional.of(saved), gained);
|
||||
});
|
||||
@@ -309,7 +451,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(),
|
||||
old.passives(), old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(),
|
||||
old.coins().subtract(amount), true, receipts,old.pvpCoinEscrow(),old.gear());
|
||||
old.coins().subtract(amount), true, receipts,old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
|
||||
repository.replace(saved, old.revision());
|
||||
return new CoinSpend(CoinSpendStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -318,12 +460,12 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
public enum CoinRefundStatus { APPLIED, DUPLICATE, UNAVAILABLE }
|
||||
public record CoinRefund(CoinRefundStatus status,Optional<CharacterRecord> record){}
|
||||
/** Compensate a persisted debit exactly once while retaining both audit receipts. */
|
||||
public CompletableFuture<CoinRefund> refundCoins(UUID characterId,UUID debitId,UUID refundId,BigInteger amount){Objects.requireNonNull(characterId);Objects.requireNonNull(debitId);Objects.requireNonNull(refundId);Objects.requireNonNull(amount);if(amount.signum()<0)throw new IllegalArgumentException("Coin refund must not be negative");return submit(()->{var found=repository.load(characterId);if(found.isEmpty())return new CoinRefund(CoinRefundStatus.UNAVAILABLE,Optional.empty());var old=found.orElseThrow();if(old.rewardReceipts().contains(refundId))return new CoinRefund(CoinRefundStatus.DUPLICATE,Optional.of(old));if(!old.rewardReceipts().contains(debitId))return new CoinRefund(CoinRefundStatus.UNAVAILABLE,Optional.of(old));var receipts=new java.util.HashSet<>(old.rewardReceipts());if(receipts.size()>=4096)receipts.remove(receipts.stream().filter(id->!id.equals(debitId)).min(UUID::compareTo).orElseThrow());receipts.add(refundId);var saved=new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),old.dead(),old.deathId(),old.diedAt(),old.coins().add(amount),old.coinsInitialized(),receipts,old.pvpCoinEscrow(),old.gear());repository.replace(saved,old.revision());return new CoinRefund(CoinRefundStatus.APPLIED,Optional.of(saved));});}
|
||||
public CompletableFuture<CoinRefund> refundCoins(UUID characterId,UUID debitId,UUID refundId,BigInteger amount){Objects.requireNonNull(characterId);Objects.requireNonNull(debitId);Objects.requireNonNull(refundId);Objects.requireNonNull(amount);if(amount.signum()<0)throw new IllegalArgumentException("Coin refund must not be negative");return submit(()->{var found=repository.load(characterId);if(found.isEmpty())return new CoinRefund(CoinRefundStatus.UNAVAILABLE,Optional.empty());var old=found.orElseThrow();if(old.rewardReceipts().contains(refundId))return new CoinRefund(CoinRefundStatus.DUPLICATE,Optional.of(old));if(!old.rewardReceipts().contains(debitId))return new CoinRefund(CoinRefundStatus.UNAVAILABLE,Optional.of(old));var receipts=new java.util.HashSet<>(old.rewardReceipts());if(receipts.size()>=4096)receipts.remove(receipts.stream().filter(id->!id.equals(debitId)).min(UUID::compareTo).orElseThrow());receipts.add(refundId);var saved=new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),old.dead(),old.deathId(),old.diedAt(),old.coins().add(amount),old.coinsInitialized(),receipts,old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());repository.replace(saved,old.revision());return new CoinRefund(CoinRefundStatus.APPLIED,Optional.of(saved));});}
|
||||
|
||||
public enum GearPurchaseStatus { APPLIED,DUPLICATE,INSUFFICIENT,NOT_UPGRADE,STALE,UNAVAILABLE }
|
||||
public record GearPurchase(GearPurchaseStatus status,Optional<CharacterRecord> record){}
|
||||
/** One character-file transaction owns both the exact coin debit and strict gear replacement. */
|
||||
public CompletableFuture<GearPurchase> purchaseGear(UUID characterId,BigInteger expectedRevision,UUID operationId,GearSlot slot,GearTier target,BigInteger price){Objects.requireNonNull(characterId);Objects.requireNonNull(expectedRevision);Objects.requireNonNull(operationId);Objects.requireNonNull(slot);Objects.requireNonNull(target);Objects.requireNonNull(price);if(target==GearTier.NONE||price.signum()<0)throw new IllegalArgumentException("Invalid gear purchase");return submit(()->{var found=repository.load(characterId);if(found.isEmpty()||found.orElseThrow().dead())return new GearPurchase(GearPurchaseStatus.UNAVAILABLE,Optional.empty());var old=found.orElseThrow();if(old.rewardReceipts().contains(operationId))return new GearPurchase(GearPurchaseStatus.DUPLICATE,Optional.of(old));if(!old.revision().equals(expectedRevision))return new GearPurchase(GearPurchaseStatus.STALE,Optional.of(old));if(!target.higherThan(old.gear().tier(slot)))return new GearPurchase(GearPurchaseStatus.NOT_UPGRADE,Optional.of(old));if(old.coins().compareTo(price)<0)return new GearPurchase(GearPurchaseStatus.INSUFFICIENT,Optional.of(old));var receipts=new java.util.HashSet<>(old.rewardReceipts());if(receipts.size()>=4096)receipts.remove(receipts.stream().min(UUID::compareTo).orElseThrow());receipts.add(operationId);var saved=new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),false,old.deathId(),old.diedAt(),old.coins().subtract(price),true,receipts,old.pvpCoinEscrow(),old.gear().with(slot,target));repository.replace(saved,old.revision());return new GearPurchase(GearPurchaseStatus.APPLIED,Optional.of(saved));});}
|
||||
public CompletableFuture<GearPurchase> purchaseGear(UUID characterId,BigInteger expectedRevision,UUID operationId,GearSlot slot,GearTier target,BigInteger price){Objects.requireNonNull(characterId);Objects.requireNonNull(expectedRevision);Objects.requireNonNull(operationId);Objects.requireNonNull(slot);Objects.requireNonNull(target);Objects.requireNonNull(price);if(target==GearTier.NONE||price.signum()<0)throw new IllegalArgumentException("Invalid gear purchase");return submit(()->{var found=repository.load(characterId);if(found.isEmpty()||found.orElseThrow().dead())return new GearPurchase(GearPurchaseStatus.UNAVAILABLE,Optional.empty());var old=found.orElseThrow();if(old.rewardReceipts().contains(operationId))return new GearPurchase(GearPurchaseStatus.DUPLICATE,Optional.of(old));if(!old.revision().equals(expectedRevision))return new GearPurchase(GearPurchaseStatus.STALE,Optional.of(old));if(!target.higherThan(old.gear().tier(slot)))return new GearPurchase(GearPurchaseStatus.NOT_UPGRADE,Optional.of(old));if(old.coins().compareTo(price)<0)return new GearPurchase(GearPurchaseStatus.INSUFFICIENT,Optional.of(old));var receipts=new java.util.HashSet<>(old.rewardReceipts());if(receipts.size()>=4096)receipts.remove(receipts.stream().min(UUID::compareTo).orElseThrow());receipts.add(operationId);var saved=new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),false,old.deathId(),old.diedAt(),old.coins().subtract(price),true,receipts,old.pvpCoinEscrow(),old.gear().with(slot,target),old.sheetGeneration());repository.replace(saved,old.revision());return new GearPurchase(GearPurchaseStatus.APPLIED,Optional.of(saved));});}
|
||||
|
||||
public enum CombatAwardStatus { APPLIED, SPACING_BLOCKED, AT_CAP, UNCHANGED, DUPLICATE, STALE, UNAVAILABLE }
|
||||
public record CombatAward(CombatAwardStatus status, Optional<CharacterRecord> record, int levels) { }
|
||||
@@ -378,7 +520,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
level, experience, old.unspentPoints().add(BigInteger.valueOf(gained)), old.health(), old.learned(),
|
||||
old.loadout(), old.passives(), old.cooldowns(), history, false, old.deathId(), old.diedAt(),
|
||||
old.coins().add(eligible ? coinReward : BigInteger.ZERO), true, receipts,old.pvpCoinEscrow(),old.gear());
|
||||
old.coins().add(eligible ? coinReward : BigInteger.ZERO), true, receipts,old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
|
||||
repository.replace(saved, old.revision());
|
||||
return new CombatAward(status, Optional.of(saved), gained);
|
||||
});
|
||||
@@ -418,7 +560,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var passives = new java.util.ArrayList<>(old.passives()); passives.set(slot, passive);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints().subtract(cost), old.health(), old.learned(), old.loadout(), passives,
|
||||
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
|
||||
repository.replace(saved, old.revision());
|
||||
return new PassiveMutation(PassiveMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -446,7 +588,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var passives = old.passives().stream().map(id -> id.equals(passive) ? "" : id).toList();
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints().add(BigInteger.ONE), old.health(), old.learned(), old.loadout(), passives,
|
||||
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
|
||||
repository.replace(saved, old.revision());
|
||||
return new PassiveMutation(PassiveMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -481,7 +623,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var loadout = new java.util.ArrayList<>(old.loadout()); loadout.set(slot, ability);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints().subtract(cost), old.health(), learned, loadout, old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -501,7 +643,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var loadout = old.loadout().stream().map(assigned -> assigned.equals(ability) ? "" : assigned).toList();
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints().add(BigInteger.ONE), old.health(), learned, loadout, old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -524,7 +666,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
if (loadout.equals(old.loadout())) return new AbilityMutation(AbilityMutationStatus.UNCHANGED, Optional.of(old));
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints(), old.health(), old.learned(), loadout, old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -548,7 +690,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var cooldowns = new java.util.HashMap<>(old.cooldowns()); cooldowns.put(ability, readyAt);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(), cooldowns,
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -593,7 +735,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
|
||||
old.level(), old.experience(), old.unspentPoints().subtract(BigInteger.ONE),
|
||||
currentHealth.add(gain), old.learned(), old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), false,
|
||||
old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
|
||||
repository.replace(saved, old.revision());
|
||||
return new StatPurchase(StatPurchaseStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -619,7 +761,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
if (old.health().compareTo(bounded) == 0) return Optional.of(old);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints(), bounded, old.learned(), old.loadout(), old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
|
||||
repository.replace(saved, old.revision());
|
||||
return Optional.of(saved);
|
||||
});
|
||||
@@ -656,7 +798,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
old.level(), old.experience(), old.unspentPoints(), java.math.BigDecimal.ZERO, old.learned(),
|
||||
old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), true, deathId, diedAt,
|
||||
loseCoins ? BigInteger.ZERO : old.coins(), old.coinsInitialized(), old.rewardReceipts(),
|
||||
loseCoins ? old.coins() : BigInteger.ZERO,old.gear());
|
||||
loseCoins ? old.coins() : BigInteger.ZERO,old.gear(),old.sheetGeneration());
|
||||
repository.replace(dead, old.revision());
|
||||
return Optional.of(dead);
|
||||
});
|
||||
@@ -693,7 +835,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var health = nativeLimit == null ? old.maximumHealth() : old.maximumHealth().min(nativeLimit);
|
||||
var restored = 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.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),BigInteger.ZERO,old.gear());
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),BigInteger.ZERO,old.gear(),old.sheetGeneration());
|
||||
repository.replace(restored, old.revision());
|
||||
return Optional.of(restored);
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ public final class CharacterYaml {
|
||||
root.put("character-id", record.id().toString());
|
||||
root.put("name", record.name());
|
||||
root.put("revision", record.revision().toString());
|
||||
root.put("sheet-generation", record.sheetGeneration().toString());
|
||||
Map<String, String> stats = new LinkedHashMap<>();
|
||||
for (Stat stat : Stat.values()) stats.put(stat.name(), record.stats().get(stat).toString());
|
||||
root.put("stats", stats);
|
||||
@@ -108,7 +109,8 @@ public final class CharacterYaml {
|
||||
root.containsKey("reward-receipts") ? strings(root.get("reward-receipts")).stream()
|
||||
.map(UUID::fromString).collect(java.util.stream.Collectors.toUnmodifiableSet()) : java.util.Set.of(),
|
||||
root.containsKey("pvp-coin-escrow") ? number(root.get("pvp-coin-escrow")) : BigInteger.ZERO,
|
||||
root.containsKey("gear")?gear(root.get("gear")):CharacterGear.none());
|
||||
root.containsKey("gear")?gear(root.get("gear")):CharacterGear.none(),
|
||||
root.containsKey("sheet-generation") ? number(root.get("sheet-generation")) : BigInteger.ZERO);
|
||||
}
|
||||
|
||||
private static CharacterGear gear(Object value){var gear=object(value);return new CharacterGear(GearTier.valueOf(text(gear.get("chestplate"))),GearTier.valueOf(text(gear.get("leggings"))),GearTier.valueOf(text(gear.get("boots"))));}
|
||||
|
||||
@@ -15,16 +15,16 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Atomic single-file durable authority for daily adventure quests. */
|
||||
public final class YamlAdventureQuestRepository {
|
||||
private final Path file;
|
||||
private final Path file;private volatile boolean legacyDungeonRewardTerms;
|
||||
public YamlAdventureQuestRepository(Path data){file=data.toAbsolutePath().resolve("adventure-quests.yml");}
|
||||
public AdventureQuestState load()throws IOException{
|
||||
if(Files.notExists(file))return AdventureQuestState.empty();
|
||||
try{
|
||||
Object decoded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));var root=map(decoded);if(number(root.get("schema-version"))!=1)throw new IllegalArgumentException();
|
||||
var sites=new ArrayList<AdventureQuestState.Site>();for(Object item:list(root.get("sites"))){var r=map(item);AdventureQuestState.Arena arena=null;if(r.containsKey("arena-shape")){var shape=AdventureQuestState.ArenaShape.valueOf(text(r.get("arena-shape")));int x=number(r.get("arena-x")),z=number(r.get("arena-z"));arena=new AdventureQuestState.Arena(shape,x,z);}sites.add(new AdventureQuestState.Site(uuid(r,"id"),AdventureQuestState.SiteType.valueOf(text(r.get("type"))),text(r.get("name")),uuid(r,"world"),decimal(r,"x"),decimal(r,"y"),decimal(r,"z"),arena));}
|
||||
Object decoded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));var root=map(decoded);int schema=number(root.get("schema-version"));if(schema!=1&&schema!=2)throw new IllegalArgumentException();legacyDungeonRewardTerms=schema==1;
|
||||
var sites=new ArrayList<AdventureQuestState.Site>();for(Object item:list(root.get("sites"))){var r=map(item);AdventureQuestState.Arena arena=null;if(r.containsKey("arena-shape")){var shape=AdventureQuestState.ArenaShape.valueOf(text(r.get("arena-shape")));int x=number(r.get("arena-x")),z=number(r.get("arena-z"));arena=new AdventureQuestState.Arena(shape,x,z);}AdventureQuestState.FishingDefinition fishing=null;if(r.get("fishing")!=null){var f=map(r.get("fishing"));fishing=new AdventureQuestState.FishingDefinition(uuid(f,"item-id"),text(f.get("item-name")),new BigInteger(text(f.get("required"))),new java.math.BigDecimal(text(f.get("chance-percent"))),new BigInteger(text(f.get("reward-xp"))),new BigInteger(text(f.get("reward-coins"))),number(f.get("radius")));}sites.add(new AdventureQuestState.Site(uuid(r,"id"),AdventureQuestState.SiteType.valueOf(text(r.get("type"))),text(r.get("name")),uuid(r,"world"),decimal(r,"x"),decimal(r,"y"),decimal(r,"z"),arena,r.containsKey("participation-mode")?AdventureQuestState.ParticipationMode.parse(text(r.get("participation-mode"))):AdventureQuestState.ParticipationMode.PARTY_AVAILABLE,fishing));}
|
||||
var givers=new ArrayList<AdventureQuestState.Giver>();for(Object item:list(root.get("givers"))){var r=map(item);givers.add(new AdventureQuestState.Giver(uuid(r,"id"),text(r.get("name")),uuid(r,"world"),decimal(r,"x"),decimal(r,"y"),decimal(r,"z"),(float)decimal(r,"yaw"),(float)decimal(r,"pitch")));}
|
||||
AdventureQuestState.Catalog catalog=null;if(root.get("catalog")!=null){var r=map(root.get("catalog"));catalog=new AdventureQuestState.Catalog(LocalDate.parse(text(r.get("day"))),uuid(r,"revision"),Instant.parse(text(r.get("created-at"))),list(r.get("sites")).stream().map(value->UUID.fromString(text(value))).toList(),r.containsKey("closed")?list(r.get("closed")).stream().map(value->UUID.fromString(text(value))).collect(java.util.stream.Collectors.toSet()):Set.of());}
|
||||
var quests=new ArrayList<AdventureQuestState.Quest>();for(Object item:list(root.get("quests"))){var r=map(item);quests.add(new AdventureQuestState.Quest(uuid(r,"id"),uuid(r,"catalog"),uuid(r,"site"),AdventureQuestState.UnitType.valueOf(text(r.get("unit-type"))),uuid(r,"unit"),list(r.get("snapshot")).stream().map(value->UUID.fromString(text(value))).collect(java.util.stream.Collectors.toSet()),Instant.parse(text(r.get("expires-at"))),new BigInteger(text(r.get("reward-xp"))),new BigInteger(text(r.get("reward-coins")))));}
|
||||
var quests=new ArrayList<AdventureQuestState.Quest>();for(Object item:list(root.get("quests"))){var r=map(item);quests.add(new AdventureQuestState.Quest(uuid(r,"id"),uuid(r,"catalog"),uuid(r,"site"),AdventureQuestState.UnitType.valueOf(text(r.get("unit-type"))),uuid(r,"unit"),list(r.get("snapshot")).stream().map(value->UUID.fromString(text(value))).collect(java.util.stream.Collectors.toSet()),Instant.parse(text(r.get("expires-at"))),new BigInteger(text(r.get("reward-xp"))),new BigInteger(text(r.get("reward-coins"))),r.containsKey("participation-mode")?AdventureQuestState.ParticipationMode.parse(text(r.get("participation-mode"))):AdventureQuestState.ParticipationMode.PARTY_AVAILABLE,r.get("fishing")==null?null:fishingTerms(map(r.get("fishing")))));}
|
||||
var unitClaims=new HashSet<AdventureQuestState.UnitClaim>();for(Object item:list(root.get("unit-claims"))){var r=map(item);unitClaims.add(new AdventureQuestState.UnitClaim(uuid(r,"catalog"),uuid(r,"site"),AdventureQuestState.UnitType.valueOf(text(r.get("unit-type"))),uuid(r,"unit")));}
|
||||
var characterClaims=new HashSet<AdventureQuestState.CharacterClaim>();for(Object item:list(root.get("character-claims"))){var r=map(item);characterClaims.add(new AdventureQuestState.CharacterClaim(uuid(r,"catalog"),uuid(r,"site"),uuid(r,"character")));}
|
||||
var rosters=new LinkedHashMap<UUID,List<EntityType>>();for(Object item:list(root.get("rosters"))){var r=map(item);rosters.put(uuid(r,"site"),list(r.get("entities")).stream().map(value->EntityType.valueOf(text(value))).toList());}
|
||||
@@ -44,23 +44,26 @@ public final class YamlAdventureQuestRepository {
|
||||
return new AdventureQuestState(sites,givers,catalog,quests,unitClaims,characterClaims,rosters,tracking,rewards,reset);
|
||||
}catch(RuntimeException invalid){throw new IOException("Invalid adventure quest file",invalid);}
|
||||
}
|
||||
public boolean requiresDungeonRewardMigration(){return legacyDungeonRewardTerms;}
|
||||
public void save(AdventureQuestState state)throws IOException{
|
||||
Objects.requireNonNull(state);var root=new LinkedHashMap<String,Object>();root.put("schema-version",1);
|
||||
Objects.requireNonNull(state);var root=new LinkedHashMap<String,Object>();root.put("schema-version",2);
|
||||
root.put("sites",state.sites().stream().sorted(Comparator.comparing(AdventureQuestState.Site::id)).map(YamlAdventureQuestRepository::siteRow).toList());
|
||||
root.put("givers",state.givers().stream().sorted(Comparator.comparing(AdventureQuestState.Giver::id)).map(v->row("id",v.id(),"name",v.name(),"world",v.world(),"x",v.x(),"y",v.y(),"z",v.z(),"yaw",v.yaw(),"pitch",v.pitch())).toList());
|
||||
if(state.catalog()!=null)root.put("catalog",row("day",state.catalog().day(),"revision",state.catalog().revision(),"created-at",state.catalog().createdAt(),"sites",state.catalog().sites(),"closed",state.catalog().closed().stream().sorted().toList()));else root.put("catalog",null);
|
||||
root.put("quests",state.quests().stream().sorted(Comparator.comparing(AdventureQuestState.Quest::id)).map(v->row("id",v.id(),"catalog",v.catalog(),"site",v.site(),"unit-type",v.unitType(),"unit",v.unit(),"snapshot",v.snapshot().stream().sorted().toList(),"expires-at",v.expiresAt(),"reward-xp",v.rewardXp(),"reward-coins",v.rewardCoins())).toList());
|
||||
root.put("quests",state.quests().stream().sorted(Comparator.comparing(AdventureQuestState.Quest::id)).map(v->row("id",v.id(),"catalog",v.catalog(),"site",v.site(),"unit-type",v.unitType(),"unit",v.unit(),"snapshot",v.snapshot().stream().sorted().toList(),"expires-at",v.expiresAt(),"reward-xp",v.rewardXp(),"reward-coins",v.rewardCoins(),"participation-mode",v.participationMode().value(),"fishing",v.fishing()==null?null:fishing(v.fishing()))).toList());
|
||||
root.put("unit-claims",state.unitClaims().stream().sorted(Comparator.comparing(v->v.catalog().toString()+v.site()+v.unit())).map(v->row("catalog",v.catalog(),"site",v.site(),"unit-type",v.type(),"unit",v.unit())).toList());
|
||||
root.put("character-claims",state.characterClaims().stream().sorted(Comparator.comparing(v->v.catalog().toString()+v.site()+v.character())).map(v->row("catalog",v.catalog(),"site",v.site(),"character",v.character())).toList());
|
||||
root.put("rosters",state.rosters().entrySet().stream().sorted(Map.Entry.comparingByKey()).map(e->row("site",e.getKey(),"entities",e.getValue())).toList());
|
||||
root.put("tracking",state.tracking().stream().sorted(Comparator.comparing(v->v.quest().toString()+v.character())).map(v->row("quest",v.quest(),"character",v.character(),"enabled",v.enabled())).toList());
|
||||
root.put("rewards",row("allocations",state.rewards().allocations().stream().sorted(Comparator.comparing(AdventureQuestState.RewardAllocation::id)).map(v->row("id",v.id(),"catalog",v.catalog(),"site",v.site(),"quest",v.quest(),"source",v.source(),"character",v.character(),"xp",v.xp(),"coins",v.coins())).toList(),"operations",state.rewards().operations().stream().sorted(Comparator.comparing(AdventureQuestState.RewardClaim::id)).map(v->row("id",v.id(),"catalog",v.catalog(),"site",v.site(),"character",v.character(),"allocations",v.allocations().stream().sorted().toList(),"xp",v.xp(),"coins",v.coins())).toList(),"receipts",state.rewards().receipts().stream().sorted(Comparator.comparing(v->v.catalog().toString()+v.site()+v.character())).map(v->row("catalog",v.catalog(),"site",v.site(),"character",v.character())).toList()));
|
||||
root.put("reset",state.reset()==null?null:row("operation",state.reset().operation(),"generation",state.reset().generation(),"started-at",state.reset().startedAt()));
|
||||
Files.createDirectories(file.getParent());Path temporary=Files.createTempFile(file.getParent(),"adventure-quests-",".tmp");try{byte[] bytes=yaml().dump(root).getBytes(StandardCharsets.UTF_8);try(var output=java.nio.channels.FileChannel.open(temporary,StandardOpenOption.WRITE)){var buffer=java.nio.ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}try{Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);}catch(AtomicMoveNotSupportedException unsupported){throw new IOException("Atomic adventure persistence unavailable",unsupported);}DurableFiles.forceDirectory(file.getParent());}finally{Files.deleteIfExists(temporary);}
|
||||
Files.createDirectories(file.getParent());Path temporary=Files.createTempFile(file.getParent(),"adventure-quests-",".tmp");try{byte[] bytes=yaml().dump(root).getBytes(StandardCharsets.UTF_8);try(var output=java.nio.channels.FileChannel.open(temporary,StandardOpenOption.WRITE)){var buffer=java.nio.ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}try{Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);}catch(AtomicMoveNotSupportedException unsupported){throw new IOException("Atomic adventure persistence unavailable",unsupported);}DurableFiles.forceDirectory(file.getParent());}finally{Files.deleteIfExists(temporary);}legacyDungeonRewardTerms=false;
|
||||
}
|
||||
private static Map<String,Object> siteRow(AdventureQuestState.Site site){var result=row("id",site.id(),"type",site.type(),"name",site.name(),"world",site.world(),"x",site.x(),"y",site.y(),"z",site.z());if(site.arena()!=null){result.put("arena-shape",encode(site.arena().shape()));result.put("arena-x",site.arena().xBlocks());result.put("arena-z",site.arena().zBlocks());}return result;}
|
||||
private static Map<String,Object> siteRow(AdventureQuestState.Site site){var result=row("id",site.id(),"type",site.type(),"name",site.name(),"world",site.world(),"x",site.x(),"y",site.y(),"z",site.z(),"participation-mode",site.participationMode().value(),"fishing",site.fishing()==null?null:fishing(site.fishing().terms()));if(site.arena()!=null){result.put("arena-shape",encode(site.arena().shape()));result.put("arena-x",site.arena().xBlocks());result.put("arena-z",site.arena().zBlocks());}return result;}
|
||||
private static Map<String,Object> fishing(AdventureQuestState.FishingTerms value){return row("item-id",value.itemId(),"item-name",value.itemName(),"required",value.required(),"chance-percent",value.chancePercent(),"reward-xp",value.rewardXp(),"reward-coins",value.rewardCoins(),"radius",value.radius());}
|
||||
private static AdventureQuestState.FishingTerms fishingTerms(Map<?,?> value){return new AdventureQuestState.FishingTerms(uuid(value,"item-id"),text(value.get("item-name")),new BigInteger(text(value.get("required"))),new java.math.BigDecimal(text(value.get("chance-percent"))),new BigInteger(text(value.get("reward-xp"))),new BigInteger(text(value.get("reward-coins"))),number(value.get("radius")));}
|
||||
private static Map<String,Object> row(Object...pairs){var row=new LinkedHashMap<String,Object>();for(int i=0;i<pairs.length;i+=2)row.put((String)pairs[i],encode(pairs[i+1]));return row;}
|
||||
private static Object encode(Object value){if(value instanceof UUID||value instanceof Enum<?>||value instanceof Instant||value instanceof LocalDate||value instanceof BigInteger||value instanceof Double||value instanceof Float)return value.toString();if(value instanceof Collection<?> values)return values.stream().map(YamlAdventureQuestRepository::encode).toList();return value;}
|
||||
private static Object encode(Object value){if(value instanceof UUID||value instanceof Enum<?>||value instanceof Instant||value instanceof LocalDate||value instanceof BigInteger||value instanceof java.math.BigDecimal||value instanceof Double||value instanceof Float)return value.toString();if(value instanceof Collection<?> values)return values.stream().map(YamlAdventureQuestRepository::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<?,?> 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();}
|
||||
|
||||
@@ -12,7 +12,9 @@ import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
@@ -23,6 +25,7 @@ public final class YamlCharacterRepository {
|
||||
private final Path directory;
|
||||
private final Replacement replacement;
|
||||
private volatile java.util.function.Predicate<UUID> unavailable = ignored -> false;
|
||||
private volatile java.util.function.Predicate<UUID> mutationUnavailable = ignored -> false;
|
||||
|
||||
@FunctionalInterface
|
||||
interface Replacement { void move(Path source, Path target) throws IOException; }
|
||||
@@ -30,6 +33,10 @@ public final class YamlCharacterRepository {
|
||||
private interface Operation { void run() throws IOException; }
|
||||
|
||||
public void attachUnavailable(java.util.function.Predicate<UUID> predicate){unavailable=Objects.requireNonNull(predicate);}
|
||||
void attachMutationUnavailable(java.util.function.Predicate<UUID> predicate) {
|
||||
mutationUnavailable = Objects.requireNonNull(predicate);
|
||||
}
|
||||
Path pluginDataDirectory() { return directory.getParent(); }
|
||||
|
||||
public YamlCharacterRepository(Path pluginDataDirectory) {
|
||||
this(pluginDataDirectory, (source, target) -> Files.move(source, target,
|
||||
@@ -71,12 +78,26 @@ public final class YamlCharacterRepository {
|
||||
}
|
||||
|
||||
public void replace(CharacterRecord character, BigInteger expectedRevision) throws IOException {
|
||||
replace(character, expectedRevision, true);
|
||||
}
|
||||
|
||||
/** Recovery owns its separately persisted reservation but still honors dungeon custody. */
|
||||
void replaceForRecovery(CharacterRecord character, BigInteger expectedRevision) throws IOException {
|
||||
replace(character, expectedRevision, false);
|
||||
}
|
||||
|
||||
private void replace(CharacterRecord character, BigInteger expectedRevision,
|
||||
boolean enforceMutationAvailability) throws IOException {
|
||||
if (unavailable.test(character.id())) throw new IOException("Character is in dungeon boss custody");
|
||||
if (enforceMutationAvailability && mutationUnavailable.test(character.id()))
|
||||
throw new IOException("Character has unresolved recovery custody");
|
||||
if (!character.revision().equals(expectedRevision.add(BigInteger.ONE))) {
|
||||
throw new IOException("Character update must advance exactly one revision");
|
||||
}
|
||||
withLock(() -> {
|
||||
if (unavailable.test(character.id())) throw new IOException("Character is in dungeon boss custody");
|
||||
if (enforceMutationAvailability && mutationUnavailable.test(character.id()))
|
||||
throw new IOException("Character has unresolved recovery custody");
|
||||
CharacterRecord current = load(character.id()).orElseThrow(() -> new IOException("Character not found"));
|
||||
if (!current.revision().equals(expectedRevision)) throw new IOException("Stale character revision");
|
||||
write(character);
|
||||
@@ -108,6 +129,41 @@ public final class YamlCharacterRepository {
|
||||
return removed[0];
|
||||
}
|
||||
|
||||
public record Catalog(List<CharacterRecord> records, int unreadable) {
|
||||
public Catalog { records = List.copyOf(records); }
|
||||
}
|
||||
|
||||
/** Enumerate only canonical record files without following directory-entry symlinks. */
|
||||
public Catalog catalog() throws IOException {
|
||||
if (Files.notExists(directory, LinkOption.NOFOLLOW_LINKS)) return new Catalog(List.of(), 0);
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS))
|
||||
throw new IOException("Character directory is unavailable");
|
||||
var records = new ArrayList<CharacterRecord>();
|
||||
int unreadable = 0;
|
||||
try (var entries = Files.newDirectoryStream(directory, "*.yml")) {
|
||||
for (var entry : entries) {
|
||||
String filename = entry.getFileName().toString();
|
||||
if (!filename.endsWith(".yml")) continue;
|
||||
UUID id;
|
||||
try {
|
||||
String stem = filename.substring(0, filename.length() - 4);
|
||||
id = UUID.fromString(stem);
|
||||
if (!id.toString().equals(stem)) continue;
|
||||
} catch (IllegalArgumentException invalid) { continue; }
|
||||
if (!Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS)) {
|
||||
unreadable++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
var record = load(id);
|
||||
if (record.isPresent()) records.add(record.orElseThrow());
|
||||
else unreadable++;
|
||||
} catch (IOException failure) { unreadable++; }
|
||||
}
|
||||
}
|
||||
return new Catalog(records, unreadable);
|
||||
}
|
||||
|
||||
public Optional<CharacterRecord> load(UUID id) throws IOException {
|
||||
Path file = file(id);
|
||||
if (Files.notExists(file, LinkOption.NOFOLLOW_LINKS)) return Optional.empty();
|
||||
|
||||
@@ -17,13 +17,13 @@ public final class YamlDungeonRepository {
|
||||
public YamlDungeonRepository(Path data){file=data.toAbsolutePath().resolve("dungeons.yml");}
|
||||
public DungeonState load()throws IOException{
|
||||
if(Files.notExists(file))return DungeonState.empty();
|
||||
try{var root=map(yaml().load(Files.readString(file,StandardCharsets.UTF_8)));if(integer(root.get("schema-version"))!=1)throw new IllegalArgumentException();var dungeons=new ArrayList<DungeonState.Dungeon>();for(var item:list(root.get("dungeons"))){var row=map(item);var rooms=new ArrayList<DungeonState.Room>();for(var roomItem:list(row.get("rooms"))){var room=map(roomItem);var mobs=new ArrayList<DungeonState.Spawn>();for(var mobItem:list(room.get("mobs"))){var mob=map(mobItem);mobs.add(new DungeonState.Spawn(uuid(mob,"id"),EntityType.valueOf(text(mob.get("type"))),text(mob.get("name")),position(map(mob.get("position")))));}var effects=new ArrayList<DungeonState.Effect>();for(var effectItem:list(room.get("effects"))){var effect=map(effectItem);effects.add(new DungeonState.Effect(uuid(effect,"id"),DungeonState.EffectTarget.valueOf(text(effect.get("target"))),text(effect.get("effect")),integer(effect.get("level")),bool(effect.get("particles"))));}DungeonState.Boss boss=null;if(room.get("boss")!=null){var value=map(room.get("boss"));DungeonState.CharacterBoss character=null;if(value.get("character")!=null){var custody=map(value.get("character"));DungeonState.Recovery recovery=null;if(custody.get("recovery")!=null){var pending=map(custody.get("recovery"));recovery=new DungeonState.Recovery(uuid(pending,"operation"),uuid(pending,"administrator"));}character=new DungeonState.CharacterBoss(uuid(custody,"operation"),CharacterYaml.decode(text(custody.get("record"))),new java.math.BigDecimal(text(custody.get("multiplier"))),custody.containsKey("prior-type")?EntityType.valueOf(text(custody.get("prior-type"))):EntityType.HUSK,custody.containsKey("prior-name")?text(custody.get("prior-name")):"",recovery);}boss=new DungeonState.Boss(EntityType.valueOf(text(value.get("type"))),text(value.get("name")),position(map(value.get("position"))),character);}rooms.add(new DungeonState.Room(uuid(room,"id"),text(room.get("name")),bounds(map(room.get("bounds"))),mobs,effects,new DungeonState.Reward(new BigInteger(text(room.get("reward-xp"))),new BigInteger(text(room.get("reward-coins")))),boss));}dungeons.add(new DungeonState.Dungeon(uuid(row,"id"),text(row.get("name")),uuid(row,"world"),position(map(row.get("entrance"))),bool(row.get("enabled")),uuid(row,"revision"),rooms));}
|
||||
try{var root=map(yaml().load(Files.readString(file,StandardCharsets.UTF_8)));int schema=integer(root.get("schema-version"));if(schema!=1&&schema!=2)throw new IllegalArgumentException();var dungeons=new ArrayList<DungeonState.Dungeon>();for(var item:list(root.get("dungeons"))){var row=map(item);var rooms=new ArrayList<DungeonState.Room>();for(var roomItem:list(row.get("rooms"))){var room=map(roomItem);var mobs=new ArrayList<DungeonState.Spawn>();for(var mobItem:list(room.get("mobs"))){var mob=map(mobItem);mobs.add(new DungeonState.Spawn(uuid(mob,"id"),EntityType.valueOf(text(mob.get("type"))),text(mob.get("name")),position(map(mob.get("position")))));}var effects=new ArrayList<DungeonState.Effect>();for(var effectItem:list(room.get("effects"))){var effect=map(effectItem);effects.add(new DungeonState.Effect(uuid(effect,"id"),DungeonState.EffectTarget.valueOf(text(effect.get("target"))),text(effect.get("effect")),integer(effect.get("level")),bool(effect.get("particles"))));}DungeonState.Boss boss=null;if(room.get("boss")!=null){var value=map(room.get("boss"));DungeonState.CharacterBoss character=null;if(value.get("character")!=null){var custody=map(value.get("character"));DungeonState.Recovery recovery=null;if(custody.get("recovery")!=null){var pending=map(custody.get("recovery"));recovery=new DungeonState.Recovery(uuid(pending,"operation"),uuid(pending,"administrator"));}character=new DungeonState.CharacterBoss(uuid(custody,"operation"),CharacterYaml.decode(text(custody.get("record"))),new java.math.BigDecimal(text(custody.get("multiplier"))),custody.containsKey("prior-type")?EntityType.valueOf(text(custody.get("prior-type"))):EntityType.HUSK,custody.containsKey("prior-name")?text(custody.get("prior-name")):"",recovery);}boss=new DungeonState.Boss(EntityType.valueOf(text(value.get("type"))),text(value.get("name")),position(map(value.get("position"))),character);}rooms.add(new DungeonState.Room(uuid(room,"id"),text(room.get("name")),bounds(map(room.get("bounds"))),mobs,effects,new DungeonState.Reward(new BigInteger(text(room.get("reward-xp"))),new BigInteger(text(room.get("reward-coins")))),boss));}var mode=row.containsKey("participation-mode")?games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.parse(text(row.get("participation-mode"))):games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;var completion=schema==2?new DungeonState.Reward(new BigInteger(text(row.get("completion-reward-xp"))),new BigInteger(text(row.get("completion-reward-coins")))):rooms.stream().filter(room->room.boss()!=null).map(DungeonState.Room::reward).findFirst().orElse(DungeonState.Reward.empty());if(schema==1)for(int index=0;index<rooms.size();index++){var room=rooms.get(index);rooms.set(index,new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects(),DungeonState.Reward.empty(),room.boss()));}dungeons.add(new DungeonState.Dungeon(uuid(row,"id"),text(row.get("name")),uuid(row,"world"),position(map(row.get("entrance"))),bool(row.get("enabled")),uuid(row,"revision"),rooms,mode,completion,schema==1||bool(row.get("completion-reward-configured"))));}
|
||||
var runs=new ArrayList<DungeonState.Run>();for(var item:list(root.get("runs"))){var row=map(item);var unitClaims=new HashSet<DungeonState.UnitClaim>();for(var value:list(row.get("unit-claims"))){var claim=map(value);unitClaims.add(new DungeonState.UnitClaim(uuid(claim,"room"),uuid(claim,"quest")));}var characterClaims=new HashSet<DungeonState.CharacterClaim>();for(var value:list(row.get("character-claims"))){var claim=map(value);characterClaims.add(new DungeonState.CharacterClaim(uuid(claim,"room"),uuid(claim,"character")));}runs.add(new DungeonState.Run(uuid(row,"dungeon"),uuid(row,"generation"),uuids(row.get("defeated-slots")),uuids(row.get("cleared-rooms")),unitClaims,characterClaims,bool(row.get("defeated"))));}
|
||||
return new DungeonState(dungeons,runs);
|
||||
}catch(RuntimeException invalid){throw new IOException("Invalid dungeon file",invalid);}
|
||||
}
|
||||
public void save(DungeonState state)throws IOException{Objects.requireNonNull(state);var root=new LinkedHashMap<String,Object>();root.put("schema-version",1);root.put("dungeons",state.dungeons().stream().sorted(Comparator.comparing(DungeonState.Dungeon::id)).map(YamlDungeonRepository::dungeon).toList());root.put("runs",state.runs().stream().sorted(Comparator.comparing(v->v.dungeon().toString()+v.generation())).map(YamlDungeonRepository::run).toList());Files.createDirectories(file.getParent());Path temporary=Files.createTempFile(file.getParent(),"dungeons-",".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 dungeon persistence unavailable",unsupported);}DurableFiles.forceDirectory(file.getParent());}finally{Files.deleteIfExists(temporary);}}
|
||||
private static Map<String,Object> dungeon(DungeonState.Dungeon value){return row("id",value.id(),"name",value.name(),"world",value.world(),"entrance",position(value.entrance()),"enabled",value.enabled(),"revision",value.revision(),"rooms",value.rooms().stream().sorted(Comparator.comparing(DungeonState.Room::id)).map(YamlDungeonRepository::room).toList());}
|
||||
public void save(DungeonState state)throws IOException{Objects.requireNonNull(state);var root=new LinkedHashMap<String,Object>();root.put("schema-version",2);root.put("dungeons",state.dungeons().stream().sorted(Comparator.comparing(DungeonState.Dungeon::id)).map(YamlDungeonRepository::dungeon).toList());root.put("runs",state.runs().stream().sorted(Comparator.comparing(v->v.dungeon().toString()+v.generation())).map(YamlDungeonRepository::run).toList());Files.createDirectories(file.getParent());Path temporary=Files.createTempFile(file.getParent(),"dungeons-",".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 dungeon persistence unavailable",unsupported);}DurableFiles.forceDirectory(file.getParent());}finally{Files.deleteIfExists(temporary);}}
|
||||
private static Map<String,Object> dungeon(DungeonState.Dungeon value){return row("id",value.id(),"name",value.name(),"world",value.world(),"entrance",position(value.entrance()),"enabled",value.enabled(),"revision",value.revision(),"participation-mode",value.participationMode().value(),"completion-reward-xp",value.completionReward().xp(),"completion-reward-coins",value.completionReward().coins(),"completion-reward-configured",value.rewardConfigured(),"rooms",value.rooms().stream().sorted(Comparator.comparing(DungeonState.Room::id)).map(YamlDungeonRepository::room).toList());}
|
||||
private static Map<String,Object> room(DungeonState.Room value){return row("id",value.id(),"name",value.name(),"bounds",bounds(value.bounds()),"mobs",value.mobs().stream().sorted(Comparator.comparing(DungeonState.Spawn::id)).map(v->row("id",v.id(),"type",v.type(),"name",v.name(),"position",position(v.position()))).toList(),"effects",value.effects().stream().sorted(Comparator.comparing(DungeonState.Effect::id)).map(v->row("id",v.id(),"target",v.target(),"effect",v.effect(),"level",v.level(),"particles",v.particles())).toList(),"reward-xp",value.reward().xp(),"reward-coins",value.reward().coins(),"boss",value.boss()==null?null:row("type",value.boss().type(),"name",value.boss().name(),"position",position(value.boss().position()),"character",value.boss().character()==null?null:character(value.boss().character())));}
|
||||
private static Map<String,Object> character(DungeonState.CharacterBoss value){return row("operation",value.operation(),"record",CharacterYaml.encode(value.character()),"multiplier",value.multiplier().toPlainString(),"prior-type",value.priorType(),"prior-name",value.priorName(),"recovery",value.recovery()==null?null:row("operation",value.recovery().operation(),"administrator",value.recovery().administrator()));}
|
||||
private static Map<String,Object> run(DungeonState.Run value){return row("dungeon",value.dungeon(),"generation",value.generation(),"defeated-slots",value.defeatedSlots().stream().sorted().toList(),"cleared-rooms",value.clearedRooms().stream().sorted().toList(),"unit-claims",value.unitClaims().stream().sorted(Comparator.comparing(v->v.room().toString()+v.quest())).map(v->row("room",v.room(),"quest",v.quest())).toList(),"character-claims",value.characterClaims().stream().sorted(Comparator.comparing(v->v.room().toString()+v.character())).map(v->row("room",v.room(),"character",v.character())).toList(),"defeated",value.defeated());}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.adventure.FishingState;
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Atomic safe-YAML authority for virtual fishing stock and non-expiring earned shares. */
|
||||
public final class YamlFishingRepository {
|
||||
private final Path file;
|
||||
public YamlFishingRepository(Path data){file=data.toAbsolutePath().resolve("fishing.yml");}
|
||||
public FishingState load() throws IOException {
|
||||
if(Files.notExists(file))return FishingState.empty();
|
||||
try{
|
||||
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 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 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 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 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);}
|
||||
}
|
||||
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("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("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("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("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<?,?> 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();}
|
||||
private static boolean bool(Object value){if(value instanceof Boolean result)return result;throw new IllegalArgumentException();}
|
||||
private static BigInteger number(Object value){return new BigInteger(value.toString());}
|
||||
private static UUID uuid(Map<?,?> row,String key){return UUID.fromString(text(row.get(key)));}
|
||||
private static Set<UUID> uuids(Object value){var result=new HashSet<UUID>();for(Object item:list(value))result.add(UUID.fromString(text(item)));return result;}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package games.dmg.fantasystick.progression;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Captured plugin-owned encounter authority for distance-spacing exemption. */
|
||||
public record ManagedEncounterOrigin(Kind kind, UUID source) {
|
||||
public enum Kind { DUNGEON, CHAMPION, CAMP }
|
||||
public ManagedEncounterOrigin { Objects.requireNonNull(kind); Objects.requireNonNull(source); }
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.EquipmentSessionStore;
|
||||
import games.dmg.fantasystick.persistence.RevivalStore;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
/** Explicit one-use preview and fail-closed delivery for saved-character sheet recovery. */
|
||||
public final class AdminCharacterRecovery implements Listener, AutoCloseable {
|
||||
public record Failure(UUID character, UUID recipient, String operation, Throwable cause) { }
|
||||
private record View(UUID nonce, Player administrator, Player recipient, CharacterRecord record,
|
||||
int destination, Inventory inventory) { }
|
||||
private record Prerequisites(java.util.Optional<CharacterRecord> record,
|
||||
java.util.Optional<EquipmentSessionStore.State> receipt,
|
||||
java.util.Optional<RevivalStore.Reservation> revival) { }
|
||||
|
||||
private final CharacterStorage characters;
|
||||
private final EquipmentSessionStore receipts;
|
||||
private final RevivalStore revivals;
|
||||
private final EquipmentSessions sessions;
|
||||
private final Server server;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Failure> failures;
|
||||
private final Map<UUID, Object> pending = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, View> views = new ConcurrentHashMap<>();
|
||||
private final CharacterSheets sheets = new CharacterSheets();
|
||||
private volatile boolean closed;
|
||||
|
||||
public AdminCharacterRecovery(CharacterStorage characters, EquipmentSessionStore receipts,
|
||||
RevivalStore revivals, EquipmentSessions sessions, Server server, Consumer<Runnable> nextTick,
|
||||
Consumer<Failure> failures) {
|
||||
this.characters = Objects.requireNonNull(characters);
|
||||
this.receipts = Objects.requireNonNull(receipts);
|
||||
this.revivals = Objects.requireNonNull(revivals);
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
this.server = Objects.requireNonNull(server);
|
||||
this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
public void open(Player administrator, UUID character, Player recipient) {
|
||||
Objects.requireNonNull(administrator); Objects.requireNonNull(character); Objects.requireNonNull(recipient);
|
||||
if (closed || !current(administrator) || !recipient.isOnline()) {
|
||||
administrator.sendMessage("Character recovery refused: administrator or recipient is unavailable.");
|
||||
return;
|
||||
}
|
||||
UUID administratorId = administrator.getUniqueId();
|
||||
UUID recipientId = recipient.getUniqueId();
|
||||
Object ticket = new Object();
|
||||
views.remove(administratorId);
|
||||
pending.put(administratorId, ticket);
|
||||
characters.inspect(character).whenComplete((record, recordFailure) -> {
|
||||
if (recordFailure != null) {
|
||||
schedule(character, recipientId, () -> finishPreview(administratorId, ticket,
|
||||
administrator, recipient, character, null, recordFailure));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var receiptStage = receipts.find(recipientId);
|
||||
receiptStage.whenComplete((receipt, receiptFailure) -> {
|
||||
if (receiptFailure != null) {
|
||||
schedule(character, recipientId, () -> finishPreview(administratorId, ticket,
|
||||
administrator, recipient, character, null, receiptFailure));
|
||||
return;
|
||||
}
|
||||
revivals.find(character).whenComplete((revival, revivalFailure) ->
|
||||
schedule(character, recipientId, () -> finishPreview(administratorId, ticket,
|
||||
administrator, recipient, character,
|
||||
revivalFailure == null ? new Prerequisites(record, receipt, revival) : null,
|
||||
revivalFailure)));
|
||||
});
|
||||
} catch (RuntimeException rejected) {
|
||||
schedule(character, recipientId, () -> finishPreview(administratorId, ticket,
|
||||
administrator, recipient, character, null, rejected));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void finishPreview(UUID administratorId, Object ticket, Player administrator, Player recipient,
|
||||
UUID character, Prerequisites loaded, Throwable failure) {
|
||||
if (closed || !pending.remove(administratorId, ticket)) return;
|
||||
Player currentRecipient = server.getPlayer(recipient.getUniqueId());
|
||||
if (!current(administrator) || currentRecipient != recipient || !recipient.isOnline()) {
|
||||
administrator.sendMessage("Character recovery refused: administrator or recipient changed.");
|
||||
return;
|
||||
}
|
||||
if (failure != null) {
|
||||
report(character, recipient.getUniqueId(), "preview", failure);
|
||||
administrator.sendMessage("Character recovery preview could not be loaded; no state changed.");
|
||||
return;
|
||||
}
|
||||
var record = loaded.record().orElse(null);
|
||||
if (record == null) {
|
||||
administrator.sendMessage("Character recovery refused: saved character not found.");
|
||||
return;
|
||||
}
|
||||
if (loaded.receipt().map(EquipmentSessionStore.State::pending).orElse(null) != null
|
||||
|| loaded.revival().isPresent()
|
||||
|| !sessions.availableForCharacterRecovery(character, recipient.getUniqueId())) {
|
||||
administrator.sendMessage("Character recovery refused: active or unresolved custody must be resolved first.");
|
||||
return;
|
||||
}
|
||||
int destination = recipient.getInventory().firstEmpty();
|
||||
if (destination < 0 || destination >= 36) {
|
||||
administrator.sendMessage("Character recovery refused: recipient needs one empty main-inventory slot.");
|
||||
return;
|
||||
}
|
||||
show(administrator, recipient, record, destination);
|
||||
}
|
||||
|
||||
private void show(Player administrator, Player recipient, CharacterRecord record, int destination) {
|
||||
var inventory = server.createInventory(null, 9, Component.text("Recover saved character"));
|
||||
inventory.setItem(2, action(record.dead() ? Material.BARRIER : Material.PAPER,
|
||||
record.dead() ? "Living recovery unavailable" : "Recover living character sheet", record, recipient));
|
||||
inventory.setItem(6, action(record.dead() ? Material.TOTEM_OF_UNDYING : Material.BARRIER,
|
||||
record.dead() ? "Revive without Tail and recover sheet" : "No-Tail revival unavailable", record, recipient));
|
||||
var view = new View(UUID.randomUUID(), administrator, recipient, record, destination, inventory);
|
||||
views.put(administrator.getUniqueId(), view);
|
||||
administrator.openInventory(inventory);
|
||||
}
|
||||
|
||||
private static ItemStack action(Material material, String label, CharacterRecord record, Player recipient) {
|
||||
var item = new ItemStack(material);
|
||||
var meta = item.getItemMeta();
|
||||
meta.displayName(Component.text(label));
|
||||
var lines = new java.util.ArrayList<Component>();
|
||||
lines.add(Component.text(record.name()));
|
||||
lines.add(Component.text(record.id().toString()));
|
||||
lines.add(Component.text("Level " + record.level() + " — " + (record.dead() ? "dead" : "living")));
|
||||
if (record.deathId() != null) lines.add(Component.text("Death " + record.deathId()));
|
||||
if (record.diedAt() != null) lines.add(Component.text("Died " + record.diedAt()));
|
||||
lines.add(Component.text("Recipient " + recipient.getName() + " — " + recipient.getUniqueId()));
|
||||
lines.add(Component.text("Confirmation invalidates every older sheet, including copies held by anyone."));
|
||||
meta.lore(lines); item.setItemMeta(meta); return item;
|
||||
}
|
||||
|
||||
@EventHandler public void close(org.bukkit.event.inventory.InventoryCloseEvent event) {
|
||||
if (!(event.getPlayer() instanceof Player administrator)) return;
|
||||
var view = views.get(administrator.getUniqueId());
|
||||
if (view != null && event.getInventory() == view.inventory()) views.remove(administrator.getUniqueId(), view);
|
||||
}
|
||||
|
||||
@EventHandler public void click(InventoryClickEvent event) {
|
||||
if (closed || !(event.getWhoClicked() instanceof Player administrator)) return;
|
||||
var view = views.get(administrator.getUniqueId());
|
||||
if (view == null || event.getInventory() != view.inventory()) return;
|
||||
event.setCancelled(true);
|
||||
int slot = event.getRawSlot();
|
||||
boolean revive = slot == 6;
|
||||
if (!(slot == 2 || slot == 6) || revive != view.record().dead()) return;
|
||||
if (!views.remove(administrator.getUniqueId(), view)) return;
|
||||
administrator.closeInventory();
|
||||
confirm(view, revive);
|
||||
}
|
||||
|
||||
private void confirm(View view, boolean revive) {
|
||||
Player administrator = view.administrator(); Player recipient = view.recipient();
|
||||
UUID recipientId = recipient.getUniqueId();
|
||||
if (!current(administrator) || server.getPlayer(recipient.getUniqueId()) != recipient
|
||||
|| !recipient.isOnline() || !empty(recipient, view.destination())) {
|
||||
administrator.sendMessage("Character recovery confirmation became stale; no state changed.");
|
||||
return;
|
||||
}
|
||||
var custody = sessions.claimCharacterRecovery(view.record().id(), recipientId);
|
||||
if (custody.isEmpty()) {
|
||||
administrator.sendMessage("Character recovery confirmation became stale; no state changed.");
|
||||
return;
|
||||
}
|
||||
var claim = custody.orElseThrow();
|
||||
java.util.concurrent.CompletionStage<java.util.Optional<EquipmentSessionStore.State>> receiptRead;
|
||||
try { receiptRead = receipts.find(recipientId); }
|
||||
catch (RuntimeException rejected) {
|
||||
claim.close();
|
||||
report(view.record().id(), recipientId, "receipt", rejected);
|
||||
administrator.sendMessage("Character recovery confirmation became stale; no state changed.");
|
||||
return;
|
||||
}
|
||||
receiptRead.whenComplete((receipt, failure) -> schedule(
|
||||
view.record().id(), recipientId, () -> {
|
||||
if (failure != null || receipt.map(EquipmentSessionStore.State::pending).orElse(null) != null
|
||||
|| !current(administrator) || server.getPlayer(recipient.getUniqueId()) != recipient
|
||||
|| !recipient.isOnline() || !empty(recipient, view.destination())) {
|
||||
claim.close();
|
||||
if (failure != null) report(view.record().id(), recipient.getUniqueId(), "receipt", failure);
|
||||
administrator.sendMessage("Character recovery confirmation became stale; no state changed.");
|
||||
return;
|
||||
}
|
||||
revivals.find(view.record().id()).whenComplete((revival, revivalFailure) -> schedule(
|
||||
view.record().id(), recipientId, () -> {
|
||||
if (revivalFailure != null || revival.isPresent() || !current(administrator)
|
||||
|| server.getPlayer(recipientId) != recipient || !recipient.isOnline()
|
||||
|| !empty(recipient, view.destination())) {
|
||||
claim.close();
|
||||
if (revivalFailure != null) report(view.record().id(), recipientId, "revival", revivalFailure);
|
||||
administrator.sendMessage("Character recovery confirmation became stale; no state changed.");
|
||||
return;
|
||||
}
|
||||
UUID operation = UUID.randomUUID();
|
||||
var request = new CharacterStorage.RecoveryRequest(operation, view.record().id(),
|
||||
administrator.getUniqueId(), recipientId, view.record().revision(),
|
||||
view.record().sheetGeneration(), revive, revive ? view.record().deathId() : null,
|
||||
NativeHealthLimit.read());
|
||||
characters.beginRecovery(request).whenComplete((commit, commitFailure) -> schedule(
|
||||
view.record().id(), recipientId, () -> deliver(view, claim, commit, commitFailure)));
|
||||
}));
|
||||
}));
|
||||
}
|
||||
|
||||
private void deliver(View view, EquipmentSessions.CharacterRecoveryClaim claim,
|
||||
CharacterStorage.RecoveryCommit commit, Throwable failure) {
|
||||
if (failure != null) {
|
||||
report(view.record().id(), view.recipient().getUniqueId(), "commit", failure);
|
||||
view.administrator().sendMessage("Character recovery failed or is retained for inspection; no retry was performed.");
|
||||
return;
|
||||
}
|
||||
if (commit.status() != CharacterStorage.RecoveryStatus.APPLIED || commit.record().isEmpty()) {
|
||||
claim.close();
|
||||
view.administrator().sendMessage("Character recovery refused: saved state or custody changed ("
|
||||
+ commit.status().name().toLowerCase(java.util.Locale.ROOT) + ").");
|
||||
return;
|
||||
}
|
||||
var record = commit.record().orElseThrow();
|
||||
Player recipient = view.recipient();
|
||||
if (!current(view.administrator()) || server.getPlayer(recipient.getUniqueId()) != recipient || !recipient.isOnline()
|
||||
|| !empty(recipient, view.destination())) {
|
||||
view.administrator().sendMessage("Character recovery committed, but native delivery is uncertain; do not retry blindly.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
recipient.getInventory().setItem(view.destination(), sheets.create(record));
|
||||
if (!sheets.authorizes(recipient.getInventory().getItem(view.destination()), record))
|
||||
throw new IllegalStateException("Native inventory did not retain the recovered sheet");
|
||||
} catch (RuntimeException uncertain) {
|
||||
report(record.id(), recipient.getUniqueId(), "native-delivery", uncertain);
|
||||
view.administrator().sendMessage("Character recovery committed, but native delivery is uncertain; do not retry blindly.");
|
||||
return;
|
||||
}
|
||||
try { recipient.updateInventory(); }
|
||||
catch (RuntimeException syncFailure) { report(record.id(), recipient.getUniqueId(), "inventory-sync", syncFailure); }
|
||||
UUID recipientId = recipient.getUniqueId();
|
||||
characters.completeRecovery(record.id(), commit.operation()).whenComplete((completed, completionFailure) ->
|
||||
schedule(record.id(), recipientId, () -> {
|
||||
if (completionFailure != null || !completed) {
|
||||
if (completionFailure != null) report(record.id(), recipient.getUniqueId(), "completion", completionFailure);
|
||||
view.administrator().sendMessage("Recovery sheet was delivered, but durable completion is retained for inspection.");
|
||||
return;
|
||||
}
|
||||
claim.close();
|
||||
if (current(view.administrator())) view.administrator().sendMessage(
|
||||
(view.record().dead() ? "Revived without a Tail and recovered " : "Recovered ")
|
||||
+ record.name() + " for " + recipient.getName() + ". Older sheets are revoked.");
|
||||
if (recipient.isOnline()) recipient.sendMessage("Recovered character sheet: " + record.name() + ".");
|
||||
}));
|
||||
}
|
||||
|
||||
private static boolean empty(Player player, int slot) {
|
||||
if (slot < 0 || slot >= 36) return false;
|
||||
var item = player.getInventory().getItem(slot);
|
||||
return item == null || item.isEmpty();
|
||||
}
|
||||
|
||||
private static boolean authorized(Player player) {
|
||||
return player.isOnline() && player.hasPermission(EquipmentAdminCommand.PERMISSION);
|
||||
}
|
||||
|
||||
private boolean current(Player player) {
|
||||
return authorized(player) && server.getPlayer(player.getUniqueId()) == player;
|
||||
}
|
||||
|
||||
private void schedule(UUID character, UUID recipient, Runnable task) {
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> { if (!closed) task.run(); }); }
|
||||
catch (RuntimeException rejected) { report(character, recipient, "schedule", rejected); }
|
||||
}
|
||||
|
||||
private void report(UUID character, UUID recipient, String operation, Throwable cause) {
|
||||
try { failures.accept(new Failure(character, recipient, operation, cause)); }
|
||||
catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
closed = true; pending.clear();
|
||||
for (var view : views.values()) view.inventory().clear();
|
||||
views.clear();
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ public final class AdminRevival implements Listener, AutoCloseable {
|
||||
private void confirm(View view,Choice choice){if(!view.current()||!unchanged(view.target,choice.candidate)){view.close();return;}characters.find(choice.record.id()).whenComplete((found,failure)->schedule(()->{if(failure!=null){report(failure);view.admin.sendMessage("Admin revival could not recheck that Tail; no item changed.");view.close();return;}if(!view.current()||found.isEmpty()||!eligible(found.orElseThrow(),choice.candidate.identity)||!unchanged(view.target,choice.candidate)){view.admin.sendMessage("That Tail selection is stale; no item changed.");view.close();return;}reserve(view,choice.candidate,found.orElseThrow());}));}
|
||||
private void reserve(View view,Candidate candidate,CharacterRecord dead){var source=view.target.getInventory().getItem(candidate.slot);int destination=source.getAmount()==1?-1:emptySlot(view.target,candidate.slot);if(source.getAmount()>1&&destination<0){view.admin.sendMessage("Admin revival needs one empty direct inventory slot to preserve copied Tails.");view.close();return;}var reservation=new RevivalStore.Reservation(UUID.randomUUID(),view.admin.getUniqueId(),view.target.getUniqueId(),dead.id(),dead.deathId(),clock.instant().truncatedTo(ChronoUnit.SECONDS),candidate.slot,source.getAmount(),destination,true);reservations.reserve(reservation).whenComplete((saved,failure)->schedule(()->{if(failure!=null){report(failure);view.admin.sendMessage("Admin revival reservation failed; no item changed.");view.close();return;}if(saved.isEmpty()||!view.current()||!unchanged(view.target,candidate)){view.admin.sendMessage("Admin revival became stale after reservation; no item changed.");view.close();return;}deliver(view,dead,reservation);}));}
|
||||
private static int emptySlot(Player target,int source){for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40||slot==source)continue;var item=target.getInventory().getItem(slot);if(item==null||item.isEmpty())return slot;}return-1;}
|
||||
private void deliver(View view,CharacterRecord dead,RevivalStore.Reservation reservation){try{var provisional=new CharacterRecord(dead.id(),dead.name(),dead.revision(),dead.stats(),dead.level(),dead.experience(),dead.unspentPoints(),dead.maximumHealth(),dead.learned(),dead.loadout(),dead.passives(),dead.cooldowns(),dead.restrictedKills(),false,dead.deathId(),dead.diedAt());var sheet=sheets.create(provisional);var source=view.target.getInventory().getItem(reservation.sourceSlot());if(reservation.sourceAmount()==1)view.target.getInventory().setItem(reservation.sourceSlot(),sheet);else{view.target.getInventory().setItem(reservation.destinationSlot(),sheet);var remaining=source.clone();remaining.setAmount(reservation.sourceAmount()-1);view.target.getInventory().setItem(reservation.sourceSlot(),remaining);}view.target.updateInventory();}catch(RuntimeException uncertain){report(uncertain);view.admin.sendMessage("Admin revival delivery is uncertain; retained proof requires recovery.");view.close();return;}characters.restoreDeathRecordAdministratively(reservation.character(),reservation.death(),NativeHealthLimit.read()).whenComplete((restored,failure)->schedule(()->{if(failure!=null||restored.isEmpty()){if(failure!=null)report(failure);view.admin.sendMessage("The Tail changed, but restoration is pending proof-based recovery.");view.close();return;}if(installFinal(view.target,restored.orElseThrow())){reservations.clear(reservation.character(),reservation.id()).exceptionally(error->{report(error);return false;});view.admin.sendMessage("Administratively restored "+restored.orElseThrow().name()+" for "+view.target.getName()+".");view.target.sendMessage("An administrator restored character sheet: "+restored.orElseThrow().name()+".");}else view.admin.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");view.close();}));}
|
||||
private void deliver(View view,CharacterRecord dead,RevivalStore.Reservation reservation){try{var provisional=new CharacterRecord(dead.id(),dead.name(),dead.revision(),dead.stats(),dead.level(),dead.experience(),dead.unspentPoints(),dead.maximumHealth(),dead.learned(),dead.loadout(),dead.passives(),dead.cooldowns(),dead.restrictedKills(),false,dead.deathId(),dead.diedAt(),dead.sheetGeneration());var sheet=sheets.create(provisional);var source=view.target.getInventory().getItem(reservation.sourceSlot());if(reservation.sourceAmount()==1)view.target.getInventory().setItem(reservation.sourceSlot(),sheet);else{view.target.getInventory().setItem(reservation.destinationSlot(),sheet);var remaining=source.clone();remaining.setAmount(reservation.sourceAmount()-1);view.target.getInventory().setItem(reservation.sourceSlot(),remaining);}view.target.updateInventory();}catch(RuntimeException uncertain){report(uncertain);view.admin.sendMessage("Admin revival delivery is uncertain; retained proof requires recovery.");view.close();return;}characters.restoreDeathRecordAdministratively(reservation.character(),reservation.death(),NativeHealthLimit.read()).whenComplete((restored,failure)->schedule(()->{if(failure!=null||restored.isEmpty()){if(failure!=null)report(failure);view.admin.sendMessage("The Tail changed, but restoration is pending proof-based recovery.");view.close();return;}if(installFinal(view.target,restored.orElseThrow())){reservations.clear(reservation.character(),reservation.id()).exceptionally(error->{report(error);return false;});view.admin.sendMessage("Administratively restored "+restored.orElseThrow().name()+" for "+view.target.getName()+".");view.target.sendMessage("An administrator restored character sheet: "+restored.orElseThrow().name()+".");}else view.admin.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");view.close();}));}
|
||||
private boolean installFinal(Player target,CharacterRecord record){int found=-1;for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40)continue;if(sheets.identify(target.getInventory().getItem(slot)).filter(record.id()::equals).isEmpty())continue;if(found>=0)return false;found=slot;}if(found<0)return false;target.getInventory().setItem(found,sheets.create(record));target.updateInventory();return true;}
|
||||
private void schedule(Runnable action){try{nextTick.accept(action);}catch(RuntimeException rejected){report(rejected);}}
|
||||
private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}
|
||||
|
||||
@@ -20,6 +20,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private final Server server;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Failure> diagnostics;
|
||||
private final java.util.LinkedHashMap<UUID, String> savedCharacterCatalog = new java.util.LinkedHashMap<>();
|
||||
private final java.util.concurrent.ConcurrentHashMap<Object, Object> savedSearches = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
private static final int SAVED_CATALOG_COMPLETION_LIMIT = 256;
|
||||
private static final int SAVED_SEARCH_PAGE_SIZE = 8;
|
||||
private volatile boolean closed;
|
||||
private games.dmg.fantasystick.persistence.AdventureSettings settings;
|
||||
private games.dmg.fantasystick.persistence.FireballSettings fireballSettings;
|
||||
@@ -93,6 +97,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private games.dmg.fantasystick.shop.FantasyArmorerController armorerController;
|
||||
private games.dmg.fantasystick.shop.ArmorMarket armorMarket;
|
||||
private games.dmg.fantasystick.book.CharacterBookController characterBooks;
|
||||
private games.dmg.fantasystick.persistence.CharacterStorage characters;
|
||||
private AdminCharacterRecovery characterRecovery;
|
||||
|
||||
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics) {
|
||||
@@ -380,9 +386,16 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
characterIssuer = Objects.requireNonNull(issuer);
|
||||
}
|
||||
|
||||
public void attachCharacterBooks(games.dmg.fantasystick.book.CharacterBookController books) {
|
||||
public void attachCharacterBooks(games.dmg.fantasystick.book.CharacterBookController books,
|
||||
games.dmg.fantasystick.persistence.CharacterStorage storage) {
|
||||
if (closed || characterBooks != null) throw new IllegalStateException("Character books already attached or command closed");
|
||||
characterBooks = Objects.requireNonNull(books);
|
||||
characters = Objects.requireNonNull(storage);
|
||||
}
|
||||
|
||||
public void attachCharacterRecovery(AdminCharacterRecovery recovery) {
|
||||
if (closed || characterRecovery != null) throw new IllegalStateException("Character recovery already attached or command closed");
|
||||
characterRecovery = Objects.requireNonNull(recovery);
|
||||
}
|
||||
|
||||
public void attachPartyRegistrars(games.dmg.fantasystick.party.PartyRegistrarDirectory directory,
|
||||
@@ -408,13 +421,19 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (closed) return true;
|
||||
if (adventureQuestCommands != null && args.length > 0 && args[0].equals("adventure")) {
|
||||
adventureQuestCommands.adventure(sender, args); return true;
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)) {
|
||||
sender.sendMessage("Permission denied: you need fantasystick.admin to administer Fantasy Stick.");
|
||||
return true;
|
||||
}
|
||||
if (args.length == 0) {
|
||||
adminUsage(sender);
|
||||
return true;
|
||||
}
|
||||
if (adventureQuestCommands != null && args[0].equals("adventure")) {
|
||||
if (args.length >= 2 && args[1].equals("reroll")) adventureQuestCommands.adventure(sender, args);
|
||||
else sender.sendMessage("Usage: /fstickadmin adventure reroll [confirm]");
|
||||
return true;
|
||||
}
|
||||
if (adventureQuestCommands != null && args.length > 0 && args[0].equals("adventure-site")) {
|
||||
adventureQuestCommands.site(sender, args); return true;
|
||||
}
|
||||
@@ -422,7 +441,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
adventureQuestCommands.giver(sender, args); return true;
|
||||
}
|
||||
if (args.length > 0 && args[0].equals("character")) {
|
||||
characterView(sender, args); return true;
|
||||
characterCommand(sender, args); return true;
|
||||
}
|
||||
if(dungeonCommands!=null&&args.length>0&&args[0].equals("dungeon")){dungeonCommands.execute(sender,args);return true;}
|
||||
if(args.length>0&&args[0].equals("shop")){shop(sender,args);return true;}
|
||||
@@ -454,7 +473,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
owner = UUID.fromString(args[1]);
|
||||
if (!owner.toString().equalsIgnoreCase(args[1])) throw new IllegalArgumentException("Noncanonical UUID shape");
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Usage: /fantasystick <inspect|recover> <player-uuid> (full UUID required)");
|
||||
sender.sendMessage("Usage: /fstickadmin <inspect|recover> <player-uuid> (full UUID required)");
|
||||
return true;
|
||||
}
|
||||
if (args[0].equals("recover")) {
|
||||
@@ -506,10 +525,50 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
return true;
|
||||
}
|
||||
|
||||
private void characterView(CommandSender sender, String[] args) {
|
||||
public boolean playerAdventure(CommandSender sender, String[] args) {
|
||||
if (closed) return true;
|
||||
if (adventureQuestCommands == null || args.length == 0 || !args[0].equals("adventure")
|
||||
|| args.length >= 2 && args[1].equals("reroll")) {
|
||||
sender.sendMessage("Usage: /fstick adventure [tracking [on|off]]");
|
||||
return true;
|
||||
}
|
||||
adventureQuestCommands.adventure(sender, args);
|
||||
return true;
|
||||
}
|
||||
|
||||
public List<String> completePlayerAdventure(CommandSender sender, String[] args) {
|
||||
if (closed || adventureQuestCommands == null || args.length == 0 || !args[0].equals("adventure"))
|
||||
return List.of();
|
||||
return adventureQuestCommands.complete(sender, args).stream()
|
||||
.filter(value -> !value.equals("reroll"))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static void adminUsage(CommandSender sender) {
|
||||
sender.sendMessage("Usage: /fstickadmin <adventure reroll [confirm]|adventure-site|character view|config|dungeon|experience|give|inspect|party-registrar|quest-giver|recover|revive|shop|training-dummy> ...");
|
||||
}
|
||||
|
||||
private void characterCommand(CommandSender sender, String[] args) {
|
||||
if (args.length >= 2 && args[1].equals("find")) {
|
||||
characterFind(sender, args);
|
||||
return;
|
||||
}
|
||||
if (args.length >= 2 && args[1].equals("show")) {
|
||||
characterShow(sender, args);
|
||||
return;
|
||||
}
|
||||
if (args.length >= 2 && args[1].equals("recover")) {
|
||||
characterRecover(sender, args);
|
||||
return;
|
||||
}
|
||||
if (args.length == 3 && args[1].equals("view")
|
||||
&& !(sender instanceof org.bukkit.entity.Player)) {
|
||||
sender.sendMessage("Character view requires an in-game administrator.");
|
||||
return;
|
||||
}
|
||||
if (characterBooks == null || !(sender instanceof org.bukkit.entity.Player administrator)
|
||||
|| args.length != 3 || !args[1].equals("view") || args[2].isBlank()) {
|
||||
sender.sendMessage("Usage: /fantasystick character view <online-player> (in-game administrator only)");
|
||||
sender.sendMessage("Usage: /fstickadmin character <find <name...>|show <character-uuid>|recover <character-uuid> <online-player>|view <online-player>>");
|
||||
return;
|
||||
}
|
||||
var matches = server.getOnlinePlayers().stream()
|
||||
@@ -523,9 +582,119 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
characterBooks.openReadOnly(administrator, matches.getFirst());
|
||||
}
|
||||
|
||||
private void characterFind(CommandSender sender, String[] args) {
|
||||
if (args.length < 3 || String.join(" ", java.util.Arrays.copyOfRange(args, 2, args.length)).isBlank()) {
|
||||
sender.sendMessage("Usage: /fstickadmin character find <name...>");
|
||||
return;
|
||||
}
|
||||
String query = String.join(" ", java.util.Arrays.copyOfRange(args, 2, args.length));
|
||||
String folded = query.toLowerCase(java.util.Locale.ROOT);
|
||||
Object ticket = new Object();
|
||||
Object requestKey = sender instanceof org.bukkit.entity.Player player
|
||||
? player.getUniqueId() : EquipmentAdminCommand.class;
|
||||
savedSearches.put(requestKey, ticket);
|
||||
characters.catalog().whenComplete((catalog, failure) -> {
|
||||
if (closed || savedSearches.get(requestKey) != ticket) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed || !savedSearches.remove(requestKey, ticket) || !sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
if (failure != null) {
|
||||
sender.sendMessage("Saved character search failed; no records were changed.");
|
||||
return;
|
||||
}
|
||||
rememberSavedCharacters(catalog.records());
|
||||
var matches = catalog.records().stream()
|
||||
.filter(record -> record.name().toLowerCase(java.util.Locale.ROOT).contains(folded))
|
||||
.sorted(java.util.Comparator.comparing(
|
||||
(games.dmg.fantasystick.persistence.CharacterRecord record) ->
|
||||
record.name().toLowerCase(java.util.Locale.ROOT))
|
||||
.thenComparing(record -> record.id().toString()))
|
||||
.toList();
|
||||
if (matches.isEmpty()) {
|
||||
sender.sendMessage(catalog.complete()
|
||||
? "No saved characters match '" + query + "'."
|
||||
: "No readable saved characters match '" + query
|
||||
+ "'; results are incomplete because " + catalog.unreadable() + " record(s) could not be examined.");
|
||||
return;
|
||||
}
|
||||
int pages = (matches.size() + SAVED_SEARCH_PAGE_SIZE - 1) / SAVED_SEARCH_PAGE_SIZE;
|
||||
for (int page = 0; page < pages; page++) {
|
||||
sender.sendMessage("Saved character results " + (page + 1) + "/" + pages
|
||||
+ (catalog.complete() ? "" : " (incomplete: " + catalog.unreadable() + " unreadable)"));
|
||||
int from = page * SAVED_SEARCH_PAGE_SIZE;
|
||||
for (var record : matches.subList(from, Math.min(matches.size(), from + SAVED_SEARCH_PAGE_SIZE))) {
|
||||
boolean active = sessions.activeCharacter(record.id()).filter(sessions::current).isPresent();
|
||||
var result = net.kyori.adventure.text.Component.text(record.name() + " — " + record.id()
|
||||
+ " — level " + record.level() + " — " + (record.dead() ? "dead" : "alive")
|
||||
+ " — " + (active ? "active" : "inactive"))
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.suggestCommand(
|
||||
"/fstickadmin character show " + record.id()));
|
||||
sender.sendMessage(result);
|
||||
}
|
||||
}
|
||||
}); } catch (RuntimeException rejected) {
|
||||
savedSearches.remove(requestKey, ticket);
|
||||
report(null, "character-find-response", rejected);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void characterRecover(CommandSender sender, String[] args) {
|
||||
if (characterRecovery == null || !(sender instanceof org.bukkit.entity.Player administrator)) {
|
||||
sender.sendMessage("Saved character recovery requires an in-game administrator.");
|
||||
return;
|
||||
}
|
||||
UUID character;
|
||||
try {
|
||||
if (args.length != 4) throw new IllegalArgumentException("Invalid shape");
|
||||
character = UUID.fromString(args[2]);
|
||||
if (!character.toString().equals(args[2])) throw new IllegalArgumentException("Noncanonical UUID");
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Usage: /fstickadmin character recover <full-character-uuid> <online-player>");
|
||||
return;
|
||||
}
|
||||
var matches = server.getOnlinePlayers().stream().filter(org.bukkit.entity.Player::isOnline)
|
||||
.filter(player -> player.getName().equals(args[3])).toList();
|
||||
if (matches.size() != 1) {
|
||||
sender.sendMessage("Character recovery refused: recipient must exactly name one online player.");
|
||||
return;
|
||||
}
|
||||
characterRecovery.open(administrator, character, matches.getFirst());
|
||||
}
|
||||
|
||||
private void characterShow(CommandSender sender, String[] args) {
|
||||
if (characterBooks == null || !(sender instanceof org.bukkit.entity.Player administrator)) {
|
||||
sender.sendMessage("Saved character books require an in-game administrator.");
|
||||
return;
|
||||
}
|
||||
UUID character;
|
||||
try {
|
||||
if (args.length != 3) throw new IllegalArgumentException("Invalid shape");
|
||||
character = UUID.fromString(args[2]);
|
||||
if (!character.toString().equals(args[2])) throw new IllegalArgumentException("Noncanonical UUID");
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Usage: /fstickadmin character show <full-character-uuid>");
|
||||
return;
|
||||
}
|
||||
characterBooks.openSavedReadOnly(administrator, character);
|
||||
}
|
||||
|
||||
private synchronized void rememberSavedCharacters(
|
||||
java.util.Collection<games.dmg.fantasystick.persistence.CharacterRecord> records) {
|
||||
for (var record : records.stream().sorted(java.util.Comparator.comparing(value -> value.id().toString())).toList()) {
|
||||
savedCharacterCatalog.put(record.id(), record.name());
|
||||
while (savedCharacterCatalog.size() > SAVED_CATALOG_COMPLETION_LIMIT)
|
||||
savedCharacterCatalog.remove(savedCharacterCatalog.keySet().iterator().next());
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized java.util.Map<UUID, String> savedCharacterSuggestions() {
|
||||
return java.util.Map.copyOf(savedCharacterCatalog);
|
||||
}
|
||||
|
||||
private void experience(CommandSender sender, String[] args) {
|
||||
if (experienceGrant == null || args.length < 4 || !args[1].equals("give")) {
|
||||
sender.sendMessage("Usage: /fantasystick experience give <active-character-name...> <positive-amount>"); return;
|
||||
sender.sendMessage("Usage: /fstickadmin experience give <active-character-name...> <positive-amount>"); return;
|
||||
}
|
||||
BigInteger amount;
|
||||
try { amount = new BigInteger(args[args.length - 1]); }
|
||||
@@ -537,7 +706,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
}
|
||||
String name = String.join(" ", java.util.Arrays.copyOfRange(args, 2, args.length - 1));
|
||||
if (name.isBlank()) {
|
||||
sender.sendMessage("Usage: /fantasystick experience give <active-character-name...> <positive-amount>"); return;
|
||||
sender.sendMessage("Usage: /fstickadmin experience give <active-character-name...> <positive-amount>"); return;
|
||||
}
|
||||
var matches = sessions.activeCharacters().stream().filter(active -> active.record().name().equals(name)).toList();
|
||||
if (matches.isEmpty()) {
|
||||
@@ -571,7 +740,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (!accepted) sender.sendMessage("Experience grant refused: the captured character session changed before persistence. No fantasy XP changed.");
|
||||
}
|
||||
|
||||
private void adminRevive(CommandSender sender,String[] args){if(args.length!=2||args[1].isBlank()||adminRevival==null||!(sender instanceof org.bukkit.entity.Player admin)){sender.sendMessage("Usage: /fantasystick revive <online-player> (an in-game administrator must select the Tail)");return;}var matches=server.getOnlinePlayers().stream().filter(player->player.isOnline()&&player.getName().equalsIgnoreCase(args[1])).toList();if(matches.size()!=1){sender.sendMessage("Admin revival refused: target must name one online player. No item changed.");return;}adminRevival.open(admin,matches.getFirst(),adminReviveSettings.timeout());}
|
||||
private void adminRevive(CommandSender sender,String[] args){if(args.length!=2||args[1].isBlank()||adminRevival==null||!(sender instanceof org.bukkit.entity.Player admin)){sender.sendMessage("Usage: /fstickadmin revive <online-player> (an in-game administrator must select the Tail)");return;}var matches=server.getOnlinePlayers().stream().filter(player->player.isOnline()&&player.getName().equalsIgnoreCase(args[1])).toList();if(matches.size()!=1){sender.sendMessage("Admin revival refused: target must name one online player. No item changed.");return;}adminRevival.open(admin,matches.getFirst(),adminReviveSettings.timeout());}
|
||||
|
||||
private void shop(CommandSender sender,String[] args){
|
||||
if(armorers==null||armorerController==null||args.length<2){shopUsage(sender);return;}
|
||||
@@ -586,7 +755,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if(args[1].equals("remove")&&args.length==3){UUID id;try{id=UUID.fromString(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Fantasy Armorer UUID is invalid.");return;}armorers.remove(id).whenComplete((removed,failure)->removeShopResponse(sender,id,removed,failure));return;}
|
||||
shopUsage(sender);
|
||||
}
|
||||
private static void shopUsage(CommandSender sender){sender.sendMessage("Usage: /fantasystick shop <place [name...]|remove <shop-uuid>|list|restock>");}
|
||||
private static void shopUsage(CommandSender sender){sender.sendMessage("Usage: /fstickadmin shop <place [name...]|remove <shop-uuid>|list|restock>");}
|
||||
private void placeShopResponse(org.bukkit.entity.Player player,org.bukkit.Location location,games.dmg.fantasystick.shop.FantasyArmorer saved,Throwable failure){if(failure!=null)report(player.getUniqueId(),"shop-place",failure);if(closed)return;nextTick.accept(()->{if(closed)return;if(failure!=null){if(player.isOnline())player.sendMessage("Fantasy Armorer placement failed; no registration or entity changed.");return;}try{int x=location.getBlockX()>>4,z=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(x,z))armorerController.reconcile(location.getWorld().getChunkAt(x,z));if(player.isOnline())player.sendMessage("Placed Fantasy Armorer "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"shop-entity",entityFailure);armorers.remove(saved.id()).whenComplete((ignored,rollback)->nextTick.accept(()->{armorerController.removeLoaded(saved.id());if(player.isOnline())player.sendMessage("Fantasy Armorer placement failed; no registration or entity changed.");}));}});}
|
||||
private void removeShopResponse(CommandSender sender,UUID id,java.util.Optional<games.dmg.fantasystick.shop.FantasyArmorer> removed,Throwable failure){if(failure!=null)report(null,"shop-remove",failure);if(closed)return;nextTick.accept(()->{if(failure!=null)sender.sendMessage("Fantasy Armorer removal failed; registration and entity are unchanged.");else if(removed.isEmpty())sender.sendMessage("Fantasy Armorer not found.");else try{armorerController.removeLoaded(id);sender.sendMessage("Removed Fantasy Armorer "+id+".");}catch(RuntimeException entityFailure){var value=removed.orElseThrow();report(null,"shop-entity-remove",entityFailure);armorers.restore(value).whenComplete((restored,rollback)->{if(rollback!=null)report(null,"shop-remove-rollback",rollback);if(closed)return;nextTick.accept(()->{if(rollback==null){var world=server.getWorld(value.world());if(world!=null&&world.isChunkLoaded(((int)Math.floor(value.x()))>>4,((int)Math.floor(value.z()))>>4))armorerController.reconcile(world.getChunkAt(((int)Math.floor(value.x()))>>4,((int)Math.floor(value.z()))>>4));sender.sendMessage("Fantasy Armorer removal failed; registration and entity are unchanged.");}else sender.sendMessage("Fantasy Armorer removal rollback failed; inspect diagnostics.");});});}});}
|
||||
|
||||
@@ -622,7 +791,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
}
|
||||
|
||||
private static void trainingDummyUsage(CommandSender sender) {
|
||||
sender.sendMessage("Usage: /fantasystick training-dummy <place [name...]|remove <dummy-uuid>|list>");
|
||||
sender.sendMessage("Usage: /fstickadmin training-dummy <place [name...]|remove <dummy-uuid>|list>");
|
||||
}
|
||||
|
||||
private void placeDummyResponse(org.bukkit.entity.Player player, org.bukkit.Location location,
|
||||
@@ -703,16 +872,16 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private void placeRegistrarResponse(org.bukkit.entity.Player player,org.bukkit.Location location,games.dmg.fantasystick.party.PartyRegistrar saved,Throwable failure){if(failure!=null)report(player.getUniqueId(),"party-registrar-place",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure!=null){if(player.isOnline())player.sendMessage("Party Registrar placement failed; no registration or entity changed.");return;}try{int chunkX=location.getBlockX()>>4,chunkZ=location.getBlockZ()>>4;if(location.getWorld().isChunkLoaded(chunkX,chunkZ))partyRegistrarController.reconcile(location.getWorld().getChunkAt(chunkX,chunkZ));if(player.isOnline())player.sendMessage("Placed Party Registrar "+saved.id()+".");}catch(RuntimeException entityFailure){report(player.getUniqueId(),"party-registrar-entity",entityFailure);partyRegistrars.remove(saved.id()).whenComplete((ignored,rollbackFailure)->{if(rollbackFailure!=null)report(player.getUniqueId(),"party-registrar-rollback",rollbackFailure);if(closed)return;nextTick.accept(()->{partyRegistrarController.removeLoaded(saved.id());if(player.isOnline())player.sendMessage("Party Registrar placement failed; no registration or entity changed.");});});}});}catch(RuntimeException rejected){report(player.getUniqueId(),"party-registrar-response",rejected);}}
|
||||
private void removeRegistrarResponse(CommandSender sender,UUID id,java.util.Optional<games.dmg.fantasystick.party.PartyRegistrar> removed,Throwable failure){if(failure!=null)report(null,"party-registrar-remove",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure!=null)sender.sendMessage("Party Registrar removal failed; registration and entity are unchanged.");else if(removed.isEmpty())sender.sendMessage("Party Registrar not found.");else try{partyRegistrarController.removeLoaded(id);sender.sendMessage("Removed Party Registrar "+id+".");}catch(RuntimeException entityFailure){report(null,"party-registrar-entity",entityFailure);partyRegistrars.restore(removed.orElseThrow()).whenComplete((restored,rollbackFailure)->{if(rollbackFailure!=null)report(null,"party-registrar-rollback",rollbackFailure);if(closed)return;nextTick.accept(()->{if(rollbackFailure==null){var world=server.getWorld(restored.world());int chunkX=((int)Math.floor(restored.x()))>>4,chunkZ=((int)Math.floor(restored.z()))>>4;if(world!=null&&world.isChunkLoaded(chunkX,chunkZ))partyRegistrarController.reconcile(world.getChunkAt(chunkX,chunkZ));}sender.sendMessage("Party Registrar removal failed; registration and entity are unchanged.");});});}});}catch(RuntimeException rejected){report(null,"party-registrar-response",rejected);}}
|
||||
private void partyRegistrar(CommandSender sender,String[] args){
|
||||
if(partyRegistrars==null||partyRegistrarController==null||args.length<2){sender.sendMessage("Usage: /fantasystick party-registrar <place [name...]|remove <registrar-uuid>|list>");return;}
|
||||
if(partyRegistrars==null||partyRegistrarController==null||args.length<2){sender.sendMessage("Usage: /fstickadmin party-registrar <place [name...]|remove <registrar-uuid>|list>");return;}
|
||||
if(args[1].equals("list")&&args.length==2){var values=partyRegistrars.all();if(values.isEmpty())sender.sendMessage("No Party Registrars are registered.");else for(var value:values)sender.sendMessage(value.id()+" "+(value.name().isBlank()?"Party Registrar":value.name())+" @ "+value.world()+" "+value.x()+" "+value.y()+" "+value.z());return;}
|
||||
if(args[1].equals("place")&&sender instanceof org.bukkit.entity.Player player){String name=args.length<3?"":String.join(" ",java.util.Arrays.copyOfRange(args,2,args.length));var location=player.getLocation().clone();java.util.concurrent.CompletableFuture<games.dmg.fantasystick.party.PartyRegistrar> operation;try{operation=partyRegistrars.place(name,location.getWorld().getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch());}catch(IllegalArgumentException invalid){sender.sendMessage("Party Registrar name is invalid.");return;}operation.whenComplete((saved,failure)->placeRegistrarResponse(player,location,saved,failure));return;}
|
||||
if(args[1].equals("remove")&&args.length==3){UUID id;try{id=UUID.fromString(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Party Registrar UUID is invalid.");return;}partyRegistrars.remove(id).whenComplete((removed,failure)->removeRegistrarResponse(sender,id,removed,failure));return;}
|
||||
sender.sendMessage("Usage: /fantasystick party-registrar <place [name...]|remove <registrar-uuid>|list>");
|
||||
sender.sendMessage("Usage: /fstickadmin party-registrar <place [name...]|remove <registrar-uuid>|list>");
|
||||
}
|
||||
|
||||
private void give(CommandSender sender, String[] args) {
|
||||
if (args.length != 2 || args[1].isBlank() || characterIssuer == null) {
|
||||
sender.sendMessage("Usage: /fantasystick give <online-player>"); return;
|
||||
sender.sendMessage("Usage: /fstickadmin give <online-player>"); return;
|
||||
}
|
||||
var matches = server.getOnlinePlayers().stream().filter(player -> player.isOnline()
|
||||
&& player.getName().equalsIgnoreCase(args[1])).toList();
|
||||
@@ -1413,7 +1582,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
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 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: /fantasystick 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;}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 switchDelayConfiguration(CommandSender sender,String[] args){if(switchDelaySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY+"="+switchDelaySettings.value()+" (new exits use this delay; existing deadlines are unchanged)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SwitchDelaySettings.Update> operation;try{operation=switchDelaySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Character switch delay is invalid; the effective value is unchanged.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-switch-delay",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure==null)switchDelaySettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Saved "+games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY+"="+saved.value()+"; new exits use it and existing deadlines are unchanged.":"Character switch delay save failed; the effective value is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
|
||||
@@ -1421,7 +1590,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void adventureConfiguration(CommandSender sender, String[] args) {
|
||||
if (args.length == 3 && !args[2].equals("true") && !args[2].equals("false")) {
|
||||
sender.sendMessage("Usage: /fantasystick config adventure-mode [true|false]"); return;
|
||||
sender.sendMessage("Usage: /fstickadmin config adventure-mode [true|false]"); return;
|
||||
}
|
||||
if (settings == null) { sender.sendMessage("Adventure configuration is unavailable."); return; }
|
||||
if (args.length == 2) {
|
||||
@@ -1443,7 +1612,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
}
|
||||
|
||||
private static void configurationUsage(CommandSender sender) {
|
||||
sender.sendMessage("Usage: /fantasystick config <setting> [value]");
|
||||
sender.sendMessage("Usage: /fstickadmin config <setting> [value]");
|
||||
}
|
||||
|
||||
private void report(UUID owner, String operation, Throwable cause) {
|
||||
@@ -1454,20 +1623,37 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
// Return only explicit implemented routes; never fall back to Bukkit's broad default suggestions.
|
||||
@Override public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
|
||||
if (closed) return List.of();
|
||||
if (adventureQuestCommands != null) {
|
||||
if (args.length == 1 && !sender.hasPermission(PERMISSION)) return matching(List.of("adventure"), args[0]);
|
||||
if (args.length > 1) {
|
||||
var adventure = adventureQuestCommands.complete(sender,args); if(!adventure.isEmpty()) return adventure;
|
||||
if(args[0].equals("adventure")||args[0].equals("adventure-site")||args[0].equals("quest-giver"))return List.of();
|
||||
}
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)) return List.of();
|
||||
if (adventureQuestCommands != null && args.length > 1) {
|
||||
if (args[0].equals("adventure")) {
|
||||
if (args.length == 2) return matching(List.of("reroll"), args[1]);
|
||||
if (args.length == 3 && args[1].equals("reroll")) return matching(List.of("confirm"), args[2]);
|
||||
return List.of();
|
||||
}
|
||||
var adventure = adventureQuestCommands.complete(sender,args); if(!adventure.isEmpty()) return adventure;
|
||||
if(args[0].equals("adventure-site")||args[0].equals("quest-giver"))return List.of();
|
||||
}
|
||||
if(dungeonCommands!=null&&args.length>1){var dungeon=dungeonCommands.complete(sender,args);if(!dungeon.isEmpty())return dungeon;if(args[0].equals("dungeon"))return List.of();}
|
||||
if (args.length == 1) return matching(sender instanceof org.bukkit.entity.Player
|
||||
? List.of("adventure", "adventure-site", "character", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "recover", "revive", "shop", "training-dummy")
|
||||
: List.of("adventure-site", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "recover", "shop", "training-dummy"), args[0]);
|
||||
if (args.length == 2 && args[0].equals("character") && sender instanceof org.bukkit.entity.Player)
|
||||
return matching(List.of("view"), args[1]);
|
||||
if (args.length == 2 && args[0].equals("character")) {
|
||||
var routes = new java.util.ArrayList<>(List.of("find"));
|
||||
if (sender instanceof org.bukkit.entity.Player) routes.addAll(List.of("recover", "show", "view"));
|
||||
return matching(routes, args[1]);
|
||||
}
|
||||
if (args.length >= 3 && args[0].equals("character") && args[1].equals("find")) {
|
||||
String prefix = String.join(" ", java.util.Arrays.copyOfRange(args, 2, args.length));
|
||||
return matchingIgnoreCase(savedCharacterSuggestions().values(), prefix);
|
||||
}
|
||||
if (args.length == 3 && args[0].equals("character")
|
||||
&& (args[1].equals("show") || args[1].equals("recover"))
|
||||
&& sender instanceof org.bukkit.entity.Player)
|
||||
return matching(savedCharacterSuggestions().keySet().stream().map(UUID::toString).toList(), args[2]);
|
||||
if (args.length == 4 && args[0].equals("character") && args[1].equals("recover")
|
||||
&& sender instanceof org.bukkit.entity.Player)
|
||||
return matching(server.getOnlinePlayers().stream().filter(org.bukkit.entity.Player::isOnline)
|
||||
.map(org.bukkit.entity.Player::getName).toList(), args[3]);
|
||||
if (args.length == 3 && args[0].equals("character") && args[1].equals("view")
|
||||
&& sender instanceof org.bukkit.entity.Player)
|
||||
return matching(server.getOnlinePlayers().stream().filter(org.bukkit.entity.Player::isOnline)
|
||||
@@ -1666,5 +1852,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private static List<String> matching(java.util.Collection<String> values, String prefix) {
|
||||
return values.stream().filter(value -> value.startsWith(prefix)).distinct().sorted().toList();
|
||||
}
|
||||
@Override public void close() { closed = true; if (characterIssuer != null) characterIssuer.close(); if(adminRevival!=null)adminRevival.close(); if(experienceGrant!=null)experienceGrant.close(); }
|
||||
|
||||
private static List<String> matchingIgnoreCase(java.util.Collection<String> values, String prefix) {
|
||||
String folded = prefix.toLowerCase(java.util.Locale.ROOT);
|
||||
return values.stream().filter(value -> value.toLowerCase(java.util.Locale.ROOT).startsWith(folded))
|
||||
.distinct().sorted(java.util.Comparator.comparing((String value) -> value.toLowerCase(java.util.Locale.ROOT))
|
||||
.thenComparing(java.util.Comparator.naturalOrder())).toList();
|
||||
}
|
||||
@Override public void close() { closed = true; savedSearches.clear(); if (characterIssuer != null) characterIssuer.close(); if(adminRevival!=null)adminRevival.close(); if(characterRecovery!=null)characterRecovery.close(); if(experienceGrant!=null)experienceGrant.close(); }
|
||||
}
|
||||
@@ -74,6 +74,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
private boolean closed;
|
||||
private Consumer<ControlInput> inputs = ignored -> { };
|
||||
private java.util.function.Predicate<org.bukkit.entity.Entity> managedInteractionTarget = ignored -> false;
|
||||
private java.util.function.BiPredicate<Player,ItemStack> nativeUseControl=(player,item)->false;
|
||||
private record Draw(Player player, ItemStack item, EquipmentSessions.SelectedControl selected) { }
|
||||
private final Map<UUID, Draw> draws = new HashMap<>();
|
||||
|
||||
@@ -104,6 +105,8 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
managedInteractionTarget = Objects.requireNonNull(target);
|
||||
}
|
||||
|
||||
public void attachNativeUseControl(java.util.function.BiPredicate<Player,ItemStack> control){if(closed)throw new IllegalStateException("Events closed");nativeUseControl=Objects.requireNonNull(control);}
|
||||
|
||||
/** The input recipient belongs to this plugin; it does not authorize vanilla item use. */
|
||||
public EquipmentSessionEvents(EquipmentSessions sessions, Consumer<Runnable> nextTick,
|
||||
Consumer<EquipmentSessions.Failure> diagnostics, Consumer<ControlInput> inputs) {
|
||||
@@ -346,7 +349,8 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void use(org.bukkit.event.player.PlayerInteractEvent event) {
|
||||
boolean allowed = event.useItemInHand() != org.bukkit.event.Event.Result.DENY;
|
||||
if (special(event.getItem()) || active(event.getPlayer()) && forbiddenEquip(event.getItem())) {
|
||||
boolean nativeControl=nativeUseControl.test(event.getPlayer(),event.getItem());
|
||||
if (special(event.getItem()) && (!nativeControl||event.getPlayer().isSneaking()) || active(event.getPlayer()) && forbiddenEquip(event.getItem())) {
|
||||
// Do not veto an unrelated door/chest interaction or undo another plugin's block veto.
|
||||
event.setUseItemInHand(org.bukkit.event.Event.Result.DENY);
|
||||
}
|
||||
@@ -354,7 +358,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
|| event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK)) {
|
||||
var selected = sessions.selectedControl(event.getPlayer(), event.getItem()).orElse(null);
|
||||
if (selected != null && !selected.ability().isEmpty() && doorInteraction(event)) return;
|
||||
if (selected != null && useGesture(selected)) {
|
||||
if (selected != null && useGesture(selected) && (!nativeControl||event.getPlayer().isSneaking())) {
|
||||
org.bukkit.entity.Entity target = selected.ability().equals("hex")
|
||||
|| selected.ability().equals("mending_light")
|
||||
|| selected.ability().equals("guardian_bond")
|
||||
@@ -459,7 +463,8 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
if (special(item)) event.setCancelled(true);
|
||||
if (event.getHand() == EquipmentSlot.HAND) {
|
||||
var selected = sessions.selectedControl(player, item).orElse(null);
|
||||
if (selected != null && useGesture(selected)) {
|
||||
boolean nativeControl=nativeUseControl.test(player,item);
|
||||
if (selected != null && useGesture(selected) && (!nativeControl||player.isSneaking())) {
|
||||
org.bukkit.entity.Entity target = selected.ability().equals("inspire") && player.isSneaking()
|
||||
? player : event.getRightClicked();
|
||||
emit(player, item, selected, ControlInput.Gesture.USE, target, 0, 0);
|
||||
|
||||
@@ -60,6 +60,8 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
private final Map<UUID, Attempt> pending = new HashMap<>();
|
||||
private final Map<UUID, Attempt> owned = new HashMap<>();
|
||||
private final Map<UUID, Attempt> active = new HashMap<>();
|
||||
private final Map<UUID, UUID> characterRecoveries = new HashMap<>();
|
||||
private final java.util.Set<UUID> recoveryRecipients = new java.util.HashSet<>();
|
||||
private final java.util.ArrayDeque<UUID> cooldownRefreshOrder = new java.util.ArrayDeque<>();
|
||||
private final java.util.Set<UUID> cooldownRefreshQueued = new java.util.HashSet<>();
|
||||
// Covers reentrant inventory setters and client sync, not just asynchronous work.
|
||||
@@ -78,6 +80,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
final Player player;
|
||||
final UUID owner;
|
||||
UUID character;
|
||||
CharacterSheets.Identity sheetIdentity;
|
||||
CharacterRecord record;
|
||||
SessionClaims.Claim claim;
|
||||
UUID session;
|
||||
@@ -250,12 +253,55 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
return attempt == null ? Optional.empty() : Optional.of(new Active(player, attempt.session, attempt.character));
|
||||
}
|
||||
|
||||
/** Server-thread recovery gate; persistence still revalidates revision/generation/death atomically. */
|
||||
public synchronized boolean availableForCharacterRecovery(UUID character, UUID recipient) {
|
||||
Objects.requireNonNull(character); Objects.requireNonNull(recipient);
|
||||
return availableForCharacterRecoveryNow(character, recipient)
|
||||
&& !characterRecoveries.containsKey(character) && !recoveryRecipients.contains(recipient);
|
||||
}
|
||||
|
||||
/** Process-lifetime custody closes the entry race while durable recovery is prepared and delivered. */
|
||||
public synchronized Optional<CharacterRecoveryClaim> claimCharacterRecovery(UUID character, UUID recipient) {
|
||||
Objects.requireNonNull(character); Objects.requireNonNull(recipient);
|
||||
if (!availableForCharacterRecovery(character, recipient)) return Optional.empty();
|
||||
characterRecoveries.put(character, recipient);
|
||||
recoveryRecipients.add(recipient);
|
||||
return Optional.of(new CharacterRecoveryClaim(character, recipient));
|
||||
}
|
||||
|
||||
private boolean availableForCharacterRecoveryNow(UUID character, UUID recipient) {
|
||||
if (closed || unavailableCharacter.test(character) || active.containsKey(recipient)
|
||||
|| pending.containsKey(recipient) || transitioning.containsKey(recipient)
|
||||
|| owned.containsKey(recipient)) return false;
|
||||
return java.util.stream.Stream.of(active.values(), pending.values(), transitioning.values(), owned.values())
|
||||
.flatMap(java.util.Collection::stream)
|
||||
.noneMatch(attempt -> character.equals(attempt.character));
|
||||
}
|
||||
|
||||
public final class CharacterRecoveryClaim implements AutoCloseable {
|
||||
private final UUID character;
|
||||
private final UUID recipient;
|
||||
private boolean released;
|
||||
private CharacterRecoveryClaim(UUID character, UUID recipient) {
|
||||
this.character = character;
|
||||
this.recipient = recipient;
|
||||
}
|
||||
@Override public void close() {
|
||||
synchronized (EquipmentSessions.this) {
|
||||
if (released) return;
|
||||
released = true;
|
||||
characterRecoveries.remove(character, recipient);
|
||||
recoveryRecipients.remove(recipient);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Server-thread effect authorization: registry identity alone is not live custody. */
|
||||
public synchronized boolean current(Active session) {
|
||||
var attempt = active.get(session.player());
|
||||
return !closed && attempt != null && attempt.session.equals(session.session())
|
||||
&& attempt.character.equals(session.character()) && attempt.player.isOnline()
|
||||
&& sheets.identify(attempt.player.getInventory().getItemInOffHand()).filter(attempt.character::equals).isPresent();
|
||||
&& attempt.character.equals(session.character()) && attempt.player.isOnline() && attempt.record != null
|
||||
&& sheets.authorizes(attempt.player.getInventory().getItemInOffHand(), attempt.record);
|
||||
}
|
||||
|
||||
/** Side-effect-free server-thread snapshots for exact active-character command resolution/autocomplete. */
|
||||
@@ -403,7 +449,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
public synchronized Optional<SelectedControl> selectedControl(Player player, ItemStack reported) {
|
||||
var attempt = active.get(player.getUniqueId());
|
||||
if (closed || attempt == null || attempt.player != player || !player.isOnline() || attempt.record == null
|
||||
|| !sheets.identify(player.getInventory().getItemInOffHand()).filter(attempt.character::equals).isPresent()) {
|
||||
|| !sheets.authorizes(player.getInventory().getItemInOffHand(), attempt.record)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
int slot = player.getInventory().getHeldItemSlot();
|
||||
@@ -422,6 +468,10 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
public synchronized void enter(Player player) {
|
||||
UUID owner = player.getUniqueId();
|
||||
if (closed) return;
|
||||
if (recoveryRecipients.contains(owner)) {
|
||||
notice(owner, "CHARACTER_RECOVERY", "Administrative character recovery is still completing.");
|
||||
return;
|
||||
}
|
||||
if (roleplayDisabled.getAsBoolean()) {
|
||||
notice(owner, "ROLEPLAY_DISABLED", "Fantasy roleplaying is disabled; the character sheet was not activated.");
|
||||
return;
|
||||
@@ -440,16 +490,22 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
notice(owner, "BUSY", "Equipment persistence or recovery is pending; retry after it completes.");
|
||||
return;
|
||||
}
|
||||
var character = sheets.identify(player.getInventory().getItemInOffHand());
|
||||
if (!player.isOnline() || character.isEmpty()) {
|
||||
var identity = sheets.identity(player.getInventory().getItemInOffHand());
|
||||
if (!player.isOnline() || identity.isEmpty()) {
|
||||
notice(owner, "INVALID_SHEET", "Hold a valid live character sheet in your off hand.");
|
||||
return;
|
||||
}
|
||||
if (unavailableCharacter.test(character.orElseThrow())) {
|
||||
var character = identity.orElseThrow().character();
|
||||
if (characterRecoveries.containsKey(character)) {
|
||||
notice(owner, "CHARACTER_RECOVERY", "That character has unresolved administrative recovery custody.");
|
||||
return;
|
||||
}
|
||||
if (unavailableCharacter.test(character)) {
|
||||
notice(owner, "CHARACTER_IN_CUSTODY", "That character sheet is assigned to a dungeon boss and cannot be activated.");
|
||||
return;
|
||||
}
|
||||
var attempt = new Attempt(player, owner, character.orElseThrow());
|
||||
var attempt = new Attempt(player, owner, character);
|
||||
attempt.sheetIdentity = identity.orElseThrow();
|
||||
if (adventureMode.getAsBoolean() && player.getGameMode() != org.bukkit.GameMode.ADVENTURE) {
|
||||
attempt.previousGameMode = player.getGameMode().name();
|
||||
}
|
||||
@@ -506,6 +562,11 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
if (record == null || record.dead() || record.health().signum() <= 0) {
|
||||
refuse(attempt, "UNAVAILABLE_CHARACTER", "The saved character is missing, dead or has no health."); return;
|
||||
}
|
||||
if (!sheets.authorizes(attempt.player.getInventory().getItemInOffHand(), record)
|
||||
|| attempt.sheetIdentity == null || !attempt.sheetIdentity.equals(
|
||||
sheets.identity(attempt.player.getInventory().getItemInOffHand()).orElse(null))) {
|
||||
refuse(attempt, "STALE_SHEET", "That character sheet generation is no longer authorized."); return;
|
||||
}
|
||||
if (loaded.state().isPresent()) {
|
||||
var state = loaded.state().orElseThrow();
|
||||
if (state.pending() != null) {
|
||||
@@ -824,10 +885,11 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
|
||||
private boolean tailCustody(Attempt attempt) {
|
||||
var current = attempt.player.getInventory().getItemInOffHand();
|
||||
var authority = attempt.deathRecord == null ? attempt.record : attempt.deathRecord;
|
||||
return attempt.deathReference != null && attempt.deathNativeReference != null
|
||||
&& Objects.equals(attempt.deathReference, copy(current))
|
||||
&& nativeIdentity(current) == attempt.deathNativeReference
|
||||
&& sheets.identify(attempt.deathReference).filter(attempt.character::equals).isPresent()
|
||||
&& authority != null && sheets.authorizes(attempt.deathReference, authority)
|
||||
&& attempt.deathReference.getAmount() >= 1
|
||||
&& attempt.deathReference.getAmount() <= attempt.deathReference.getMaxStackSize();
|
||||
}
|
||||
@@ -1042,7 +1104,8 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
|| !Objects.equals(attempt.entryMarker, EquipmentSessionMarker.read(attempt.player).orElse(null)))
|
||||
|| !Arrays.equals(attempt.snapshot, snapshot(attempt.player))
|
||||
|| !Objects.equals(attempt.cursor, copy(attempt.player.getItemOnCursor()))
|
||||
|| !sheets.identify(attempt.player.getInventory().getItemInOffHand()).filter(attempt.character::equals).isPresent()) {
|
||||
|| attempt.sheetIdentity != null && !attempt.sheetIdentity.equals(
|
||||
sheets.identity(attempt.player.getInventory().getItemInOffHand()).orElse(null))) {
|
||||
refuse(attempt, "STALE_ENTRY", "Entry changed before preparation completed; retry with your current equipment.");
|
||||
return false;
|
||||
}
|
||||
@@ -1210,6 +1273,8 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
}
|
||||
for (var owner : java.util.List.copyOf(pending.keySet())) cancelEntry(owner);
|
||||
for (var attempt : java.util.List.copyOf(active.values())) exit(attempt.player);
|
||||
characterRecoveries.clear();
|
||||
recoveryRecipients.clear();
|
||||
claims.close();
|
||||
} finally {
|
||||
closeSweepFinished = true;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.command.TabExecutor;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Strict ordinary-player command root for personal adventure and presentation controls. */
|
||||
public final class FantasyPlayerCommand implements TabExecutor {
|
||||
private final EquipmentAdminCommand adventures;
|
||||
private final FantasyFeedbackCommand feedback;
|
||||
private final FantasyParticlesCommand particles;
|
||||
|
||||
public FantasyPlayerCommand(EquipmentAdminCommand adventures, FantasyFeedbackCommand feedback,
|
||||
FantasyParticlesCommand particles) {
|
||||
this.adventures = Objects.requireNonNull(adventures);
|
||||
this.feedback = Objects.requireNonNull(feedback);
|
||||
this.particles = Objects.requireNonNull(particles);
|
||||
}
|
||||
|
||||
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (args.length == 0) {
|
||||
if (!(sender instanceof Player)) sender.sendMessage("Fantasy Stick player controls require an in-game player.");
|
||||
return false;
|
||||
}
|
||||
return switch (args[0]) {
|
||||
case "adventure" -> adventures.playerAdventure(sender, args);
|
||||
case "feedback" -> feedback.onCommand(sender, command, label, tail(args));
|
||||
case "particles" -> particles.onCommand(sender, command, label, tail(args));
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
@Override public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
|
||||
if (!(sender instanceof Player)) return List.of();
|
||||
if (args.length == 1) return matching(List.of("adventure", "feedback", "particles"), args[0]);
|
||||
return switch (args[0]) {
|
||||
case "adventure" -> adventures.completePlayerAdventure(sender, args);
|
||||
case "feedback" -> feedback.onTabComplete(sender, command, alias, tail(args));
|
||||
case "particles" -> particles.onTabComplete(sender, command, alias, tail(args));
|
||||
default -> List.of();
|
||||
};
|
||||
}
|
||||
|
||||
private static String[] tail(String[] args) {
|
||||
return java.util.Arrays.copyOfRange(args, 1, args.length);
|
||||
}
|
||||
|
||||
private static List<String> matching(List<String> values, String rawPrefix) {
|
||||
String prefix = rawPrefix.toLowerCase(Locale.ROOT);
|
||||
return values.stream().filter(value -> value.startsWith(prefix)).toList();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
# Captured when a new fantasy session starts; changing this does not alter active sessions.
|
||||
# Admin command: /fantasystick config adventure-mode [true|false]
|
||||
# Admin command: /fstickadmin config adventure-mode [true|false]
|
||||
adventure-mode: true
|
||||
admin-revive-selection-timeout-seconds: 60
|
||||
|
||||
@@ -32,7 +32,7 @@ sheet-resource-pack-url: ''
|
||||
sheet-resource-pack-sha1: ''
|
||||
|
||||
# Active-session recovery after actual accepted damage has stopped.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
out-of-combat-healing-delay-seconds: 10
|
||||
out-of-combat-healing-interval-seconds: 10
|
||||
out-of-combat-healing-amount: 1
|
||||
@@ -73,9 +73,11 @@ adventure-champion-health: 100
|
||||
adventure-champion-damage: 8
|
||||
adventure-champion-reward-xp: 150
|
||||
adventure-champion-reward-coins: 40
|
||||
fishing-catch-xp-min: 1
|
||||
fishing-catch-xp-max: 3
|
||||
|
||||
# Fireball defaults from the reviewed starter catalogue.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
fireball-base-damage: 6
|
||||
fireball-cooldown-seconds: 1.2
|
||||
fireball-range-blocks: 32
|
||||
@@ -87,7 +89,7 @@ great-sword-base-damage: 7
|
||||
great-sword-recovery-seconds: 0.75
|
||||
|
||||
# Frost Shard defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
frost-shard-base-damage: 4
|
||||
frost-shard-cooldown-seconds: 5
|
||||
frost-shard-range-blocks: 32
|
||||
@@ -97,7 +99,7 @@ frost-shard-slowness-amplifier: 0
|
||||
frost-shard-slowness-duration-seconds: 2
|
||||
|
||||
# Longbow defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
longbow-base-damage: 5
|
||||
longbow-recovery-seconds: 1
|
||||
longbow-minimum-draw-ticks: 3
|
||||
@@ -107,24 +109,24 @@ longbow-lifetime-seconds: 10
|
||||
longbow-full-projectile-speed-blocks-per-tick: 3
|
||||
|
||||
# Quickstep defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
quickstep-cooldown-seconds: 5
|
||||
quickstep-distance-blocks: 4
|
||||
quickstep-speed-blocks-per-tick: 1
|
||||
|
||||
# Second Wind defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
second-wind-base-healing: 6
|
||||
second-wind-cooldown-seconds: 15
|
||||
|
||||
# Stone Skin defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
stone-skin-cooldown-seconds: 14
|
||||
stone-skin-duration-seconds: 4
|
||||
stone-skin-damage-reduction-percent: 25
|
||||
|
||||
# Shield Bash defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
shield-bash-base-damage: 4
|
||||
shield-bash-cooldown-seconds: 3
|
||||
shield-bash-range-blocks: 3
|
||||
@@ -132,39 +134,39 @@ shield-bash-horizontal-knockback: 0.8
|
||||
shield-bash-minimum-vertical-knockback: 0.35
|
||||
|
||||
# Hex defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
hex-cooldown-seconds: 10
|
||||
hex-range-blocks: 12
|
||||
hex-duration-seconds: 4
|
||||
hex-damage-reduction-percent: 20
|
||||
|
||||
# Inspire defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
inspire-base-temporary-health: 4
|
||||
inspire-cooldown-seconds: 15
|
||||
inspire-range-blocks: 12
|
||||
inspire-duration-seconds: 6
|
||||
|
||||
# Mending Light defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
mending-light-base-healing: 6
|
||||
mending-light-cooldown-seconds: 8
|
||||
mending-light-range-blocks: 12
|
||||
|
||||
# Purify defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
purify-cooldown-seconds: 15
|
||||
purify-range-blocks: 12
|
||||
|
||||
# Guardian Bond defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
guardian-bond-cooldown-seconds: 20
|
||||
guardian-bond-range-blocks: 10
|
||||
guardian-bond-duration-seconds: 6
|
||||
guardian-bond-redirect-percent: 30
|
||||
|
||||
# Rallying Cry defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
rallying-cry-cooldown-seconds: 25
|
||||
rallying-cry-radius-blocks: 8
|
||||
rallying-cry-duration-seconds: 6
|
||||
@@ -172,7 +174,7 @@ rallying-cry-speed-amplifier: 0
|
||||
rallying-cry-damage-bonus-percent: 20
|
||||
|
||||
# Frost Nova defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
frost-nova-base-damage: 5
|
||||
frost-nova-cooldown-seconds: 12
|
||||
frost-nova-radius-blocks: 5
|
||||
@@ -180,7 +182,7 @@ frost-nova-slowness-amplifier: 1
|
||||
frost-nova-slowness-duration-seconds: 3
|
||||
|
||||
# Chain Lightning defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
chain-lightning-base-damage: 8
|
||||
chain-lightning-cooldown-seconds: 18
|
||||
chain-lightning-initial-range-blocks: 16
|
||||
@@ -189,7 +191,7 @@ chain-lightning-additional-targets: 3
|
||||
chain-lightning-jump-multiplier: 0.75
|
||||
|
||||
# Sanctuary defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
sanctuary-cooldown-seconds: 45
|
||||
sanctuary-radius-blocks: 6
|
||||
sanctuary-vertical-range-blocks: 3
|
||||
@@ -199,7 +201,7 @@ sanctuary-healing-per-pulse: 2
|
||||
sanctuary-damage-reduction-percent: 20
|
||||
|
||||
# War Banner defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
war-banner-cooldown-seconds: 45
|
||||
war-banner-placement-range-blocks: 12
|
||||
war-banner-radius-blocks: 8
|
||||
@@ -209,7 +211,7 @@ war-banner-damage-bonus-percent: 25
|
||||
war-banner-knockback-resistance-percent: 50
|
||||
|
||||
# Meteor defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
meteor-base-damage: 14
|
||||
meteor-cooldown-seconds: 45
|
||||
meteor-target-range-blocks: 24
|
||||
|
||||
@@ -6,16 +6,13 @@ depend: [ProtocolLib]
|
||||
description: Transferable characters, native fantasy health, recoverable equipment, character books and parties.
|
||||
author: dmg.games
|
||||
commands:
|
||||
fantasystick:
|
||||
description: Inspect daily adventures or administer Fantasy Stick.
|
||||
usage: /fantasystick adventure [reroll confirm] | character view <online-player> | adventure-site <place <camp|champion> [name...]|remove <site-uuid>|teleport <site-uuid>|list> | dungeon <teleport <dungeon-uuid>|...> | quest-giver <place [name...]|remove <giver-uuid>|list> | <admin route>
|
||||
|
||||
fantasyfeedback:
|
||||
description: Read or change routine Fantasy Stick combat and ability feedback for your player account.
|
||||
usage: /fantasyfeedback [on|off]
|
||||
fantasyparticles:
|
||||
description: Read or change Fantasy Stick particle visibility for your player account.
|
||||
usage: /fantasyparticles <all|players> [on|off]
|
||||
fstick:
|
||||
description: Inspect personal adventures and manage Fantasy Stick player preferences.
|
||||
usage: /fstick adventure [tracking [on|off]] | feedback [on|off] | particles <all|players> [on|off]
|
||||
fstickadmin:
|
||||
description: Administer Fantasy Stick.
|
||||
usage: /fstickadmin adventure reroll [confirm] | character view <online-player> | adventure-site <place <camp|champion> [name...]|remove <site-uuid>|teleport <site-uuid>|list> | dungeon <teleport <dungeon-uuid>|...> | quest-giver <place [name...]|remove <giver-uuid>|list> | <admin route>
|
||||
permission: fantasystick.admin
|
||||
permissions:
|
||||
fantasystick.admin:
|
||||
description: Issue/revive sheets, grant fantasy experience, recover sessions and configure fantasy mode.
|
||||
|
||||
@@ -275,8 +275,8 @@ class AdminCharacterBookNativeTest {
|
||||
|
||||
assertEquals(java.util.List.of("character", "config"),
|
||||
fixture.command.tabComplete(admin.player, "fantasystick", new String[]{"c"}));
|
||||
assertEquals(java.util.List.of("view"),
|
||||
fixture.command.tabComplete(admin.player, "fantasystick", new String[]{"character", ""}));
|
||||
assertEquals(java.util.List.of("find", "recover", "show", "view"),
|
||||
fixture.command.tabComplete(admin.player, "fstickadmin", new String[]{"character", ""}));
|
||||
assertEquals(java.util.List.of("ActiveTarget"),
|
||||
fixture.command.tabComplete(admin.player, "fantasystick", new String[]{"character", "view", ""}));
|
||||
assertEquals(java.util.List.of("ActiveTarget"),
|
||||
@@ -293,8 +293,8 @@ class AdminCharacterBookNativeTest {
|
||||
admin.allowed = true;
|
||||
|
||||
var console = new EquipmentAdminCommandNativeTest.Sender();
|
||||
assertTrue(fixture.command.tabComplete(console.port, "fantasystick",
|
||||
new String[]{"character", ""}).isEmpty());
|
||||
assertEquals(java.util.List.of("find"), fixture.command.tabComplete(console.port, "fstickadmin",
|
||||
new String[]{"character", ""}));
|
||||
fixture.command.execute(console.port, "fantasystick",
|
||||
new String[]{"character", "view", "ActiveTarget"});
|
||||
assertTrue(console.messages.getLast().contains("in-game administrator"));
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.event.inventory.InventoryAction;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.event.inventory.InventoryType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Registered confirmed reissue/revival through durable generation authority and native delivery. */
|
||||
class AdminCharacterRecoveryNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void confirmedLivingRecoveryRevokesOldSheetPreservesCharacterAndActivatesReplacement() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var administrator = actor(data, "Admin");
|
||||
var recipient = actor(data, "Recipient");
|
||||
var source = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(administrator.core.id, administrator.player);
|
||||
fixture.players.put(recipient.core.id, recipient.player);
|
||||
var storage = characters(fixture);
|
||||
var original = storage.renameCharacter(source.core.record.id(), "Recovered Hero")
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
var sheets = new games.dmg.fantasystick.items.CharacterSheets();
|
||||
var issued = sheets.create(original);
|
||||
var legacy = issued.clone();
|
||||
var legacyMeta = legacy.getItemMeta();
|
||||
legacyMeta.getPersistentDataContainer().remove(
|
||||
new org.bukkit.NamespacedKey("fantasystick", "sheet_generation"));
|
||||
legacy.setItemMeta(legacyMeta);
|
||||
assertTrue(sheets.authorizes(legacy, original));
|
||||
var malformed = issued.clone();
|
||||
var malformedMeta = malformed.getItemMeta();
|
||||
malformedMeta.getPersistentDataContainer().set(
|
||||
new org.bukkit.NamespacedKey("fantasystick", "sheet_generation"),
|
||||
org.bukkit.persistence.PersistentDataType.STRING, "not-a-generation");
|
||||
malformed.setItemMeta(malformedMeta);
|
||||
assertFalse(sheets.authorizes(malformed, original));
|
||||
var oldSheet = legacy;
|
||||
int destination = recipient.core.inventory.firstEmpty();
|
||||
|
||||
assertTrue(fixture.adminCommand.execute(administrator.player, "fstickadmin",
|
||||
new String[]{"character", "find", "Recovered"}));
|
||||
drain(fixture, recipient.core.id);
|
||||
assertEquals(java.util.List.of(original.id().toString()), fixture.adminCommand.tabComplete(
|
||||
administrator.player, "fstickadmin", new String[]{"character", "recover", original.id().toString().substring(0, 8)}));
|
||||
assertEquals(java.util.List.of("Recipient"), fixture.adminCommand.tabComplete(
|
||||
administrator.player, "fstickadmin", new String[]{"character", "recover", original.id().toString(), "Rec"}));
|
||||
administrator.allowed = false;
|
||||
assertTrue(fixture.adminCommand.tabComplete(administrator.player, "fstickadmin",
|
||||
new String[]{"character", "recover", ""}).isEmpty());
|
||||
administrator.allowed = true;
|
||||
|
||||
assertTrue(fixture.adminCommand.execute(administrator.player, "fstickadmin",
|
||||
new String[]{"character", "recover", original.id().toString(), "Recipient"}));
|
||||
var recovery = FantasyStickLifecycleNativeTest.Fixture.field(
|
||||
fixture.adminCommand.getExecutor(), "characterRecovery");
|
||||
assertEquals(1, ((java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(
|
||||
recovery, "pending")).size());
|
||||
drain(fixture, recipient.core.id);
|
||||
assertEquals(1, administrator.inventories.size(), () -> administrator.messages + " pending="
|
||||
+ ((java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(recovery, "pending")).size()
|
||||
+ " tasks=" + fixture.tasks.size());
|
||||
var preview = administrator.inventories.getLast();
|
||||
assertEquals(Material.PAPER, preview.getItem(2).getType());
|
||||
assertEquals(Material.BARRIER, preview.getItem(6).getType());
|
||||
String previewText = String.valueOf(preview.getItem(2).getItemMeta().lore());
|
||||
assertTrue(previewText.contains(original.id().toString()));
|
||||
assertTrue(previewText.contains(recipient.core.id.toString()));
|
||||
click(fixture, administrator, preview, 2);
|
||||
recipient.core.inventory.setItemInOffHand(oldSheet);
|
||||
sessions(fixture).enter(recipient.player);
|
||||
assertTrue(sessions(fixture).active(recipient.core.id).isEmpty());
|
||||
assertTrue(recipient.messages.stream().anyMatch(message -> message.contains("recovery is still completing")));
|
||||
recipient.core.inventory.setItemInOffHand(null);
|
||||
drain(fixture, recipient.core.id);
|
||||
|
||||
var recovered = storage.inspect(original.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertEquals(original.id(), recovered.id());
|
||||
assertEquals(original.name(), recovered.name());
|
||||
assertEquals(original.stats(), recovered.stats());
|
||||
assertEquals(original.level(), recovered.level());
|
||||
assertEquals(original.experience(), recovered.experience());
|
||||
assertEquals(original.unspentPoints(), recovered.unspentPoints());
|
||||
assertEquals(original.health(), recovered.health());
|
||||
assertEquals(original.learned(), recovered.learned());
|
||||
assertEquals(original.loadout(), recovered.loadout());
|
||||
assertEquals(original.passives(), recovered.passives());
|
||||
assertEquals(original.cooldowns(), recovered.cooldowns());
|
||||
assertEquals(original.coins(), recovered.coins());
|
||||
assertEquals(original.gear(), recovered.gear());
|
||||
assertEquals(original.sheetGeneration().add(java.math.BigInteger.ONE), recovered.sheetGeneration());
|
||||
var replacement = recipient.core.inventory.getItem(destination);
|
||||
assertTrue(sheets.authorizes(replacement, recovered), () -> administrator.messages
|
||||
+ " item=" + replacement);
|
||||
assertFalse(sheets.authorizes(oldSheet, recovered));
|
||||
assertTrue(administrator.messages.stream().anyMatch(message -> message.contains("Older sheets are revoked")));
|
||||
|
||||
var ordinary = recipient.core.inventory.getItemInOffHand();
|
||||
recipient.core.inventory.setItemInOffHand(oldSheet);
|
||||
sessions(fixture).enter(recipient.player);
|
||||
drain(fixture, recipient.core.id);
|
||||
assertTrue(sessions(fixture).active(recipient.core.id).isEmpty());
|
||||
fixture.plugin.setEnabled(false);
|
||||
fixture.plugin.setEnabled(true);
|
||||
recipient.core.inventory.setItem(destination, null);
|
||||
recipient.core.inventory.setItemInOffHand(replacement);
|
||||
sessions(fixture).enter(recipient.player);
|
||||
drain(fixture, recipient.core.id);
|
||||
var active = sessions(fixture).active(recipient.core.id);
|
||||
assertTrue(active.isPresent(), () -> "messages=" + recipient.messages + " contents="
|
||||
+ java.util.Arrays.toString(recipient.core.inventory.getContents()));
|
||||
assertEquals(recovered.id(), active.orElseThrow().character());
|
||||
recipient.core.inventory.setItemInOffHand(ordinary);
|
||||
assertEquals(recovered.sheetGeneration(), new games.dmg.fantasystick.persistence.YamlCharacterRepository(data)
|
||||
.load(recovered.id()).orElseThrow().sheetGeneration());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void malformedConsoleFullInventoryClosedPreviewAndPermissionLossMutateNothing() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var administrator = actor(data, "Admin");
|
||||
var recipient = actor(data, "Recipient");
|
||||
var source = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(administrator.core.id, administrator.player);
|
||||
fixture.players.put(recipient.core.id, recipient.player);
|
||||
var storage = characters(fixture);
|
||||
var original = storage.inspect(source.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
|
||||
for (String[] arguments : java.util.List.of(
|
||||
new String[]{"character", "recover", "not-a-uuid", "Recipient"},
|
||||
new String[]{"character", "recover", original.id().toString()},
|
||||
new String[]{"character", "recover", original.id().toString(), "Recipient", "extra"}))
|
||||
assertTrue(fixture.adminCommand.execute(administrator.player, "fstickadmin", arguments));
|
||||
|
||||
var console = mock(org.bukkit.command.CommandSender.class);
|
||||
when(console.hasPermission(games.dmg.fantasystick.session.EquipmentAdminCommand.PERMISSION)).thenReturn(true);
|
||||
assertTrue(fixture.adminCommand.execute(console, "fstickadmin",
|
||||
new String[]{"character", "recover", original.id().toString(), "Recipient"}));
|
||||
verify(console).sendMessage(contains("in-game administrator"));
|
||||
|
||||
var reservation = new games.dmg.fantasystick.persistence.RevivalStore.Reservation(
|
||||
UUID.randomUUID(), UUID.randomUUID(), recipient.core.id, original.id(), UUID.randomUUID(),
|
||||
java.time.Instant.parse("2026-09-23T00:00:00Z"), 0, 1, -1, true);
|
||||
assertTrue(revivals(fixture).reserve(reservation).get(5, TimeUnit.SECONDS).isPresent());
|
||||
fixture.adminCommand.execute(administrator.player, "fstickadmin",
|
||||
new String[]{"character", "recover", original.id().toString(), "Recipient"});
|
||||
drain(fixture, recipient.core.id);
|
||||
assertTrue(administrator.messages.stream().anyMatch(message -> message.contains("custody must be resolved")));
|
||||
assertTrue(revivals(fixture).clear(original.id(), reservation.id()).get(5, TimeUnit.SECONDS));
|
||||
|
||||
for (int slot = 0; slot < 36; slot++)
|
||||
recipient.core.inventory.setItem(slot, new org.bukkit.inventory.ItemStack(Material.STONE));
|
||||
fixture.adminCommand.execute(administrator.player, "fstickadmin",
|
||||
new String[]{"character", "recover", original.id().toString(), "Recipient"});
|
||||
drain(fixture, recipient.core.id);
|
||||
assertTrue(administrator.inventories.isEmpty());
|
||||
assertTrue(administrator.messages.stream().anyMatch(message -> message.contains("empty main-inventory slot")));
|
||||
assertEquals(original, storage.inspect(original.id()).get(5, TimeUnit.SECONDS).orElseThrow());
|
||||
|
||||
recipient.core.inventory.setItem(1, null);
|
||||
fixture.adminCommand.execute(administrator.player, "fstickadmin",
|
||||
new String[]{"character", "recover", original.id().toString(), "Recipient"});
|
||||
drain(fixture, recipient.core.id);
|
||||
var preview = administrator.inventories.getLast();
|
||||
close(fixture, administrator, preview);
|
||||
var ignored = clickEvent(administrator, preview, 2);
|
||||
fixture.dispatcher.accept(ignored);
|
||||
assertFalse(ignored.isCancelled());
|
||||
assertEquals(original, storage.inspect(original.id()).get(5, TimeUnit.SECONDS).orElseThrow());
|
||||
|
||||
fixture.adminCommand.execute(administrator.player, "fstickadmin",
|
||||
new String[]{"character", "recover", original.id().toString(), "Recipient"});
|
||||
drain(fixture, recipient.core.id);
|
||||
administrator.allowed = false;
|
||||
click(fixture, administrator, administrator.inventories.getLast(), 2);
|
||||
drain(fixture, recipient.core.id);
|
||||
assertEquals(original, storage.inspect(original.id()).get(5, TimeUnit.SECONDS).orElseThrow());
|
||||
assertNull(recipient.core.inventory.getItem(1));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void activeCharacterCustodyRefusesRecoveryBeforePreview() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var administrator = actor(data, "Admin");
|
||||
var recipient = actor(data, "Recipient");
|
||||
var holder = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(administrator.core.id, administrator.player);
|
||||
fixture.players.put(recipient.core.id, recipient.player);
|
||||
fixture.players.put(holder.core.id, holder.player);
|
||||
fixture.activate(holder);
|
||||
|
||||
fixture.adminCommand.execute(administrator.player, "fstickadmin",
|
||||
new String[]{"character", "recover", holder.core.record.id().toString(), "Recipient"});
|
||||
drain(fixture, recipient.core.id);
|
||||
assertTrue(administrator.inventories.isEmpty());
|
||||
assertTrue(administrator.messages.stream().anyMatch(message -> message.contains("custody must be resolved")));
|
||||
assertEquals(java.math.BigInteger.ZERO, characters(fixture).inspect(holder.core.record.id())
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow().sheetGeneration());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void changedNativeDestinationAfterCommitRetainsUncertainCustodyWithoutBlindReplay() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var administrator = actor(data, "Admin");
|
||||
var recipient = actor(data, "Recipient");
|
||||
var source = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(administrator.core.id, administrator.player);
|
||||
fixture.players.put(recipient.core.id, recipient.player);
|
||||
var storage = characters(fixture);
|
||||
var original = storage.inspect(source.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
int destination = recipient.core.inventory.firstEmpty();
|
||||
|
||||
fixture.adminCommand.execute(administrator.player, "fstickadmin",
|
||||
new String[]{"character", "recover", original.id().toString(), "Recipient"});
|
||||
drain(fixture, recipient.core.id);
|
||||
click(fixture, administrator, administrator.inventories.getLast(), 2);
|
||||
awaitTask(fixture, recipient.core.id).run();
|
||||
awaitTask(fixture, recipient.core.id).run();
|
||||
characters(fixture).inspect(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
|
||||
recipient.core.inventory.setItem(destination, new org.bukkit.inventory.ItemStack(Material.DIAMOND));
|
||||
awaitTask(fixture, recipient.core.id).run();
|
||||
|
||||
var committed = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data)
|
||||
.load(original.id()).orElseThrow();
|
||||
assertEquals(original.sheetGeneration().add(java.math.BigInteger.ONE), committed.sheetGeneration());
|
||||
assertEquals(Material.DIAMOND, recipient.core.inventory.getItem(destination).getType());
|
||||
assertThrows(java.util.concurrent.CompletionException.class, () -> storage.find(original.id()).join());
|
||||
assertTrue(administrator.messages.stream().anyMatch(message -> message.contains("delivery is uncertain")));
|
||||
fixture.adminCommand.execute(administrator.player, "fstickadmin",
|
||||
new String[]{"character", "recover", original.id().toString(), "Recipient"});
|
||||
drain(fixture, recipient.core.id);
|
||||
assertTrue(administrator.messages.stream().anyMatch(message -> message.contains("custody must be resolved")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void explicitNoTailRecoveryRevivesSameDeathAtFullHealthWithoutReplayingConsequences() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var administrator = actor(data, "Admin");
|
||||
var recipient = actor(data, "Recipient");
|
||||
var source = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(administrator.core.id, administrator.player);
|
||||
fixture.players.put(recipient.core.id, recipient.player);
|
||||
var storage = characters(fixture);
|
||||
var dead = storage.recordDeath(source.core.record.id(), true).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
var oldTail = new games.dmg.fantasystick.items.TailBooks().create(dead);
|
||||
int destination = recipient.core.inventory.firstEmpty();
|
||||
|
||||
fixture.adminCommand.execute(administrator.player, "fstickadmin",
|
||||
new String[]{"character", "recover", dead.id().toString(), "Recipient"});
|
||||
drain(fixture, recipient.core.id);
|
||||
assertFalse(administrator.inventories.isEmpty(), administrator.messages::toString);
|
||||
var preview = administrator.inventories.getLast();
|
||||
assertEquals(Material.BARRIER, preview.getItem(2).getType());
|
||||
assertEquals(Material.TOTEM_OF_UNDYING, preview.getItem(6).getType());
|
||||
click(fixture, administrator, preview, 6);
|
||||
drain(fixture, recipient.core.id);
|
||||
|
||||
var revived = storage.inspect(dead.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertFalse(revived.dead());
|
||||
assertEquals(revived.maximumHealth(), revived.health());
|
||||
assertEquals(dead.id(), revived.id());
|
||||
assertEquals(dead.stats(), revived.stats());
|
||||
assertEquals(dead.coins(), revived.coins());
|
||||
assertEquals(java.math.BigInteger.ZERO, revived.pvpCoinEscrow());
|
||||
assertEquals(dead.deathId(), revived.deathId());
|
||||
assertTrue(new games.dmg.fantasystick.items.CharacterSheets()
|
||||
.authorizes(recipient.core.inventory.getItem(destination), revived));
|
||||
assertEquals(dead.deathId(), new games.dmg.fantasystick.items.TailBooks().identify(oldTail).orElseThrow().deathId());
|
||||
assertTrue(storage.restoreDeathRecordAdministratively(dead.id(), dead.deathId(), revived.maximumHealth())
|
||||
.get(5, TimeUnit.SECONDS).isEmpty(), "The old Tail death authority was consumed by the living state");
|
||||
var laterDeath = storage.recordDeath(revived.id(), false).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertNotEquals(dead.deathId(), laterDeath.deathId());
|
||||
assertTrue(storage.restoreDeathRecordAdministratively(dead.id(), dead.deathId(), revived.maximumHealth())
|
||||
.get(5, TimeUnit.SECONDS).isEmpty(), "An old Tail cannot revive a different later death");
|
||||
assertEquals(laterDeath.deathId(), storage.inspect(dead.id()).get(5, TimeUnit.SECONDS)
|
||||
.orElseThrow().deathId());
|
||||
}
|
||||
}
|
||||
|
||||
private static FantasyStickLifecycleNativeTest.Actor actor(Path data, String name) throws Exception {
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data); actor.accountName = name; return actor;
|
||||
}
|
||||
|
||||
private static void click(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor administrator, org.bukkit.inventory.Inventory inventory, int slot) {
|
||||
var click = clickEvent(administrator, inventory, slot);
|
||||
fixture.dispatcher.accept(click);
|
||||
assertTrue(click.isCancelled());
|
||||
}
|
||||
|
||||
private static InventoryClickEvent clickEvent(FantasyStickLifecycleNativeTest.Actor administrator,
|
||||
org.bukkit.inventory.Inventory inventory, int slot) {
|
||||
var view = view(administrator, inventory);
|
||||
return new InventoryClickEvent(view, InventoryType.SlotType.CONTAINER, slot,
|
||||
org.bukkit.event.inventory.ClickType.LEFT, InventoryAction.PICKUP_ALL);
|
||||
}
|
||||
|
||||
private static void close(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor administrator, org.bukkit.inventory.Inventory inventory) {
|
||||
fixture.dispatcher.accept(new org.bukkit.event.inventory.InventoryCloseEvent(view(administrator, inventory)));
|
||||
}
|
||||
|
||||
private static org.bukkit.inventory.InventoryView view(
|
||||
FantasyStickLifecycleNativeTest.Actor administrator, org.bukkit.inventory.Inventory inventory) {
|
||||
var view = mock(org.bukkit.inventory.InventoryView.class);
|
||||
when(view.getTopInventory()).thenReturn(inventory);
|
||||
when(view.getBottomInventory()).thenReturn(administrator.core.inventory);
|
||||
when(view.getPlayer()).thenReturn(administrator.player);
|
||||
return view;
|
||||
}
|
||||
|
||||
private static Runnable awaitTask(FantasyStickLifecycleNativeTest.Fixture fixture, UUID recipient) throws Exception {
|
||||
for (int pass = 0; pass < 100; pass++) {
|
||||
characters(fixture).inspect(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
|
||||
fixture.receipts().find(recipient).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
revivals(fixture).find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
|
||||
var callback = fixture.tasks.poll();
|
||||
if (callback != null) return callback;
|
||||
Thread.sleep(5);
|
||||
}
|
||||
throw new AssertionError("Expected a deferred recovery callback");
|
||||
}
|
||||
|
||||
private static void drain(FantasyStickLifecycleNativeTest.Fixture fixture, UUID recipient) throws Exception {
|
||||
for (int pass = 0; pass < 40; pass++) {
|
||||
characters(fixture).inspect(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
|
||||
fixture.receipts().find(recipient).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
revivals(fixture).find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
|
||||
Runnable callback;
|
||||
boolean ran = false;
|
||||
while ((callback = fixture.tasks.poll()) != null) { callback.run(); ran = true; }
|
||||
if (!ran) Thread.sleep(5);
|
||||
}
|
||||
}
|
||||
|
||||
private static games.dmg.fantasystick.persistence.CharacterStorage characters(
|
||||
FantasyStickLifecycleNativeTest.Fixture fixture) {
|
||||
return (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
|
||||
}
|
||||
private static games.dmg.fantasystick.persistence.RevivalStore revivals(
|
||||
FantasyStickLifecycleNativeTest.Fixture fixture) {
|
||||
return (games.dmg.fantasystick.persistence.RevivalStore)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "revivals");
|
||||
}
|
||||
private static games.dmg.fantasystick.session.EquipmentSessions sessions(
|
||||
FantasyStickLifecycleNativeTest.Fixture fixture) {
|
||||
return (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestController;
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestDirectory;
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestState;
|
||||
import games.dmg.fantasystick.dungeon.DungeonDirectory;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
final class AdventureParticipationModeNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void registeredSiteAndDungeonCommandsPersistReportUpdateAndCompleteModes() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "adventure-daily-site-count: 0\n");
|
||||
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");
|
||||
when(admin.world.getUID()).thenReturn(UUID.randomUUID());
|
||||
|
||||
assertEquals(List.of("--mode"), f.command.tabComplete(admin.player, "fantasystick",
|
||||
new String[]{"adventure-site", "place", "camp", "--"}));
|
||||
assertEquals(List.of("individual-only"), f.command.tabComplete(admin.player, "fantasystick",
|
||||
new String[]{"adventure-site", "place", "camp", "--mode", "i"}));
|
||||
var adventures = (AdventureQuestDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,
|
||||
"adventureQuests");
|
||||
f.command.execute(admin.player, "fantasystick", new String[]{"adventure-site", "place", "camp",
|
||||
"--mode", "individual-only", "10", "Solo Camp"});
|
||||
await(f, () -> !adventures.snapshot().sites().isEmpty());
|
||||
var site = adventures.snapshot().sites().getFirst();
|
||||
assertEquals(AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY, site.participationMode());
|
||||
assertTrue(admin.messages.getLast().contains("mode=individual-only"));
|
||||
f.command.execute(admin.player, "fantasystick", new String[]{"adventure-site", "list"});
|
||||
assertTrue(admin.messages.getLast().contains("mode=individual-only"));
|
||||
assertEquals(List.of(site.id().toString()), f.command.tabComplete(admin.player, "fantasystick",
|
||||
new String[]{"adventure-site", "mode", ""}));
|
||||
assertEquals(List.of("party-available", "party-required"), f.command.tabComplete(admin.player,
|
||||
"fantasystick", new String[]{"adventure-site", "mode", site.id().toString(), "party-"}));
|
||||
f.command.execute(admin.player, "fantasystick", new String[]{"adventure-site", "mode",
|
||||
site.id().toString()});
|
||||
assertTrue(admin.messages.getLast().contains("individual-only"));
|
||||
f.command.execute(admin.player, "fantasystick", new String[]{"adventure-site", "mode",
|
||||
site.id().toString(), "party-required"});
|
||||
drain(f);
|
||||
assertEquals(AdventureQuestState.ParticipationMode.PARTY_REQUIRED,
|
||||
adventures.findSite(site.id()).orElseThrow().participationMode());
|
||||
int count = adventures.snapshot().sites().size();
|
||||
f.command.execute(admin.player, "fantasystick", new String[]{"adventure-site", "place", "camp",
|
||||
"--mode", "invalid", "Broken"});
|
||||
assertEquals(count, adventures.snapshot().sites().size());
|
||||
|
||||
assertEquals(List.of("--mode"), f.command.tabComplete(admin.player, "fantasystick",
|
||||
new String[]{"dungeon", "create", "--"}));
|
||||
var dungeons = (DungeonDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "dungeons");
|
||||
f.command.execute(admin.player, "fantasystick", new String[]{"dungeon", "create", "--mode",
|
||||
"party-required", "Mode Vault"});
|
||||
await(f, () -> !dungeons.snapshot().dungeons().isEmpty());
|
||||
var dungeon = dungeons.snapshot().dungeons().getFirst();
|
||||
assertEquals(AdventureQuestState.ParticipationMode.PARTY_REQUIRED, dungeon.participationMode());
|
||||
assertEquals(List.of(dungeon.id().toString()), f.command.tabComplete(admin.player, "fantasystick",
|
||||
new String[]{"dungeon", "mode", ""}));
|
||||
f.command.execute(admin.player, "fantasystick", new String[]{"dungeon", "mode",
|
||||
dungeon.id().toString(), "party-available"});
|
||||
drain(f);
|
||||
assertEquals(AdventureQuestState.ParticipationMode.PARTY_AVAILABLE,
|
||||
dungeons.find(dungeon.id()).orElseThrow().participationMode());
|
||||
f.command.execute(admin.player, "fantasystick", new String[]{"dungeon", "mode",
|
||||
dungeon.id().toString(), "party-required"}); drain(f);
|
||||
var room = dungeons.addRadiusRoom(dungeon.id(), "Boss", 0, 64, 0, 2, 3)
|
||||
.get(5, TimeUnit.SECONDS);
|
||||
dungeons.setBoss(dungeon.id(), room.id(), org.bukkit.entity.EntityType.HUSK, "Keeper",
|
||||
0, 65, 0, 0, 0).get(5, TimeUnit.SECONDS);
|
||||
dungeons.setCompletionReward(dungeon.id(), java.math.BigInteger.ZERO, java.math.BigInteger.ZERO)
|
||||
.get(5, TimeUnit.SECONDS);
|
||||
f.command.execute(admin.player, "fantasystick", new String[]{"dungeon", "enable",
|
||||
dungeon.id().toString()}); drain(f);
|
||||
f.command.execute(admin.player, "fantasystick", new String[]{"dungeon", "open",
|
||||
dungeon.id().toString()}); drain(f);
|
||||
assertEquals(AdventureQuestState.ParticipationMode.PARTY_REQUIRED,
|
||||
adventures.findSite(dungeon.id()).orElseThrow().participationMode());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void questGiverLabelsAndExplicitlyAcceptsPersonalOrPartyForm() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "adventure-daily-site-count: 3\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);
|
||||
UUID 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 personal = adventures.placeSite(AdventureQuestState.SiteType.CAMP, "Flexible Camp", world,
|
||||
0, 64, 0, null, AdventureQuestState.ParticipationMode.PARTY_AVAILABLE).get(5, TimeUnit.SECONDS);
|
||||
var required = adventures.placeSite(AdventureQuestState.SiteType.CHAMPION, "Party Summit", world,
|
||||
20, 64, 0, null, AdventureQuestState.ParticipationMode.PARTY_REQUIRED).get(5, TimeUnit.SECONDS);
|
||||
var individual = adventures.placeSite(AdventureQuestState.SiteType.CAMP, "Solo Camp", world,
|
||||
40, 64, 0, null, AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY).get(5, TimeUnit.SECONDS);
|
||||
adventures.ensureToday().get(5, TimeUnit.SECONDS);
|
||||
var controller = (AdventureQuestController) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,
|
||||
"adventureQuestController");
|
||||
|
||||
controller.open(member.player); drain(f);
|
||||
var memberMenu = member.inventories.getLast();
|
||||
var personalItem = java.util.stream.IntStream.range(0, memberMenu.getSize())
|
||||
.mapToObj(memberMenu::getItem).filter(java.util.Objects::nonNull)
|
||||
.filter(item -> PlainTextComponentSerializer.plainText().serialize(item.getItemMeta().displayName())
|
||||
.equals("Flexible Camp")).findFirst().orElseThrow();
|
||||
var lore = personalItem.getItemMeta().lore().stream()
|
||||
.map(PlainTextComponentSerializer.plainText()::serialize).toList();
|
||||
assertTrue(lore.contains("Mode: party-available"));
|
||||
assertTrue(lore.stream().anyMatch(line -> line.contains("Left click: Accept individually")));
|
||||
var personalClick = mock(InventoryClickEvent.class);
|
||||
when(personalClick.getWhoClicked()).thenReturn(member.player);
|
||||
when(personalClick.getInventory()).thenReturn(memberMenu);
|
||||
when(personalClick.getCurrentItem()).thenReturn(personalItem);
|
||||
controller.click(personalClick); drain(f);
|
||||
var personalQuest = adventures.activeFor(member.core.record.id(), party.id()).orElseThrow();
|
||||
assertEquals(AdventureQuestState.UnitType.SOLO, personalQuest.unitType());
|
||||
assertEquals(Set.of(member.core.record.id()), personalQuest.snapshot());
|
||||
assertTrue(adventures.abandon(personalQuest.id(), personalQuest.unit()).get(5, TimeUnit.SECONDS));
|
||||
|
||||
controller.open(member.player); drain(f);
|
||||
var memberIndividualMenu = member.inventories.getLast();
|
||||
var individualItem = offer(memberIndividualMenu, "Solo Camp");
|
||||
click(controller, member, memberIndividualMenu, individualItem, false); drain(f);
|
||||
var memberIndividual = adventures.activeFor(member.core.record.id(), party.id()).orElseThrow();
|
||||
assertEquals(individual.id(), memberIndividual.site());
|
||||
assertEquals(AdventureQuestState.UnitType.SOLO, memberIndividual.unitType());
|
||||
assertTrue(adventures.abandon(memberIndividual.id(), memberIndividual.unit()).get(5, TimeUnit.SECONDS));
|
||||
click(controller, member, memberIndividualMenu, individualItem, true);
|
||||
assertTrue(member.messages.getLast().contains("individual-only"));
|
||||
var memberRequiredItem = offer(memberIndividualMenu, "Party Summit");
|
||||
click(controller, member, memberIndividualMenu, memberRequiredItem, false);
|
||||
assertTrue(member.messages.getLast().contains("party-required"));
|
||||
click(controller, member, memberIndividualMenu, memberRequiredItem, true);
|
||||
assertTrue(member.messages.getLast().contains("Only the active party leader"));
|
||||
|
||||
var partyless = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
partyless.core.location.setWorld(leader.world); f.players.put(partyless.core.id, partyless.player);
|
||||
f.activate(partyless); controller.open(partyless.player); drain(f);
|
||||
var partylessMenu = partyless.inventories.getLast();
|
||||
click(controller, partyless, partylessMenu, offer(partylessMenu, "Solo Camp"), false); drain(f);
|
||||
var partylessQuest = adventures.activeFor(partyless.core.record.id(), null).orElseThrow();
|
||||
assertEquals(AdventureQuestState.UnitType.SOLO, partylessQuest.unitType());
|
||||
assertTrue(adventures.abandon(partylessQuest.id(), partylessQuest.unit()).get(5, TimeUnit.SECONDS));
|
||||
var partylessRequired = offer(partylessMenu, "Party Summit");
|
||||
click(controller, partyless, partylessMenu, partylessRequired, false);
|
||||
assertTrue(partyless.messages.getLast().contains("party-required"));
|
||||
click(controller, partyless, partylessMenu, partylessRequired, true);
|
||||
assertTrue(partyless.messages.getLast().contains("Only the active party leader"));
|
||||
|
||||
controller.open(leader.player); drain(f);
|
||||
var leaderIndividualMenu = leader.inventories.getLast();
|
||||
var leaderIndividualItem = offer(leaderIndividualMenu, "Solo Camp");
|
||||
click(controller, leader, leaderIndividualMenu, leaderIndividualItem, false); drain(f);
|
||||
var leaderIndividual = adventures.activeFor(leader.core.record.id(), party.id()).orElseThrow();
|
||||
assertEquals(AdventureQuestState.UnitType.SOLO, leaderIndividual.unitType());
|
||||
assertTrue(adventures.abandon(leaderIndividual.id(), leaderIndividual.unit()).get(5, TimeUnit.SECONDS));
|
||||
click(controller, leader, leaderIndividualMenu, leaderIndividualItem, true);
|
||||
assertTrue(leader.messages.getLast().contains("individual-only"));
|
||||
|
||||
controller.open(leader.player); drain(f);
|
||||
var leaderMenu = leader.inventories.getLast();
|
||||
var requiredItem = java.util.stream.IntStream.range(0, leaderMenu.getSize())
|
||||
.mapToObj(leaderMenu::getItem).filter(java.util.Objects::nonNull)
|
||||
.filter(item -> PlainTextComponentSerializer.plainText().serialize(item.getItemMeta().displayName())
|
||||
.equals("Party Summit")).findFirst().orElseThrow();
|
||||
var requiredLore = requiredItem.getItemMeta().lore().stream()
|
||||
.map(PlainTextComponentSerializer.plainText()::serialize).toList();
|
||||
assertTrue(requiredLore.contains("Mode: party-required"));
|
||||
assertTrue(requiredLore.stream().anyMatch(line -> line.contains("Right click: Accept for party")));
|
||||
var partyClick = mock(InventoryClickEvent.class);
|
||||
when(partyClick.getWhoClicked()).thenReturn(leader.player);
|
||||
when(partyClick.getInventory()).thenReturn(leaderMenu);
|
||||
when(partyClick.getCurrentItem()).thenReturn(requiredItem);
|
||||
when(partyClick.isRightClick()).thenReturn(true);
|
||||
controller.click(partyClick);
|
||||
assertTrue(parties.disband(leader.core.id, party.id()), "party changes before acceptance publication");
|
||||
await(f, () -> leader.messages.stream().anyMatch(message -> message.contains("character or party changed")));
|
||||
assertTrue(adventures.active().isEmpty());
|
||||
|
||||
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()));
|
||||
controller.click(partyClick);
|
||||
UUID acceptedParty = party.id();
|
||||
await(f, () -> adventures.activeFor(leader.core.record.id(), acceptedParty).isPresent());
|
||||
var partyQuest = adventures.activeFor(leader.core.record.id(), acceptedParty).orElseThrow();
|
||||
assertEquals(AdventureQuestState.UnitType.PARTY, partyQuest.unitType());
|
||||
assertEquals(Set.of(leader.core.record.id(), member.core.record.id()), partyQuest.snapshot());
|
||||
assertEquals(AdventureQuestState.ParticipationMode.PARTY_REQUIRED,
|
||||
partyQuest.participationMode());
|
||||
}
|
||||
}
|
||||
|
||||
private static org.bukkit.inventory.ItemStack offer(org.bukkit.inventory.Inventory menu, String name) {
|
||||
return java.util.stream.IntStream.range(0, menu.getSize()).mapToObj(menu::getItem)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.filter(item -> PlainTextComponentSerializer.plainText().serialize(item.getItemMeta().displayName())
|
||||
.equals(name)).findFirst().orElseThrow();
|
||||
}
|
||||
private static void click(AdventureQuestController controller, FantasyStickLifecycleNativeTest.Actor actor,
|
||||
org.bukkit.inventory.Inventory menu, org.bukkit.inventory.ItemStack item,
|
||||
boolean right) {
|
||||
var event = mock(InventoryClickEvent.class);
|
||||
when(event.getWhoClicked()).thenReturn(actor.player); when(event.getInventory()).thenReturn(menu);
|
||||
when(event.getCurrentItem()).thenReturn(item); when(event.isRightClick()).thenReturn(right);
|
||||
controller.click(event);
|
||||
}
|
||||
private static void drain(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
|
||||
for (int attempt = 0; attempt < 20; attempt++) {
|
||||
var task = f.tasks.poll(50, TimeUnit.MILLISECONDS);
|
||||
if (task == null) break;
|
||||
task.run();
|
||||
}
|
||||
}
|
||||
private static void await(FantasyStickLifecycleNativeTest.Fixture f,
|
||||
java.util.function.BooleanSupplier condition) throws Exception {
|
||||
for (int attempt = 0; attempt < 50 && !condition.getAsBoolean(); attempt++) {
|
||||
drain(f); Thread.sleep(5);
|
||||
}
|
||||
assertTrue(condition.getAsBoolean());
|
||||
}
|
||||
}
|
||||
@@ -123,7 +123,7 @@ class AdventureQuestNativeTest {
|
||||
assertEquals(1,directory.snapshot().sites().size());assertEquals(1,directory.snapshot().givers().size());
|
||||
assertEquals(List.of("camp","champion"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure-site","place","c"}));
|
||||
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 controller=(games.dmg.fantasystick.adventure.AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");var trophies=(games.dmg.fantasystick.adventure.FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");controller.reconcile(chunk);
|
||||
var giver=spawned.stream().filter(Villager.class::isInstance).map(Villager.class::cast).findFirst().orElseThrow();verify(giver,atLeastOnce()).setAI(false);verify(giver,atLeastOnce()).setGravity(false);verify(giver,atLeastOnce()).setInvulnerable(true);verify(giver,atLeastOnce()).setCollidable(false);
|
||||
f.activate(actor);int books=actor.books.size();var interaction=new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.HAND);f.dispatcher.accept(interaction);drainOne(f);assertTrue(interaction.isCancelled());assertEquals(books,actor.books.size());assertFalse(actor.inventories.isEmpty());int menus=actor.inventories.size();var duplicate=new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.OFF_HAND);f.dispatcher.accept(duplicate);assertTrue(duplicate.isCancelled());assertEquals(menus,actor.inventories.size());
|
||||
var menu=actor.inventories.getLast();assertEquals(Material.PAPER,menu.getItem(0).getType());assertFalse(menu.getItem(0).getItemMeta().hasEnchants());assertNull(menu.getItem(0).getItemMeta().getItemModel());assertFalse(menu.getItem(0).getItemMeta().lore().stream().map(Object::toString).anyMatch(line->line.contains("0.0")),"Coordinates stay hidden before acceptance");var originalCatalog=directory.snapshot().catalog();
|
||||
@@ -135,7 +135,7 @@ class AdventureQuestNativeTest {
|
||||
assertTrue(actor.messages.stream().anyMatch(message -> message.equals(
|
||||
"Accepted Dawn Camp; expires " + directory.active().getFirst().expiresAt()
|
||||
+ ". Follow the Quest Compass.")));
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"adventure"});
|
||||
f.playerCommand.execute(actor.player,"fstick",new String[]{"adventure"});
|
||||
assertEquals("Dawn Camp; state=waiting; expires "+directory.active().getFirst().expiresAt()
|
||||
+"; Quest Compass guidance=on.",actor.messages.getLast());
|
||||
assertFalse(actor.messages.getLast().contains("(12, 70, -8)"));assertFalse(actor.messages.getLast().contains(worldId.toString()));
|
||||
@@ -145,17 +145,17 @@ class AdventureQuestNativeTest {
|
||||
|
||||
for(Runnable pulse:List.copyOf(f.pulses))pulse.run();for(int retry=0;retry<10&&spawned.stream().filter(entity->!(entity instanceof Villager)).count()<4;retry++)drainOne(f);
|
||||
var enemies=spawned.stream().filter(entity->!(entity instanceof Villager)).toList();assertEquals(4,enemies.size());assertTrue(controller.hasActiveEncounter());
|
||||
var enemy=enemies.getFirst();when(enemy.getLocation()).thenReturn(new Location(actor.world,siteRecord.x()+11,siteRecord.y(),siteRecord.z()));when(actor.world.getEntities()).thenReturn(new ArrayList<Entity>(spawned));for(Runnable pulse:List.copyOf(f.pulses))pulse.run();verify(enemy).teleport(argThat((Location location)->location.getX()==siteRecord.x()&&location.getY()==siteRecord.y()&&location.getZ()==siteRecord.z()));
|
||||
var enemy=enemies.getFirst();assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CAMP,controller.encounterOrigin(enemy).orElseThrow().kind());when(enemy.getLocation()).thenReturn(new Location(actor.world,siteRecord.x()+11,siteRecord.y(),siteRecord.z()));when(actor.world.getEntities()).thenReturn(new ArrayList<Entity>(spawned));for(Runnable pulse:List.copyOf(f.pulses))pulse.run();verify(enemy).teleport(argThat((Location location)->location.getX()==siteRecord.x()&&location.getY()==siteRecord.y()&&location.getZ()==siteRecord.z()));
|
||||
var outsider=new FantasyStickLifecycleNativeTest.Actor(data);outsider.core.location.setWorld(actor.world);f.players.put(outsider.core.id,outsider.player);var outsiderSource=mock(org.bukkit.damage.DamageSource.class);when(outsiderSource.getCausingEntity()).thenReturn(outsider.player);when(outsiderSource.getDirectEntity()).thenReturn(outsider.player);var refusedHit=new org.bukkit.event.entity.EntityDamageByEntityEvent(outsider.player,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,outsiderSource,1);f.dispatcher.accept(refusedHit);assertTrue(refusedHit.isCancelled());var enemySource=mock(org.bukkit.damage.DamageSource.class);when(enemySource.getCausingEntity()).thenReturn(enemy);when(enemySource.getDirectEntity()).thenReturn(enemy);var refusedAttack=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,outsider.player,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,enemySource,1);f.dispatcher.accept(refusedAttack);assertTrue(refusedAttack.isCancelled());var targetEvent=new org.bukkit.event.entity.EntityTargetLivingEntityEvent(enemy,outsider.player,org.bukkit.event.entity.EntityTargetEvent.TargetReason.CLOSEST_PLAYER);f.dispatcher.accept(targetEvent);assertTrue(targetEvent.isCancelled());
|
||||
var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);
|
||||
var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(actor.player,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1);f.dispatcher.accept(hit);assertFalse(hit.isCancelled());
|
||||
var attemptsField=controller.getClass().getDeclaredField("attempts");attemptsField.setAccessible(true);var attempts=(Map<?,?>)attemptsField.get(controller);assertEquals(1,attempts.size());var attempt=attempts.values().iterator().next();var representedField=attempt.getClass().getDeclaredField("represented");representedField.setAccessible(true);assertEquals(1,((Set<?>)representedField.get(attempt)).size());var recipientsMethod=controller.getClass().getDeclaredMethod("recipients",games.dmg.fantasystick.adventure.AdventureQuestState.Quest.class,UUID.class);recipientsMethod.setAccessible(true);var activeQuest=directory.active().getFirst();assertEquals(List.of(actor.core.record.id()),recipientsMethod.invoke(controller,activeQuest,activeQuest.site()));
|
||||
for(var value:enemies){var death=new org.bukkit.event.entity.EntityDeathEvent(value,source,new ArrayList<>(),7);f.dispatcher.accept(death);assertEquals(0,death.getDroppedExp());assertTrue(death.getDrops().isEmpty());}
|
||||
for(var value:enemies){var death=new org.bukkit.event.entity.EntityDeathEvent(value,source,new ArrayList<>(),7);f.dispatcher.accept(death);assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CAMP,controller.encounterOrigin(value).orElseThrow().kind(),"each processed death retains immutable managed origin authority");assertEquals(0,death.getDroppedExp());assertTrue(death.getDrops().isEmpty());}
|
||||
assertTrue(((Map<?,?>)attemptsField.get(controller)).isEmpty());
|
||||
for(int i=0;i<50&&(directory.active().size()!=1||directory.unclaimed(activeQuest.catalog(),activeQuest.site(),actor.core.record.id()).xp().signum()==0||actor.core.inventory.getItem(0).getType()!=Material.BOOK);i++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}
|
||||
var banked=storage.find(actor.core.record.id()).get().orElseThrow();assertEquals(java.math.BigInteger.ZERO,banked.experience());assertEquals(actorCoins,banked.coins());assertEquals(new games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward(java.math.BigInteger.valueOf(100),java.math.BigInteger.valueOf(20)),directory.unclaimed(activeQuest.catalog(),activeQuest.site(),actor.core.record.id()));assertEquals(Material.BOOK,actor.core.inventory.getItem(0).getType(),"completion restores the US-069 book control");assertEquals(1,directory.active().size(),"Unrepresented party retains its quest");assertEquals(java.math.BigInteger.ZERO,storage.find(partyA.core.record.id()).get().orElseThrow().experience());assertEquals(java.math.BigInteger.ZERO,storage.find(partyB.core.record.id()).get().orElseThrow().experience());
|
||||
int rewardMenuCount=actor.inventories.size();controller.open(actor.player);for(int retry=0;retry<10&&actor.inventories.size()==rewardMenuCount;retry++)drainOne(f);var rewardsMenu=actor.inventories.getLast();assertEquals(Material.PAPER,rewardsMenu.getItem(0).getType());assertTrue(rewardsMenu.getItem(0).getItemMeta().lore().stream().map(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText()::serialize).anyMatch(line->line.contains("Rewards ready")),rewardsMenu.getItem(0).getItemMeta().lore().toString());var rewardClick=mock(InventoryClickEvent.class);when(rewardClick.getWhoClicked()).thenReturn(actor.player);when(rewardClick.getInventory()).thenReturn(rewardsMenu);when(rewardClick.getCurrentItem()).thenReturn(rewardsMenu.getItem(0));controller.click(rewardClick);for(int i=0;i<50&&directory.unclaimed(activeQuest.catalog(),activeQuest.site(),actor.core.record.id()).xp().signum()>0;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();assertEquals(java.math.BigInteger.valueOf(100),saved.experience());assertEquals(actorCoins.add(java.math.BigInteger.valueOf(20)),saved.coins());assertTrue(directory.rewardClaimed(activeQuest.catalog(),activeQuest.site(),actor.core.record.id()),"durable reward receipt missing");
|
||||
int priorEnemies=enemies.size();for(Runnable pulse:List.copyOf(f.pulses))pulse.run();for(int retry=0;retry<10&&spawned.stream().filter(entity->!(entity instanceof Villager)).count()<priorEnemies+4;retry++)drainOne(f);var second=spawned.stream().filter(entity->!(entity instanceof Villager)).skip(priorEnemies).toList();assertEquals(4,second.size());var partySource=mock(org.bukkit.damage.DamageSource.class);when(partySource.getCausingEntity()).thenReturn(partyA.player);when(partySource.getDirectEntity()).thenReturn(partyA.player);var partyHit=new org.bukkit.event.entity.EntityDamageByEntityEvent(partyA.player,second.getFirst(),org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1);f.dispatcher.accept(partyHit);for(var value:second)f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(value,partySource,new ArrayList<>(),0));for(int i=0;i<50&&!directory.active().isEmpty();i++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}var savedA=storage.find(partyA.core.record.id()).get().orElseThrow();var savedB=storage.find(partyB.core.record.id()).get().orElseThrow();assertEquals(java.math.BigInteger.ZERO,savedA.experience());assertEquals(java.math.BigInteger.ZERO,savedB.experience());var pendingA=directory.unclaimed(catalog.revision(),siteId,partyA.core.record.id());var pendingB=directory.unclaimed(catalog.revision(),siteId,partyB.core.record.id());assertEquals(java.math.BigInteger.valueOf(100),pendingA.xp().add(pendingB.xp()));assertTrue(pendingA.xp().signum()>0&&pendingB.xp().signum()>0,"Present non-attacker receives an independent banked share");assertEquals(java.math.BigInteger.valueOf(20),pendingA.coins().add(pendingB.coins()));assertTrue(directory.active().isEmpty());for(var member:List.of(partyA,partyB)){int memberMenuCount=member.inventories.size();controller.open(member.player);for(int retry=0;retry<10&&member.inventories.size()==memberMenuCount;retry++)drainOne(f);var memberMenu=member.inventories.getLast();assertEquals(Material.PAPER,memberMenu.getItem(0).getType());var memberClick=mock(InventoryClickEvent.class);when(memberClick.getWhoClicked()).thenReturn(member.player);when(memberClick.getInventory()).thenReturn(memberMenu);when(memberClick.getCurrentItem()).thenReturn(memberMenu.getItem(0));controller.click(memberClick);for(int retry=0;retry<50&&directory.unclaimed(catalog.revision(),siteId,member.core.record.id()).xp().signum()>0;retry++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}}savedA=storage.find(partyA.core.record.id()).get().orElseThrow();savedB=storage.find(partyB.core.record.id()).get().orElseThrow();assertEquals(java.math.BigInteger.valueOf(100),savedA.experience().add(savedB.experience()));assertEquals(java.math.BigInteger.valueOf(20),savedA.coins().subtract(partyACoins).add(savedB.coins().subtract(partyBCoins)));var completedCatalog=directory.snapshot().catalog();assertFalse(directory.snapshot().rosters().isEmpty());f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll","confirm"});for(int retry=0;retry<15&&(directory.snapshot().catalog().revision().equals(completedCatalog.revision())||directory.snapshot().reset()!=null);retry++)drainOne(f);var replacement=directory.snapshot().catalog();assertNotEquals(completedCatalog.revision(),replacement.revision());assertTrue(directory.snapshot().rosters().isEmpty());assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Acceptance.APPLIED,directory.accept(replacement.revision(),replacement.sites().getFirst(),games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,actor.core.record.id(),Set.of(actor.core.record.id())).get(5,java.util.concurrent.TimeUnit.SECONDS).status());
|
||||
for(int i=0;i<50&&(directory.active().size()!=1||trophies.trophies(actor.core.record.id()).isEmpty()||actor.core.inventory.getItem(0).getType()!=Material.BOOK);i++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}
|
||||
var banked=storage.find(actor.core.record.id()).get().orElseThrow();assertEquals(java.math.BigInteger.ZERO,banked.experience());assertEquals(actorCoins,banked.coins());assertEquals(games.dmg.fantasystick.adventure.FishingState.TrophyType.CAMP,trophies.trophies(actor.core.record.id()).getFirst().type());assertEquals(Material.BOOK,actor.core.inventory.getItem(0).getType(),"completion restores the US-069 book control");assertEquals(1,directory.active().size(),"Unrepresented party retains its quest");assertEquals(java.math.BigInteger.ZERO,storage.find(partyA.core.record.id()).get().orElseThrow().experience());assertEquals(java.math.BigInteger.ZERO,storage.find(partyB.core.record.id()).get().orElseThrow().experience());
|
||||
var soloTrophy=trophies.trophies(actor.core.record.id()).getFirst();assertEquals("Spoils of Dawn Camp",soloTrophy.name());assertEquals("Dawn Camp",soloTrophy.origin());assertEquals(new games.dmg.fantasystick.adventure.FishingState.Reward(java.math.BigInteger.valueOf(100),java.math.BigInteger.valueOf(20)),soloTrophy.total());assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward.empty(),directory.unclaimedAll(actor.core.record.id()),"camp clear does not immediately unlock rewards");
|
||||
int priorEnemies=enemies.size();for(Runnable pulse:List.copyOf(f.pulses))pulse.run();for(int retry=0;retry<10&&spawned.stream().filter(entity->!(entity instanceof Villager)).count()<priorEnemies+4;retry++)drainOne(f);var second=spawned.stream().filter(entity->!(entity instanceof Villager)).skip(priorEnemies).toList();assertEquals(4,second.size());var partySource=mock(org.bukkit.damage.DamageSource.class);when(partySource.getCausingEntity()).thenReturn(partyA.player);when(partySource.getDirectEntity()).thenReturn(partyA.player);var partyHit=new org.bukkit.event.entity.EntityDamageByEntityEvent(partyA.player,second.getFirst(),org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1);f.dispatcher.accept(partyHit);partyA.core.location.setX(100);doReturn(false).when(partyA.player).isOnline();for(var value:second)f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(value,partySource,new ArrayList<>(),0));await(f,()->directory.active().isEmpty()&&!trophies.trophies(partyA.core.record.id()).isEmpty());var partyTrophy=trophies.trophies(partyA.core.record.id()).getFirst();assertEquals(games.dmg.fantasystick.adventure.FishingState.TrophyType.CAMP,partyTrophy.type());assertEquals(Set.of(partyB.core.record.id()),partyTrophy.rewards().keySet(),"absent leader carries the spoils without gaining a share");assertEquals(new games.dmg.fantasystick.adventure.FishingState.Reward(java.math.BigInteger.valueOf(100),java.math.BigInteger.valueOf(20)),partyTrophy.total());assertEquals(java.math.BigInteger.ZERO,storage.find(partyA.core.record.id()).get().orElseThrow().experience());assertEquals(java.math.BigInteger.ZERO,storage.find(partyB.core.record.id()).get().orElseThrow().experience());var completedCatalog=directory.snapshot().catalog();assertFalse(directory.snapshot().rosters().isEmpty());f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll","confirm"});for(int retry=0;retry<15&&(directory.snapshot().catalog().revision().equals(completedCatalog.revision())||directory.snapshot().reset()!=null);retry++)drainOne(f);var replacement=directory.snapshot().catalog();assertNotEquals(completedCatalog.revision(),replacement.revision());assertTrue(directory.snapshot().rosters().isEmpty());var turn=(games.dmg.fantasystick.adventure.FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");redeemAndClaim(f,turn,trophies,storage,actor,actorCoins,100,20);doReturn(true).when(partyA.player).isOnline();redeem(f,turn,partyA);claim(f,turn,trophies,storage,partyB,partyBCoins,100,20);assertEquals(partyACoins,storage.find(partyA.core.record.id()).get().orElseThrow().coins(),"carrier without a share receives no coins");assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Acceptance.APPLIED,directory.accept(replacement.revision(),replacement.sites().getFirst(),games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,actor.core.record.id(),Set.of(actor.core.record.id())).get(5,java.util.concurrent.TimeUnit.SECONDS).status());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,13 +394,13 @@ class AdventureQuestNativeTest {
|
||||
quest.unit().toString(), actor.core.record.id().toString())) {
|
||||
assertFalse(accepted.contains(hidden));
|
||||
}
|
||||
f.command.execute(actor.player, "fantasystick", new String[]{"adventure"});
|
||||
f.playerCommand.execute(actor.player, "fstick", new String[]{"adventure"});
|
||||
String inspected = actor.messages.getLast();
|
||||
assertEquals("Monster Camp; state=waiting; expires "+quest.expiresAt()+"; Quest Compass guidance=on.",inspected);
|
||||
for(String hidden:List.of("(12, -1, -9)",worldId.toString(),site.id().toString(),quest.id().toString()))assertFalse(inspected.contains(hidden));
|
||||
|
||||
when(actor.world.getUID()).thenReturn(UUID.randomUUID());
|
||||
f.command.execute(actor.player, "fantasystick", new String[]{"adventure"});
|
||||
f.playerCommand.execute(actor.player, "fstick", new String[]{"adventure"});
|
||||
assertEquals("Monster Camp; state=waiting; expires "+quest.expiresAt()+"; Quest Compass guidance=on.",actor.messages.getLast());
|
||||
assertFalse(actor.messages.getLast().contains(worldId.toString()));
|
||||
f.command.execute(actor.player, "fantasystick", new String[]{"adventure-site", "list"});
|
||||
@@ -461,15 +461,15 @@ class AdventureQuestNativeTest {
|
||||
characters.find(UUID.randomUUID()).get(5,java.util.concurrent.TimeUnit.SECONDS);for(int retry=0;retry<10&&actor.books.isEmpty();retry++)f.tick();assertEquals(1,actor.books.size());
|
||||
|
||||
doReturn(false).when(actor.player).hasPermission("fantasystick.admin");
|
||||
assertEquals(List.of("tracking"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure","t"}));
|
||||
assertEquals(List.of("off","on"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure","tracking",""}));
|
||||
assertTrue(f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure","tracking","on","extra"}).isEmpty());
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"adventure","tracking"});
|
||||
assertEquals(List.of("tracking"),f.playerCommand.tabComplete(actor.player,"fstick",new String[]{"adventure","t"}));
|
||||
assertEquals(List.of("off","on"),f.playerCommand.tabComplete(actor.player,"fstick",new String[]{"adventure","tracking",""}));
|
||||
assertTrue(f.playerCommand.tabComplete(actor.player,"fstick",new String[]{"adventure","tracking","on","extra"}).isEmpty());
|
||||
f.playerCommand.execute(actor.player,"fstick",new String[]{"adventure","tracking"});
|
||||
assertTrue(actor.messages.getLast().contains("on"));
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"adventure","tracking","off"});for(int retry=0;retry<10&&actor.core.inventory.getItem(0).getType()!=Material.BOOK;retry++)drainOne(f);
|
||||
f.playerCommand.execute(actor.player,"fstick",new String[]{"adventure","tracking","off"});for(int retry=0;retry<10&&actor.core.inventory.getItem(0).getType()!=Material.BOOK;retry++)drainOne(f);
|
||||
assertEquals(Material.BOOK,actor.core.inventory.getItem(0).getType());
|
||||
assertFalse(directory.trackingEnabled(quest.id(),actor.core.record.id()));
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"adventure","tracking","on"});for(int retry=0;retry<10&&actor.core.inventory.getItem(0).getType()!=Material.COMPASS;retry++)drainOne(f);
|
||||
f.playerCommand.execute(actor.player,"fstick",new String[]{"adventure","tracking","on"});for(int retry=0;retry<10&&actor.core.inventory.getItem(0).getType()!=Material.COMPASS;retry++)drainOne(f);
|
||||
assertEquals(Material.COMPASS,actor.core.inventory.getItem(0).getType());
|
||||
|
||||
actor.core.location.setX(site.x());actor.core.location.setY(site.y());actor.core.location.setZ(site.z());
|
||||
@@ -533,5 +533,11 @@ class AdventureQuestNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static void redeemAndClaim(FantasyStickLifecycleNativeTest.Fixture f,games.dmg.fantasystick.adventure.FishingController turn,games.dmg.fantasystick.adventure.FishingDirectory trophies,games.dmg.fantasystick.persistence.CharacterStorage storage,FantasyStickLifecycleNativeTest.Actor actor,java.math.BigInteger initialCoins,long xp,long coins)throws Exception{redeem(f,turn,actor);claim(f,turn,trophies,storage,actor,initialCoins,xp,coins);}
|
||||
private static void redeem(FantasyStickLifecycleNativeTest.Fixture f,games.dmg.fantasystick.adventure.FishingController turn,FantasyStickLifecycleNativeTest.Actor actor)throws Exception{turn.open(actor.player);var menu=actor.inventories.getLast();click(turn,actor,menu,item(menu,"Redeem Spoils of"));await(f,()->actor.inventories.size()>0&&menu!=actor.inventories.getLast());}
|
||||
private static void claim(FantasyStickLifecycleNativeTest.Fixture f,games.dmg.fantasystick.adventure.FishingController turn,games.dmg.fantasystick.adventure.FishingDirectory trophies,games.dmg.fantasystick.persistence.CharacterStorage storage,FantasyStickLifecycleNativeTest.Actor actor,java.math.BigInteger initialCoins,long xp,long coins)throws Exception{turn.open(actor.player);var menu=actor.inventories.getLast();click(turn,actor,menu,item(menu,"Claim earned quest rewards"));await(f,()->trophies.unclaimed(actor.core.record.id()).equals(games.dmg.fantasystick.adventure.FishingState.Reward.empty()));var saved=storage.find(actor.core.record.id()).get().orElseThrow();assertEquals(java.math.BigInteger.valueOf(xp),saved.experience());assertEquals(initialCoins.add(java.math.BigInteger.valueOf(coins)),saved.coins());}
|
||||
private static org.bukkit.inventory.ItemStack item(org.bukkit.inventory.Inventory menu,String prefix){var plain=net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText();return java.util.stream.IntStream.range(0,menu.getSize()).mapToObj(menu::getItem).filter(java.util.Objects::nonNull).filter(value->value.getItemMeta().displayName()!=null&&plain.serialize(value.getItemMeta().displayName()).startsWith(prefix)).findFirst().orElseThrow();}
|
||||
private static void click(games.dmg.fantasystick.adventure.FishingController turn,FantasyStickLifecycleNativeTest.Actor actor,org.bukkit.inventory.Inventory menu,org.bukkit.inventory.ItemStack item){var event=mock(InventoryClickEvent.class);when(event.getWhoClicked()).thenReturn(actor.player);when(event.getInventory()).thenReturn(menu);when(event.getCurrentItem()).thenReturn(item);turn.click(event);}
|
||||
private static void await(FantasyStickLifecycleNativeTest.Fixture f,java.util.function.BooleanSupplier condition)throws Exception{for(int retry=0;retry<80&&!condition.getAsBoolean();retry++){var task=f.tasks.poll(20,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(condition.getAsBoolean());}
|
||||
private static void drainOne(FantasyStickLifecycleNativeTest.Fixture f)throws Exception{var task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();}
|
||||
}
|
||||
@@ -37,12 +37,19 @@ class ChampionQuestNativeTest {
|
||||
assertEquals(List.of("champion"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure-site","place","ch"}));
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"config","adventure-champion-damage","9"});drainOne(f);assertEquals(List.of("8","9"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","adventure-champion-damage",""}));
|
||||
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 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));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 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();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);var death=new org.bukkit.event.entity.EntityDeathEvent(champion,source,new ArrayList<>(),9);f.dispatcher.accept(death);assertEquals(0,death.getDroppedExp());assertTrue(death.getDrops().isEmpty());for(int i=0;i<50&&(directory.active().size()!=1||directory.unclaimed(catalog.revision(),site,actor.core.record.id()).xp().add(directory.unclaimed(catalog.revision(),site,member.core.record.id()).xp()).signum()==0);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());var partyRewardA=directory.unclaimed(catalog.revision(),site,actor.core.record.id());var partyRewardB=directory.unclaimed(catalog.revision(),site,member.core.record.id());assertEquals(java.math.BigInteger.valueOf(150),partyRewardA.xp().add(partyRewardB.xp()));assertEquals(java.math.BigInteger.valueOf(40),partyRewardA.coins().add(partyRewardB.coins()));assertEquals(1,directory.active().size(),"Unrepresented solo quest remains");verify(f.bossBars.getFirst(),atLeastOnce()).removeAll();int memberParticlesAfterCompletion=member.particleCalls.size();
|
||||
for(Runnable pulse:List.copyOf(f.pulses))pulse.run();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));for(int i=0;i<50&&!directory.active().isEmpty();i++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}var savedSolo=storage.find(solo.core.record.id()).get().orElseThrow();assertEquals(soloInitial.experience(),savedSolo.experience());assertEquals(soloInitial.coins(),savedSolo.coins());assertEquals(new games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward(java.math.BigInteger.valueOf(150),java.math.BigInteger.valueOf(40)),directory.unclaimed(catalog.revision(),site,solo.core.record.id()));verify(f.bossBars.get(1),atLeastOnce()).removeAll();
|
||||
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();
|
||||
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));for(int i=0;i<50&&!directory.active().isEmpty();i++){var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(10);}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");
|
||||
}
|
||||
}
|
||||
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();}
|
||||
private static void click(games.dmg.fantasystick.adventure.FishingController controller,FantasyStickLifecycleNativeTest.Actor actor,org.bukkit.inventory.Inventory menu,org.bukkit.inventory.ItemStack item){var event=mock(InventoryClickEvent.class);when(event.getWhoClicked()).thenReturn(actor.player);when(event.getInventory()).thenReturn(menu);when(event.getCurrentItem()).thenReturn(item);controller.click(event);}
|
||||
private static void await(FantasyStickLifecycleNativeTest.Fixture f,java.util.function.BooleanSupplier condition)throws Exception{for(int attempt=0;attempt<80&&!condition.getAsBoolean();attempt++){var task=f.tasks.poll(20,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(condition.getAsBoolean());}
|
||||
private static void awaitPulse(FantasyStickLifecycleNativeTest.Fixture f,java.util.function.BooleanSupplier condition)throws Exception{for(int attempt=0;attempt<200&&!condition.getAsBoolean();attempt++){var task=f.tasks.poll(20,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();for(Runnable pulse:List.copyOf(f.pulses))pulse.run();Thread.sleep(5);}assertTrue(condition.getAsBoolean(),"Timed out waiting for the next champion encounter");}
|
||||
private static void drainOne(FantasyStickLifecycleNativeTest.Fixture f)throws Exception{var task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();}
|
||||
}
|
||||
@@ -225,6 +225,29 @@ class CharacterAnvilNamesNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleGenerationCannotRenameOrLaunderARecoveredCharacterSheet() throws Exception {
|
||||
var original = new CharacterFactory().create(ProgressionRules.defaults());
|
||||
var staleSheet = new CharacterSheets().create(original);
|
||||
var fixture = new AnvilFixture();
|
||||
fixture.grid.setItem(0, staleSheet);
|
||||
var output = named(staleSheet, Component.text("Laundered name"));
|
||||
var problems = new CopyOnWriteArrayList<CharacterAnvilNames.Failure>();
|
||||
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
|
||||
storage.create(original).join();
|
||||
var operation = java.util.UUID.randomUUID();
|
||||
var commit = storage.beginRecovery(new CharacterStorage.RecoveryRequest(operation, original.id(),
|
||||
java.util.UUID.randomUUID(), java.util.UUID.randomUUID(), original.revision(),
|
||||
original.sheetGeneration(), false, null, java.math.BigDecimal.valueOf(20))).join();
|
||||
storage.completeRecovery(original.id(), operation).join();
|
||||
listener(storage, problems).onTaken(new AnvilTakeResultEvent(fixture.actor.player, fixture.view, output));
|
||||
var current = storage.find(original.id()).join().orElseThrow();
|
||||
assertEquals(original.name(), current.name());
|
||||
assertEquals(java.math.BigInteger.ONE, current.sheetGeneration());
|
||||
assertEquals(1, problems.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearingACustomNameRespectsAnExplicitNativeItemNameComponent() throws Exception {
|
||||
var original = new CharacterFactory().create(ProgressionRules.defaults());
|
||||
|
||||
@@ -115,12 +115,16 @@ class CharacterSheetsNativeTest {
|
||||
assertEquals(List.of(Component.text("Character sheet:"), Component.text("Place in off hand"),
|
||||
Component.text("to enter fantasy world.")), sheet.getItemMeta().lore());
|
||||
assertEquals(record.id(), sheets.identify(sheet).orElseThrow());
|
||||
assertEquals(Set.of(new NamespacedKey("fantasystick", "sheet_version"), new NamespacedKey("fantasystick", "character_id")),
|
||||
sheet.getItemMeta().getPersistentDataContainer().getKeys(), "No player owner, equipment or progress snapshot belongs on the sheet");
|
||||
assertEquals(Set.of(new NamespacedKey("fantasystick", "sheet_version"),
|
||||
new NamespacedKey("fantasystick", "character_id"),
|
||||
new NamespacedKey("fantasystick", "sheet_generation")),
|
||||
sheet.getItemMeta().getPersistentDataContainer().getKeys(),
|
||||
"Only character identity and revocable issuance generation belong on the sheet");
|
||||
var ops = RegistryOps.create(NbtOps.INSTANCE, CraftRegistry.getMinecraftRegistry());
|
||||
var encoded = net.minecraft.world.item.ItemStack.CODEC.encodeStart(ops, CraftItemStack.asNMSCopy(sheet)).getOrThrow();
|
||||
var reloaded = CraftItemStack.asBukkitCopy(net.minecraft.world.item.ItemStack.CODEC.parse(ops, encoded).getOrThrow());
|
||||
assertEquals(record.id(), new CharacterSheets().identify(reloaded).orElseThrow());
|
||||
assertEquals(record.sheetGeneration(), new CharacterSheets().identity(reloaded).orElseThrow().generation());
|
||||
assertEquals(sheet, reloaded);
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,23 @@ class CombatExperienceNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void everyManagedEncounterOriginWaivesDistanceWithoutChangingRestrictedHistory() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),"hostile-coin-drop-min: 10\nhostile-coin-drop-max: 10\n");
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);var combat=(games.dmg.fantasystick.party.CombatExperience)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"combatExperience");
|
||||
for(var kind:games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.values()){
|
||||
var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);f.activate(actor);combat.attachManagedOrigins(entity->java.util.Optional.of(new games.dmg.fantasystick.progression.ManagedEncounterOrigin(kind,UUID.nameUUIDFromBytes(entity.getUniqueId().toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)))));var world=mock(org.bukkit.World.class);when(world.getUID()).thenReturn(UUID.randomUUID());var before=record(f,actor);var first=mob(EntityType.ZOMBIE,new Location(world,0,64,0));hit(f,actor.player,first,false);die(f,first);settle(f);var second=mob(EntityType.ZOMBIE,new Location(world,1,64,0));hit(f,actor.player,second,false);die(f,second);settle(f);var saved=record(f,actor);assertTrue(saved.experience().compareTo(before.experience())>0,kind.toString());assertEquals(before.coins().add(java.math.BigInteger.valueOf(20)),saved.coins(),kind.toString());assertEquals(before.restrictedKills(),saved.restrictedKills(),kind+" never consults or advances ordinary spacing history");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void repeatedManagedKillAuthorityCannotPayTwice() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),"hostile-coin-drop-min: 10\nhostile-coin-drop-max: 10\n");
|
||||
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 actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);f.activate(actor);var authority=UUID.randomUUID();combat.attachManagedOrigins(entity->java.util.Optional.of(new games.dmg.fantasystick.progression.ManagedEncounterOrigin(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,authority)));var world=mock(org.bukkit.World.class);when(world.getUID()).thenReturn(UUID.randomUUID());var before=record(f,actor);for(int attempt=0;attempt<2;attempt++){var target=mob(EntityType.ZOMBIE,new Location(world,0,64,0));hit(f,actor.player,target,false);die(f,target);settle(f);}var saved=record(f,actor);assertEquals(before.experience().add(java.math.BigInteger.TEN),saved.experience());assertEquals(before.coins().add(java.math.BigInteger.TEN),saved.coins());assertEquals(before.restrictedKills(),saved.restrictedKills());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void everyRestrictedMobCoinClassUsesTheExistingExactAndCrossWorldSpacingGeometry() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"hostile-coin-drop-min: 12\nhostile-coin-drop-max: 12\n"
|
||||
@@ -353,6 +370,7 @@ class CombatExperienceNativeTest {
|
||||
games.dmg.fantasystick.progression.CombatRewardRules.defaults(), sessions,
|
||||
java.time.Clock.fixed(java.time.Instant.parse("2026-09-19T20:00:00Z"), java.time.ZoneOffset.UTC), Runnable::run,
|
||||
ignored -> attacker, failures::add);
|
||||
var authority=new games.dmg.fantasystick.progression.ManagedEncounterOrigin(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,UUID.randomUUID());var settled=new ArrayList<games.dmg.fantasystick.progression.ManagedEncounterOrigin>();service.attachManagedOrigins(ignored->java.util.Optional.of(authority));service.attachManagedSettlement(settled::add);
|
||||
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
var target = mob(EntityType.WITCH, new Location(world, 0, 64, 0));
|
||||
var damageSource = mock(org.bukkit.damage.DamageSource.class);
|
||||
@@ -360,11 +378,11 @@ class CombatExperienceNativeTest {
|
||||
service.damage(new EntityDamageByEntityEvent(attacker, target, DamageCause.ENTITY_ATTACK, damageSource, 1));
|
||||
service.mobDeath(new org.bukkit.event.entity.EntityDeathEvent(target, damageSource, new ArrayList<>(), 0));
|
||||
verify(storage, times(1)).awardCombatReward(eq(original.id()), any(), any(), any(), any(), any(), any(), any());
|
||||
assertEquals(1, failures.size()); verify(attacker, never()).sendMessage(anyString());
|
||||
assertEquals(1, failures.size());assertTrue(settled.isEmpty(),"slot authority remains unsettled while reward persistence is uncertain");verify(attacker, never()).sendMessage(anyString());
|
||||
service.configure(service.rules().withHardPool(java.math.BigInteger.valueOf(25)));
|
||||
service.retryPending();
|
||||
verify(storage, times(2)).awardCombatReward(eq(original.id()), any(), any(), any(), any(), any(), any(), any());
|
||||
verify(attacker, times(1)).sendMessage(contains("character coins"));
|
||||
verify(attacker, times(1)).sendMessage(contains("character coins"));assertEquals(java.util.List.of(authority),settled,"slot defeat may publish only after the captured payout is durable");
|
||||
service.mobDeath(new org.bukkit.event.entity.EntityDeathEvent(target, damageSource, new ArrayList<>(), 0));
|
||||
service.retryPending();
|
||||
verify(storage, times(2)).awardCombatReward(eq(original.id()), any(), any(), any(), any(), any(), any(), any());
|
||||
|
||||
@@ -34,6 +34,7 @@ class DefeatHealthDeliveryNativeTest {
|
||||
if (a.nativePlayer.queuedHealthUpdatePacket != null) sent.add(a.nativePlayer.queuedHealthUpdatePacket);
|
||||
a.nativePlayer.queueHealthUpdatePacket = false;
|
||||
PartyLifecycleNativeTest.directory(f).defeat(java.util.UUID.randomUUID()).join();
|
||||
f.awaitCharacterAndReceipt(a);
|
||||
PartyBookNativeTest.drain(f);
|
||||
assertTrue(death.isCancelled());
|
||||
assertEquals(a.maximumHealth.getValue(), a.player.getHealth());
|
||||
|
||||
@@ -39,6 +39,7 @@ class DungeonNativeTest {
|
||||
var enabled=directory.create("Enabled Depths",targetWorld,-20.5,55.25,-33.75,-135f,22f).get(5,TimeUnit.SECONDS);
|
||||
var room=directory.addRadiusRoom(enabled.id(),"Boss",-20,54,-34,2,3).get(5,TimeUnit.SECONDS);
|
||||
directory.setBoss(enabled.id(),room.id(),EntityType.HUSK,"Keeper",-20,55,-34,0,0).get(5,TimeUnit.SECONDS);
|
||||
directory.setCompletionReward(enabled.id(),BigInteger.ZERO,BigInteger.ZERO).get(5,TimeUnit.SECONDS);
|
||||
enabled=directory.enable(enabled.id(),List.of()).get(5,TimeUnit.SECONDS);
|
||||
var before=directory.snapshot();var teleports=new ArrayList<Location>();
|
||||
doReturn(java.util.concurrent.CompletableFuture.completedFuture(mock(Chunk.class))).when(destination.world).getChunkAtAsync(anyInt(),anyInt());
|
||||
@@ -60,30 +61,30 @@ class DungeonNativeTest {
|
||||
}
|
||||
|
||||
@SuppressWarnings({"unchecked","rawtypes"})
|
||||
@Test void registeredAuthoringQuestAcceptanceRoomRewardAndDirectBossClearAreConnected() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\n");
|
||||
@Test void registeredAuthoringKillRewardsAndCompletionTrophiesAreConnected() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\nhostile-coin-drop-min: 6\nhostile-coin-drop-max: 6\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);doReturn(true).when(actor.player).hasPermission("fantasystick.admin");
|
||||
var actorEffects=new HashMap<org.bukkit.potion.PotionEffectType,org.bukkit.potion.PotionEffect>();doAnswer(call->Set.copyOf(actorEffects.values())).when(actor.player).getActivePotionEffects();doAnswer(call->{var effect=(org.bukkit.potion.PotionEffect)call.getArgument(0);actorEffects.put(effect.getType(),effect);return true;}).when(actor.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());doAnswer(call->{actorEffects.remove((org.bukkit.potion.PotionEffectType)call.getArgument(0));return null;}).when(actor.player).removePotionEffect(any(org.bukkit.potion.PotionEffectType.class));UUID worldId=UUID.randomUUID();when(actor.world.getUID()).thenReturn(worldId);when(actor.world.getName()).thenReturn("Dungeon Realm");when(actor.world.isChunkLoaded(anyInt(),anyInt())).thenReturn(true);
|
||||
var spawned=new ArrayList<LivingEntity>();
|
||||
doAnswer(call->{Class<?> type=call.getArgument(1);LivingEntity entity=mock(type.asSubclass(LivingEntity.class));var pdc=new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());when(entity.getPersistentDataContainer()).thenReturn(pdc);when(entity.getUniqueId()).thenReturn(UUID.randomUUID());when(entity.getWorld()).thenReturn(actor.world);when(entity.getLocation()).thenReturn(((Location)call.getArgument(0)).clone());((java.util.function.Consumer<LivingEntity>)call.getArgument(2)).accept(entity);spawned.add(entity);return entity;}).when(actor.world).spawn(any(Location.class),any(Class.class),any(java.util.function.Consumer.class));
|
||||
doAnswer(call->{Class<?> type=call.getArgument(1);LivingEntity entity=mock(type.asSubclass(LivingEntity.class));var pdc=new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());when(entity.getPersistentDataContainer()).thenReturn(pdc);when(entity.getUniqueId()).thenReturn(UUID.randomUUID());when(entity.getType()).thenReturn(type==Husk.class?EntityType.HUSK:EntityType.VINDICATOR);when(entity.getWorld()).thenReturn(actor.world);when(entity.getLocation()).thenReturn(((Location)call.getArgument(0)).clone());((java.util.function.Consumer<LivingEntity>)call.getArgument(2)).accept(entity);spawned.add(entity);return entity;}).when(actor.world).spawn(any(Location.class),any(Class.class),any(java.util.function.Consumer.class));
|
||||
when(actor.world.getEntities()).thenAnswer(call->new ArrayList<Entity>(spawned));
|
||||
assertEquals(List.of("dungeon"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"dung"}));var denied=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(denied.core.id,denied.player);doReturn(false).when(denied.player).hasPermission("fantasystick.admin");f.command.execute(denied.player,"fantasystick",new String[]{"dungeon","create","Forbidden"});assertTrue(denied.messages.getLast().contains("Permission denied"));assertTrue(f.command.tabComplete(denied.player,"fantasystick",new String[]{"dungeon",""}).isEmpty());
|
||||
command(f,actor,"dungeon","create","Sunken","Keep");var dungeons=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var dungeon=dungeons.snapshot().dungeons().getFirst();
|
||||
command(f,actor,"dungeon","create","Sunken","Keep");var dungeons=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var dungeon=dungeons.snapshot().dungeons().getFirst();command(f,actor,"dungeon","reward",dungeon.id().toString());assertTrue(actor.messages.getLast().contains("not configured"));
|
||||
command(f,actor,"dungeon","edit","start",dungeon.id().toString());var dungeonController=(games.dmg.fantasystick.dungeon.DungeonController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeonController");await(f,()->dungeonController.editing(actor.core.id).isPresent(),actor.messages);
|
||||
command(f,actor,"dungeon","room","create","corner","4","4","3","Entry");await(f,()->!dungeons.find(dungeon.id()).orElseThrow().rooms().isEmpty(),actor.messages);var first=dungeons.find(dungeon.id()).orElseThrow().rooms().getFirst();
|
||||
command(f,actor,"dungeon","room","mob","add","husk","Guard A");command(f,actor,"dungeon","room","mob","add","husk","Guard B");command(f,actor,"dungeon","room","effect","add","players","speed","1","false");command(f,actor,"dungeon","room","reward","set","50","10");
|
||||
command(f,actor,"dungeon","room","mob","add","husk","Guard A");command(f,actor,"dungeon","room","mob","add","husk","Guard B");command(f,actor,"dungeon","room","effect","add","players","speed","1","false");
|
||||
actor.core.location.setX(actor.core.location.getBlockX()+4.2);
|
||||
command(f,actor,"dungeon","room","create","corner","3","4","3","Boss");var bossRoom=dungeons.find(dungeon.id()).orElseThrow().rooms().getLast();
|
||||
command(f,actor,"dungeon","room","reward","set","30","5");command(f,actor,"dungeon","room","boss","mob","vindicator","Keeper");
|
||||
actor.particleCalls.clear();pulse(f);assertFalse(actor.particleCalls.isEmpty());assertTrue(actor.particleCalls.size()<=1024);assertTrue(actor.particleCalls.stream().allMatch(call->call[0]==Particle.DUST));var particlePreferences=(games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"feedbackPreferences");assertTrue(particlePreferences.publish(particlePreferences.setAllParticles(actor.core.id,false).toCompletableFuture().get(5,TimeUnit.SECONDS)));actor.particleCalls.clear();pulse(f);assertTrue(actor.particleCalls.isEmpty(),"All-particle opt-out suppresses dungeon-editor boundaries");assertTrue(particlePreferences.publish(particlePreferences.setAllParticles(actor.core.id,true).toCompletableFuture().get(5,TimeUnit.SECONDS)));var outsider=new FantasyStickLifecycleNativeTest.Actor(data);outsider.core.location.setWorld(actor.world);f.players.put(outsider.core.id,outsider.player);doReturn(true).when(outsider.player).hasPermission("fantasystick.admin");command(f,outsider,"dungeon","edit","start",dungeon.id().toString());assertTrue(outsider.messages.getLast().contains("editing lease"));assertTrue(outsider.particleCalls.isEmpty());
|
||||
command(f,actor,"dungeon","reward",dungeon.id().toString(),"30","5");command(f,actor,"dungeon","reward",dungeon.id().toString());assertTrue(actor.messages.getLast().contains("30 XP and 5 coins"));assertEquals(List.of("30"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"dungeon","reward",dungeon.id().toString(),"3"}));assertEquals(List.of("5"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"dungeon","reward",dungeon.id().toString(),"30","5"}));f.command.execute(actor.player,"fantasystick",new String[]{"dungeon","room","reward","set","99","99"});assertTrue(actor.messages.getLast().contains("Usage:"));assertEquals(BigInteger.valueOf(30),dungeons.find(dungeon.id()).orElseThrow().completionReward().xp());command(f,actor,"dungeon","room","boss","mob","vindicator","Keeper");actor.core.location.setX(actor.core.location.getX()+3.2);command(f,actor,"dungeon","room","create","corner","2","4","3","Skipped");var skipped=dungeons.find(dungeon.id()).orElseThrow().rooms().getLast();
|
||||
actor.particleCalls.clear();pulse(f);assertFalse(actor.particleCalls.isEmpty());assertTrue(actor.particleCalls.size()<=1024);assertTrue(actor.particleCalls.stream().allMatch(call->call[0]==Particle.DUST));var particlePreferences=(games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"feedbackPreferences");assertTrue(particlePreferences.publish(particlePreferences.setAllParticles(actor.core.id,false).toCompletableFuture().get(5,TimeUnit.SECONDS)));actor.particleCalls.clear();pulse(f);assertTrue(actor.particleCalls.isEmpty(),"All-particle opt-out suppresses dungeon-editor boundaries");assertTrue(particlePreferences.publish(particlePreferences.setAllParticles(actor.core.id,true).toCompletableFuture().get(5,TimeUnit.SECONDS)));var outsider=new FantasyStickLifecycleNativeTest.Actor(data);outsider.core.location.setWorld(actor.world);f.players.put(outsider.core.id,outsider.player);doReturn(true).when(outsider.player).hasPermission("fantasystick.admin");command(f,outsider,"dungeon","edit","start",dungeon.id().toString());assertTrue(outsider.messages.getLast().contains("editing lease"));f.command.execute(outsider.player,"fantasystick",new String[]{"dungeon","reward",dungeon.id().toString(),"90","90"});assertTrue(outsider.messages.getLast().contains("another administrator"));assertEquals(BigInteger.valueOf(30),dungeons.find(dungeon.id()).orElseThrow().completionReward().xp());assertTrue(outsider.particleCalls.isEmpty());
|
||||
command(f,actor,"dungeon","edit","done");actor.particleCalls.clear();pulse(f);assertTrue(actor.particleCalls.isEmpty());command(f,actor,"dungeon","enable",dungeon.id().toString());command(f,actor,"dungeon","open",dungeon.id().toString());
|
||||
var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");assertTrue(adventures.snapshot().catalog().sites().contains(dungeon.id()));assertEquals(new AdventureQuestDirectory.Reward(BigInteger.valueOf(80),BigInteger.valueOf(15)),adventures.reward(adventures.findSite(dungeon.id()).orElseThrow()));
|
||||
f.activate(actor);var questController=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");questController.open(actor.player);drain(f);var menu=actor.inventories.getLast();assertEquals(Material.PAPER,menu.getItem(0).getType());var click=mock(InventoryClickEvent.class);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getInventory()).thenReturn(menu);when(click.getCurrentItem()).thenReturn(menu.getItem(0));questController.click(click);drain(f);assertEquals(dungeon.id(),adventures.active().getFirst().site());var partyA=new FantasyStickLifecycleNativeTest.Actor(data);var partyB=new FantasyStickLifecycleNativeTest.Actor(data);for(var member:List.of(partyA,partyB)){member.core.location.setWorld(actor.world);member.core.location.setX(100);f.players.put(member.core.id,member.player);doReturn(java.util.Set.of()).when(member.player).getActivePotionEffects();doReturn(true).when(member.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());f.activate(member);}var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(partyA.core.id).orElseThrow();assertTrue(parties.accept(partyB.core.id,parties.invite(partyA.core.id,party.id(),partyB.core.id).orElseThrow().id()));var catalog=adventures.snapshot().catalog();assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.PARTY,party.id(),Set.of(partyA.core.record.id(),partyB.core.record.id())).get(5,TimeUnit.SECONDS).status());
|
||||
var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");assertTrue(adventures.snapshot().catalog().sites().contains(dungeon.id()));assertEquals(new AdventureQuestDirectory.Reward(BigInteger.valueOf(30),BigInteger.valueOf(5)),adventures.reward(adventures.findSite(dungeon.id()).orElseThrow()));
|
||||
f.activate(actor);var questController=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");questController.open(actor.player);drain(f);var menu=actor.inventories.getLast();assertEquals(Material.PAPER,menu.getItem(0).getType());var offerLore=menu.getItem(0).getItemMeta().lore().toString();assertTrue(offerLore.contains("Completion trophy pool: 30 XP, 5 coins"));assertTrue(offerLore.contains("additional variable XP, coins and native loot"));var click=mock(InventoryClickEvent.class);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getInventory()).thenReturn(menu);when(click.getCurrentItem()).thenReturn(menu.getItem(0));questController.click(click);drain(f);assertEquals(dungeon.id(),adventures.active().getFirst().site());var partyA=new FantasyStickLifecycleNativeTest.Actor(data);var partyB=new FantasyStickLifecycleNativeTest.Actor(data);for(var member:List.of(partyA,partyB)){member.core.location.setWorld(actor.world);member.core.location.setX(100);f.players.put(member.core.id,member.player);doReturn(java.util.Set.of()).when(member.player).getActivePotionEffects();doReturn(true).when(member.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());f.activate(member);}var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(partyA.core.id).orElseThrow();assertTrue(parties.accept(partyB.core.id,parties.invite(partyA.core.id,party.id(),partyB.core.id).orElseThrow().id()));var catalog=adventures.snapshot().catalog();assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.PARTY,party.id(),Set.of(partyA.core.record.id(),partyB.core.record.id())).get(5,TimeUnit.SECONDS).status());
|
||||
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var before=storage.find(actor.core.record.id()).get().orElseThrow();var partyABefore=storage.find(partyA.core.record.id()).get().orElseThrow();var partyBBefore=storage.find(partyB.core.record.id()).get().orElseThrow();for(var member:List.of(actor,partyA)){member.core.location.setX(first.bounds().minX()+.5);member.core.location.setY(first.bounds().minY()+1);member.core.location.setZ(first.bounds().minZ()+.5);}
|
||||
pulse(f);drain(f);pulse(f);drain(f);var initialMobs=spawned.stream().filter(Husk.class::isInstance).toList();assertEquals(2,initialMobs.size());var ordinary=initialMobs.getFirst();var survivor=initialMobs.getLast();var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);var partySource=mock(org.bukkit.damage.DamageSource.class);when(partySource.getCausingEntity()).thenReturn(partyA.player);when(partySource.getDirectEntity()).thenReturn(partyA.player);f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,ordinary,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,ordinary,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));var drops=new ArrayList<ItemStack>();drops.add(new ItemStack(Material.ROTTEN_FLESH));var ordinaryDeath=new EntityDeathEvent(ordinary,source,drops,7);f.dispatcher.accept(ordinaryDeath);drain(f);assertEquals(7,ordinaryDeath.getDroppedExp());assertFalse(ordinaryDeath.getDrops().isEmpty());var partial=dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow();assertEquals(1,partial.defeatedSlots().size());assertFalse(partial.clearedRooms().contains(first.id()));for(var member:List.of(actor,partyA))member.core.location.setX(100);pulse(f);verify(survivor,atLeastOnce()).remove();assertFalse(actorEffects.containsKey(org.bukkit.potion.PotionEffectType.SPEED));for(var member:List.of(actor,partyA)){member.core.location.setX(first.bounds().minX()+.5);member.core.location.setY(first.bounds().minY()+1);member.core.location.setZ(first.bounds().minZ()+.5);}pulse(f);drain(f);var respawned=spawned.stream().filter(Husk.class::isInstance).toList().getLast();assertNotSame(survivor,respawned);f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,respawned,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,respawned,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));f.dispatcher.accept(new EntityDeathEvent(respawned,source,new ArrayList<>(),0));await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow().clearedRooms().contains(first.id()),actor.messages);assertEquals(partyBBefore.experience(),storage.find(partyB.core.record.id()).get().orElseThrow().experience(),"Late party member receives no retroactive room reward");
|
||||
for(var member:List.of(actor,partyA,partyB)){member.core.location.setX(bossRoom.bounds().minX()+.5);member.core.location.setY(bossRoom.bounds().minY()+1);member.core.location.setZ(bossRoom.bounds().minZ()+.5);}pulse(f);drain(f);var firstBoss=spawned.stream().filter(Vindicator.class::isInstance).findFirst().orElseThrow();for(var member:List.of(actor,partyA,partyB))member.core.location.setX(100);pulse(f);verify(firstBoss,atLeastOnce()).remove();for(var member:List.of(actor,partyA,partyB)){member.core.location.setX(bossRoom.bounds().minX()+.5);member.core.location.setY(bossRoom.bounds().minY()+1);member.core.location.setZ(bossRoom.bounds().minZ()+.5);}pulse(f);drain(f);var boss=spawned.stream().filter(Vindicator.class::isInstance).toList().getLast();assertNotSame(firstBoss,boss);f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,boss,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,boss,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDeathEvent(boss,source,new ArrayList<>(),0));
|
||||
await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).map(DungeonState.Run::defeated).orElse(false)&&adventures.active().isEmpty(),actor.messages);var run=dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow();assertTrue(run.defeated(),"dungeon run not defeated");assertTrue(adventures.active().isEmpty(),"boss did not close all dungeon quests");assertTrue(adventures.characterClaimed(catalog.revision(),dungeon.id(),actor.core.record.id()),"solo completion claim missing");assertTrue(adventures.characterClaimed(catalog.revision(),dungeon.id(),partyA.core.record.id()),"represented party member claim missing");assertTrue(adventures.characterClaimed(catalog.revision(),dungeon.id(),partyB.core.record.id()),"present snapshotted party member claim missing");var after=storage.find(actor.core.record.id()).get().orElseThrow();assertEquals(before.experience(),after.experience());assertEquals(before.coins(),after.coins());assertEquals(new AdventureQuestDirectory.Reward(BigInteger.valueOf(80),BigInteger.valueOf(15)),adventures.unclaimed(catalog.revision(),dungeon.id(),actor.core.record.id()));var partyAAfter=storage.find(partyA.core.record.id()).get().orElseThrow();var partyBAfter=storage.find(partyB.core.record.id()).get().orElseThrow();assertEquals(partyABefore.experience(),partyAAfter.experience());assertEquals(partyBBefore.experience(),partyBAfter.experience());var bankedA=adventures.unclaimed(catalog.revision(),dungeon.id(),partyA.core.record.id());var bankedB=adventures.unclaimed(catalog.revision(),dungeon.id(),partyB.core.record.id());assertEquals(BigInteger.valueOf(80),bankedA.xp().add(bankedB.xp()));assertEquals(BigInteger.valueOf(15),bankedA.coins().add(bankedB.coins()));assertTrue(bankedA.xp().signum()>0&&bankedB.xp().signum()>0,"both present party members require banked shares");var defeatedGeneration=adventures.snapshot().catalog().revision();command(f,actor,"dungeon","close",dungeon.id().toString(),"confirm");command(f,actor,"dungeon","open",dungeon.id().toString());assertTrue(actor.messages.getLast().contains("open refused"),actor.messages.toString());assertTrue(dungeons.runView(dungeon.id(),defeatedGeneration).orElseThrow().defeated(),"defeated generation changed");command(f,actor,"adventure","reroll","confirm");await(f,()->adventures.snapshot().reset()==null&&!adventures.snapshot().catalog().revision().equals(defeatedGeneration),actor.messages);assertTrue(dungeons.snapshot().runs().isEmpty(),"full reset retained dungeon run");command(f,actor,"dungeon","open",dungeon.id().toString());var resetCatalog=adventures.snapshot().catalog();assertTrue(resetCatalog.sites().contains(dungeon.id()),"reopened dungeon absent from replacement catalog");assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(resetCatalog.revision(),dungeon.id(),AdventureQuestState.UnitType.SOLO,actor.core.record.id(),Set.of(actor.core.record.id())).get(5,TimeUnit.SECONDS).status());command(f,actor,"dungeon","edit","start",dungeon.id().toString());assertTrue(actor.messages.getLast().contains("exact confirm"),actor.messages.toString());assertFalse(adventures.active().isEmpty(),"fresh dungeon quest missing");command(f,actor,"dungeon","edit","start",dungeon.id().toString(),"confirm");await(f,()->dungeonController.editing(actor.core.id).isPresent(),actor.messages);assertTrue(adventures.active().isEmpty(),"confirmed edit retained accepted quest");assertFalse(dungeons.find(dungeon.id()).orElseThrow().enabled());
|
||||
pulse(f);drain(f);pulse(f);drain(f);var initialMobs=spawned.stream().filter(Husk.class::isInstance).toList();assertEquals(2,initialMobs.size());var ordinary=initialMobs.getFirst();assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,dungeonController.encounterOrigin(ordinary).orElseThrow().kind());var survivor=initialMobs.getLast();assertNotEquals(dungeonController.encounterOrigin(ordinary),dungeonController.encounterOrigin(survivor),"each slot has independent kill authority");var survivorOrigin=dungeonController.encounterOrigin(survivor);var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);var partySource=mock(org.bukkit.damage.DamageSource.class);when(partySource.getCausingEntity()).thenReturn(partyA.player);when(partySource.getDirectEntity()).thenReturn(partyA.player);f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,ordinary,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,ordinary,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));var drops=new ArrayList<ItemStack>();drops.add(new ItemStack(Material.ROTTEN_FLESH));var ordinaryDeath=new EntityDeathEvent(ordinary,source,drops,7);f.dispatcher.accept(ordinaryDeath);await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).map(run->run.defeatedSlots().size()==1).orElse(false),actor.messages);assertEquals(7,ordinaryDeath.getDroppedExp());assertFalse(ordinaryDeath.getDrops().isEmpty());var partial=dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow();assertEquals(1,partial.defeatedSlots().size());assertFalse(partial.clearedRooms().contains(first.id()));for(var member:List.of(actor,partyA))member.core.location.setX(100);pulse(f);verify(survivor,atLeastOnce()).remove();assertFalse(actorEffects.containsKey(org.bukkit.potion.PotionEffectType.SPEED));for(var member:List.of(actor,partyA)){member.core.location.setX(first.bounds().minX()+.5);member.core.location.setY(first.bounds().minY()+1);member.core.location.setZ(first.bounds().minZ()+.5);}pulse(f);drain(f);var respawned=spawned.stream().filter(Husk.class::isInstance).toList().getLast();assertNotSame(survivor,respawned);assertEquals(survivorOrigin,dungeonController.encounterOrigin(respawned),"respawned slot retains duplicate-safe kill authority");f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,respawned,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,respawned,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));f.dispatcher.accept(new EntityDeathEvent(respawned,source,new ArrayList<>(),0));await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow().clearedRooms().contains(first.id()),actor.messages);assertTrue(storage.find(partyB.core.record.id()).get().orElseThrow().experience().compareTo(partyBBefore.experience())>0,"ordinary party combat sharing remains active");
|
||||
var actorAfterKills=storage.find(actor.core.record.id()).get().orElseThrow();var partyAAfterKills=storage.find(partyA.core.record.id()).get().orElseThrow();var partyBAfterKills=storage.find(partyB.core.record.id()).get().orElseThrow();for(var member:List.of(actor,partyA,partyB)){member.core.location.setX(bossRoom.bounds().minX()+.5);member.core.location.setY(bossRoom.bounds().minY()+1);member.core.location.setZ(bossRoom.bounds().minZ()+.5);}pulse(f);drain(f);var firstBoss=spawned.stream().filter(Vindicator.class::isInstance).findFirst().orElseThrow();for(var member:List.of(actor,partyA,partyB))member.core.location.setX(100);pulse(f);verify(firstBoss,atLeastOnce()).remove();for(var member:List.of(actor,partyA,partyB)){member.core.location.setX(bossRoom.bounds().minX()+.5);member.core.location.setY(bossRoom.bounds().minY()+1);member.core.location.setZ(bossRoom.bounds().minZ()+.5);}pulse(f);drain(f);var boss=spawned.stream().filter(Vindicator.class::isInstance).toList().getLast();assertNotSame(firstBoss,boss);f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,boss,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,boss,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDeathEvent(boss,source,new ArrayList<>(),0));assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,dungeonController.encounterOrigin(boss).orElseThrow().kind(),"boss cleanup retains immutable managed origin authority");
|
||||
await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).map(DungeonState.Run::defeated).orElse(false)&&adventures.active().isEmpty()&&storage.find(actor.core.record.id()).join().orElseThrow().experience().compareTo(actorAfterKills.experience())>0,actor.messages);var run=dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow();assertTrue(run.defeated(),"dungeon run not defeated");assertFalse(run.clearedRooms().contains(skipped.id()),"boss death completes without clearing every room");assertTrue(adventures.active().isEmpty(),"boss did not close all dungeon quests");assertTrue(adventures.characterClaimed(catalog.revision(),dungeon.id(),actor.core.record.id()),"solo completion claim missing");assertTrue(adventures.characterClaimed(catalog.revision(),dungeon.id(),partyA.core.record.id()),"represented party member claim missing");assertTrue(adventures.characterClaimed(catalog.revision(),dungeon.id(),partyB.core.record.id()),"present snapshotted party member claim missing");var after=storage.find(actor.core.record.id()).get().orElseThrow();assertTrue(after.experience().compareTo(before.experience())>0,"ordinary dungeon kills award standard combat XP");assertTrue(after.coins().compareTo(before.coins())>0,"ordinary dungeon kills award standard character coins");assertTrue(after.experience().compareTo(actorAfterKills.experience())>0,"genuinely killed dungeon boss grants native-classified per-kill XP");assertTrue(after.coins().compareTo(actorAfterKills.coins())>0,"genuinely killed dungeon boss grants native-classified per-kill coins");assertEquals(AdventureQuestDirectory.Reward.empty(),adventures.unclaimed(catalog.revision(),dungeon.id(),actor.core.record.id()));var partyAAfter=storage.find(partyA.core.record.id()).get().orElseThrow();var partyBAfter=storage.find(partyB.core.record.id()).get().orElseThrow();assertTrue(partyAAfter.experience().compareTo(partyABefore.experience())>0,"party contributor receives normal kill XP");assertTrue(partyBAfter.experience().compareTo(partyBBefore.experience())>0);assertTrue(partyAAfter.experience().compareTo(partyAAfterKills.experience())>0);assertTrue(partyBAfter.experience().compareTo(partyBAfterKills.experience())>0);var trophies=(games.dmg.fantasystick.adventure.FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");var soloTrophy=trophies.trophies(actor.core.record.id()).getFirst();var partyTrophy=trophies.trophies(partyA.core.record.id()).getFirst();assertEquals(games.dmg.fantasystick.adventure.FishingState.TrophyType.DUNGEON,soloTrophy.type());assertEquals("Trophy of Sunken Keep",soloTrophy.name());assertEquals(new games.dmg.fantasystick.adventure.FishingState.Reward(BigInteger.valueOf(30),BigInteger.valueOf(5)),soloTrophy.total());assertEquals(new games.dmg.fantasystick.adventure.FishingState.Reward(BigInteger.valueOf(30),BigInteger.valueOf(5)),partyTrophy.total());assertEquals(Set.of(partyA.core.record.id(),partyB.core.record.id()),partyTrophy.rewards().keySet());assertEquals(AdventureQuestDirectory.Reward.empty(),adventures.unclaimed(catalog.revision(),dungeon.id(),partyA.core.record.id()));var defeatedGeneration=adventures.snapshot().catalog().revision();command(f,actor,"dungeon","close",dungeon.id().toString(),"confirm");command(f,actor,"dungeon","open",dungeon.id().toString());assertTrue(actor.messages.getLast().contains("open refused"),actor.messages.toString());assertTrue(dungeons.runView(dungeon.id(),defeatedGeneration).orElseThrow().defeated(),"defeated generation changed");command(f,actor,"adventure","reroll","confirm");await(f,()->adventures.snapshot().reset()==null&&!adventures.snapshot().catalog().revision().equals(defeatedGeneration),actor.messages);assertEquals(1,trophies.trophies(actor.core.record.id()).size(),"reroll preserves earned dungeon trophy");var claimBase=storage.find(actor.core.record.id()).get().orElseThrow();var turn=(games.dmg.fantasystick.adventure.FishingController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishingController");turn.open(actor.player);var trophyMenu=actor.inventories.getLast();var plain=net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText();var redeem=java.util.stream.IntStream.range(0,trophyMenu.getSize()).mapToObj(trophyMenu::getItem).filter(Objects::nonNull).filter(item->item.getItemMeta().displayName()!=null&&plain.serialize(item.getItemMeta().displayName()).equals("Redeem Trophy of Sunken Keep")).findFirst().orElseThrow();var redeemClick=mock(InventoryClickEvent.class);when(redeemClick.getWhoClicked()).thenReturn(actor.player);when(redeemClick.getInventory()).thenReturn(trophyMenu);when(redeemClick.getCurrentItem()).thenReturn(redeem);turn.click(redeemClick);await(f,()->trophies.trophies(actor.core.record.id()).isEmpty()&&trophies.unclaimed(actor.core.record.id()).equals(new games.dmg.fantasystick.adventure.FishingState.Reward(BigInteger.valueOf(30),BigInteger.valueOf(5))),actor.meLine truncated
|
||||
|
||||
}
|
||||
}
|
||||
@@ -91,7 +92,7 @@ class DungeonNativeTest {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\ndungeon-room-link-distance: 10\n");
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);doReturn(true).when(actor.player).hasPermission("fantasystick.admin");when(actor.world.getUID()).thenReturn(UUID.randomUUID());
|
||||
command(f,actor,"dungeon","create","Feedback");var dungeons=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var dungeon=dungeons.snapshot().dungeons().getFirst();command(f,actor,"dungeon","edit","start",dungeon.id().toString());var controller=(games.dmg.fantasystick.dungeon.DungeonController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeonController");await(f,()->controller.editing(actor.core.id).isPresent(),actor.messages);
|
||||
command(f,actor,"dungeon","create","Feedback");var dungeons=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");await(f,()->!dungeons.snapshot().dungeons().isEmpty(),actor.messages);var dungeon=dungeons.snapshot().dungeons().getFirst();command(f,actor,"dungeon","edit","start",dungeon.id().toString());var controller=(games.dmg.fantasystick.dungeon.DungeonController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeonController");await(f,()->controller.editing(actor.core.id).isPresent(),actor.messages);
|
||||
command(f,actor,"dungeon","room","create","corner","1","1","1","First");await(f,()->dungeons.find(dungeon.id()).orElseThrow().rooms().size()==1,actor.messages);var first=dungeons.find(dungeon.id()).orElseThrow().rooms().getFirst();
|
||||
command(f,actor,"dungeon","room","create","corner","1","1","1","Overlap");await(f,()->actor.messages.stream().anyMatch(message->message.contains("overlaps an existing room")),actor.messages);
|
||||
actor.core.location.setX(first.bounds().maxX()+11.1);actor.core.location.setY(first.bounds().minY()+1);actor.core.location.setZ(first.bounds().minZ()+.5);command(f,actor,"dungeon","room","create","corner","1","1","1","Too Far");await(f,()->actor.messages.stream().anyMatch(message->message.contains("configured 10-block link distance")),actor.messages);
|
||||
@@ -115,9 +116,9 @@ class DungeonNativeTest {
|
||||
var admin=new FantasyStickLifecycleNativeTest.Actor(root);f.players.put(admin.core.id,admin.player);doReturn(true).when(admin.player).hasPermission("fantasystick.admin");var bossStats=new java.util.EnumMap<games.dmg.fantasystick.progression.Stat,BigInteger>(games.dmg.fantasystick.progression.Stat.class);for(var stat:games.dmg.fantasystick.progression.Stat.values())bossStats.put(stat,BigInteger.valueOf(30));var abilities=List.of("fireball","frost_nova","hex","second_wind","purify","stone_skin","guardian_bond");var configuredBoss=new games.dmg.fantasystick.persistence.CharacterRecord(admin.core.record.id(),admin.core.record.name(),BigInteger.ONE,bossStats,admin.core.record.level(),admin.core.record.experience(),admin.core.record.unspentPoints(),java.math.BigDecimal.valueOf(40),Set.copyOf(abilities),abilities,List.of("speed_i","low_light_vision","quicker_regeneration_i"),Map.of("fireball",java.time.Instant.parse("2099-01-01T00:00:00Z")),Map.of(),false,null,null,admin.core.record.coins(),admin.core.record.coinsInitialized(),admin.core.record.rewardReceipts(),admin.core.record.pvpCoinEscrow(),new games.dmg.fantasystick.persistence.CharacterGear(games.dmg.fantasystick.persistence.GearTier.COPPER,games.dmg.fantasystick.persistence.GearTier.GOLD,games.dmg.fantasystick.persistence.GearTier.IRON));new games.dmg.fantasystick.persistence.YamlCharacterRepository(root).replace(configuredBoss,BigInteger.ZERO);
|
||||
UUID worldId=UUID.randomUUID();when(admin.world.getUID()).thenReturn(worldId);when(admin.world.isChunkLoaded(anyInt(),anyInt())).thenReturn(true);
|
||||
var spawned=new ArrayList<Vindicator>();var maximumBases=new IdentityHashMap<Vindicator,double[]>();var attackBases=new IdentityHashMap<Vindicator,double[]>();var bossEffects=new IdentityHashMap<Vindicator,Set<org.bukkit.potion.PotionEffect>>();var bossEquipment=new IdentityHashMap<Vindicator,org.bukkit.inventory.EntityEquipment>();
|
||||
doAnswer(call->{var mob=mock(Vindicator.class);var pdc=new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());var maximum=mock(org.bukkit.attribute.AttributeInstance.class);var damage=mock(org.bukkit.attribute.AttributeInstance.class);var equipment=mock(org.bukkit.inventory.EntityEquipment.class);var maximumBase=new double[]{20};var attackBase=new double[]{1};var health=new double[]{20};var effects=new LinkedHashSet<org.bukkit.potion.PotionEffect>();effects.add(new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.WEAKNESS,200,0));maximumBases.put(mob,maximumBase);attackBases.put(mob,attackBase);bossEffects.put(mob,effects);bossEquipment.put(mob,equipment);when(mob.getPersistentDataContainer()).thenReturn(pdc);when(mob.getUniqueId()).thenReturn(UUID.randomUUID());when(mob.getLocation()).thenReturn(((Location)call.getArgument(0)).clone());when(mob.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).thenReturn(maximum);when(mob.getAttribute(org.bukkit.attribute.Attribute.ATTACK_DAMAGE)).thenReturn(damage);when(maximum.getValue()).thenAnswer(ignored->maximumBase[0]);doAnswer(invocation->{maximumBase[0]=(Double)invocation.getArgument(0);return null;}).when(maximum).setBaseValue(anyDouble());doAnswer(invocation->{attackBase[0]=(Double)invocation.getArgument(0);return null;}).when(damage).setBaseValue(anyDouble());when(mob.getHealth()).thenAnswer(ignored->health[0]);doAnswer(invocation->{health[0]=(Double)invocation.getArgument(0);return null;}).when(mob).setHealth(anyDouble());when(mob.getEquipment()).thenReturn(equipment);when(mob.hasLineOfSight(any(Entity.class))).thenReturn(true);when(mob.getActivePotionEffects()).thenAnswer(ignored->Set.copyOf(effects));doAnswer(invocation->{effects.add(invocation.getArgument(0));return true;}).when(mob).addPotionEffect(any(org.bukkit.potion.PotionEffect.class));doAnswer(invocation->{effects.add(invocation.getArgument(0));return true;}).when(mob).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());doAnswer(invocation->{effects.removeIf(effect->effect.getType().equals(invocation.getArgument(0)));return null;}).when(mob).removePotionEffect(any(org.bukkit.potion.PotionEffectType.class));((java.util.function.Consumer<Vindicator>)call.getArgument(2)).accept(mob);spawned.add(mob);return mob;}).when(admin.world).spawn(any(Location.class),eq(Vindicator.class),any(java.util.function.Consumer.class));
|
||||
doAnswer(call->{var mob=mock(Vindicator.class);var pdc=new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());var maximum=mock(org.bukkit.attribute.AttributeInstance.class);var damage=mock(org.bukkit.attribute.AttributeInstance.class);var equipment=mock(org.bukkit.inventory.EntityEquipment.class);var maximumBase=new double[]{20};var attackBase=new double[]{1};var health=new double[]{20};var effects=new LinkedHashSet<org.bukkit.potion.PotionEffect>();effects.add(new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.WEAKNESS,200,0));maximumBases.put(mob,maximumBase);attackBases.put(mob,attackBase);bossEffects.put(mob,effects);bossEquipment.put(mob,equipment);when(mob.getPersistentDataContainer()).thenReturn(pdc);when(mob.getUniqueId()).thenReturn(UUID.randomUUID());when(mob.getType()).thenReturn(EntityType.VINDICATOR);when(mob.getWorld()).thenReturn(admin.world);when(mob.getLocation()).thenReturn(((Location)call.getArgument(0)).clone());when(mob.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).thenReturn(maximum);when(mob.getAttribute(org.bukkit.attribute.Attribute.ATTACK_DAMAGE)).thenReturn(damage);when(maximum.getValue()).thenAnswer(ignored->maximumBase[0]);doAnswer(invocation->{maximumBase[0]=(Double)invocation.getArgument(0);return null;}).when(maximum).setBaseValue(anyDouble());doAnswer(invocation->{attackBase[0]=(Double)invocation.getArgument(0);return null;}).when(damage).setBaseValue(anyDouble());when(mob.getHealth()).thenAnswer(ignored->health[0]);doAnswer(invocation->{health[0]=(Double)invocation.getArgument(0);return null;}).when(mob).setHealth(anyDouble());when(mob.getEquipment()).thenReturn(equipment);when(mob.hasLineOfSight(any(Entity.class))).thenReturn(true);when(mob.getActivePotionEffects()).thenAnswer(ignored->Set.copyOf(effects));doAnswer(invocation->{effects.add(invocation.getArgument(0));return true;}).when(mob).addPotionEffect(any(org.bukkit.potion.PotionEffect.class));doAnswer(invocation->{effects.add(invocation.getArgument(0));return true;}).when(mob).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());doAnswer(invocation->{effects.removeIf(effect->effect.getType().equals(invocation.getArgument(0)));return null;}).when(mob).removePotionEffect(any(org.bukkit.potion.PotionEffectType.class));((java.util.function.Consumer<Vindicator>)call.getArgument(2)).accept(mob);spawned.add(mob);return mob;}).when(admin.world).spawn(any(Location.class),eq(Vindicator.class),any(java.util.function.Consumer.class));
|
||||
when(admin.world.getEntities()).thenAnswer(ignored->new ArrayList<Entity>(spawned));
|
||||
var dungeons=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var dungeon=dungeons.create("Mirror",worldId,0,64,0,0,0).get(5,TimeUnit.SECONDS);var room=dungeons.addRadiusRoom(dungeon.id(),"Boss",0,64,0,3,3).get(5,TimeUnit.SECONDS);dungeons.setBoss(dungeon.id(),room.id(),EntityType.HUSK,"Temporary",0,65,0,0,0).get(5,TimeUnit.SECONDS);command(f,admin,"dungeon","edit","start",dungeon.id().toString());var controller=(games.dmg.fantasystick.dungeon.DungeonController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeonController");await(f,()->controller.editing(admin.core.id).isPresent(),admin.messages);admin.core.location.setX(.5);admin.core.location.setY(65);admin.core.location.setZ(.5);var sheet=admin.core.inventory.getItemInOffHand();admin.core.inventory.setItemInOffHand(null);admin.core.inventory.setItemInMainHand(sheet);command(f,admin,"dungeon","room","boss","assign-sheet","2");await(f,()->dungeons.custody(admin.core.record.id()).isPresent(),admin.messages);command(f,admin,"dungeon","edit","done");command(f,admin,"dungeon","enable",dungeon.id().toString());command(f,admin,"dungeon","open",dungeon.id().toString());
|
||||
var dungeons=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var dungeon=dungeons.create("Mirror",worldId,0,64,0,0,0).get(5,TimeUnit.SECONDS);var room=dungeons.addRadiusRoom(dungeon.id(),"Boss",0,64,0,3,3).get(5,TimeUnit.SECONDS);dungeons.setBoss(dungeon.id(),room.id(),EntityType.HUSK,"Temporary",0,65,0,0,0).get(5,TimeUnit.SECONDS);dungeons.setCompletionReward(dungeon.id(),BigInteger.ZERO,BigInteger.ZERO).get(5,TimeUnit.SECONDS);command(f,admin,"dungeon","edit","start",dungeon.id().toString());var controller=(games.dmg.fantasystick.dungeon.DungeonController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeonController");await(f,()->controller.editing(admin.core.id).isPresent(),admin.messages);admin.core.location.setX(.5);admin.core.location.setY(65);admin.core.location.setZ(.5);var sheet=admin.core.inventory.getItemInOffHand();admin.core.inventory.setItemInOffHand(null);admin.core.inventory.setItemInMainHand(sheet);command(f,admin,"dungeon","room","boss","assign-sheet","2");await(f,()->dungeons.custody(admin.core.record.id()).isPresent(),admin.messages);command(f,admin,"dungeon","edit","done");command(f,admin,"dungeon","enable",dungeon.id().toString());command(f,admin,"dungeon","open",dungeon.id().toString());
|
||||
var fighter=new FantasyStickLifecycleNativeTest.Actor(root);fighter.core.location.setWorld(admin.world);fighter.core.location.setX(.5);fighter.core.location.setY(65);fighter.core.location.setZ(.5);f.players.put(fighter.core.id,fighter.player);doReturn(false).when(fighter.player).isDead();doReturn(true).when(fighter.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());var abilityHits=new ArrayList<Double>();fighter.damage=(amount,source)->abilityHits.add(amount);f.activate(fighter);var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");var catalog=adventures.snapshot().catalog();assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.SOLO,fighter.core.record.id(),Set.of(fighter.core.record.id())).get(5,TimeUnit.SECONDS).status());var challenger=new FantasyStickLifecycleNativeTest.Actor(root);challenger.core.location.setWorld(admin.world);challenger.core.location.setX(1.5);challenger.core.location.setY(65);challenger.core.location.setZ(.5);f.players.put(challenger.core.id,challenger.player);doReturn(false).when(challenger.player).isDead();doReturn(true).when(challenger.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());challenger.damage=(amount,sourceEntity)->{};f.activate(challenger);assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.SOLO,challenger.core.record.id(),Set.of(challenger.core.record.id())).get(5,TimeUnit.SECONDS).status());pulse(f);drain(f);pulse(f);drain(f);assertFalse(spawned.isEmpty());var boss=spawned.getLast();assertEquals(70d,maximumBases.get(boss)[0]);assertEquals(26d,attackBases.get(boss)[0]);var equipment=bossEquipment.get(boss);verify(equipment).setChestplate(argThat(item->item!=null&&item.getType()==Material.COPPER_CHESTPLATE));verify(equipment).setLeggings(argThat(item->item!=null&&item.getType()==Material.GOLDEN_LEGGINGS));verify(equipment).setBoots(argThat(item->item!=null&&item.getType()==Material.IRON_BOOTS));assertTrue(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.SPEED)),bossEffects.get(boss)::toString);assertTrue(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.NIGHT_VISION)),bossEffects.get(boss)::toString);boss.setHealth(50);var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(fighter.player);when(source.getDirectEntity()).thenReturn(fighter.player);f.dispatcher.accept(new EntityDamageByEntityEvent(fighter.player,boss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3));pulse(f);verify(boss,atLeastOnce()).setTarget(fighter.player);assertTrue(abilityHits.size()>=2,"Every ready offensive slot casts in the same pulse");int firstCasts=abilityHits.size();pulse(f);assertEquals(firstCasts,abilityHits.size(),"Boss-only cooldowns suppress immediate recasts");assertFalse(bossEffects.get(boss).stream().anyMatch(effect->effect.getType().equals(org.bukkit.potion.PotionEffectType.WEAKNESS)),"Purify removes a real harmful effect");var challengerSource=mock(org.bukkit.damage.DamageSource.class);when(challengerSource.getCausingEntity()).thenReturn(challenger.player);when(challengerSource.getDirectEntity()).thenReturn(challenger.player);f.dispatcher.accept(new EntityDamageByEntityEvent(challenger.player,boss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,challengerSource,10));pulse(f);var targets=org.mockito.ArgumentCaptor.forClass(LivingEntity.class);verify(boss,atLeastOnce()).setTarget(targets.capture());assertSame(fighter.player,targets.getAllValues().getLast(),"Higher later threat cannot break fixation");fighter.core.location.setX(100);pulse(f);targets=org.mockito.ArgumentCaptor.forClass(LivingEntity.class);verify(boss,atLeastOnce()).setTarget(targets.capture());assertSame(challenger.player,targets.getAllValues().getLast(),"Ineligible fixation retargets highest remaining threat");
|
||||
challenger.core.location.setX(100);pulse(f);verify(boss,atLeastOnce()).remove();challenger.core.location.setX(.5);pulse(f);drain(f);var freshBoss=spawned.getLast();assertNotSame(boss,freshBoss);assertEquals(70d,maximumBases.get(freshBoss)[0]);f.dispatcher.accept(new EntityDamageByEntityEvent(challenger.player,freshBoss,EntityDamageEvent.DamageCause.ENTITY_ATTACK,challengerSource,2));f.dispatcher.accept(new EntityDeathEvent(freshBoss,challengerSource,new ArrayList<>(),0));await(f,()->dungeons.runView(dungeon.id(),catalog.revision()).map(DungeonState.Run::defeated).orElse(false),admin.messages);assertTrue(dungeons.custody(admin.core.record.id()).isPresent(),"Boss defeat keeps character custody for later days");challenger.core.location.setX(100);command(f,admin,"dungeon","disable",dungeon.id().toString(),"confirm");admin.core.location.setX(100);command(f,admin,"dungeon","boss","recover-sheet",dungeon.id().toString());await(f,()->dungeons.custody(admin.core.record.id()).isEmpty(),admin.messages);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(configuredBoss,storage.find(configuredBoss.id()).get(5,TimeUnit.SECONDS).orElseThrow());assertTrue(Arrays.stream(admin.core.inventory.getContents()).filter(Objects::nonNull).anyMatch(item->new games.dmg.fantasystick.items.CharacterSheets().identify(item).filter(configuredBoss.id()::equals).isPresent()));
|
||||
}
|
||||
|
||||
@@ -17,6 +17,34 @@ class EquipmentAdminCommandNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void registeredRootsKeepPlayerAndAdministratorGuidanceSeparated() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var administrator = new Sender();
|
||||
f.adminCommand.execute(administrator.port, "fstickadmin", new String[]{});
|
||||
assertTrue(administrator.messages.getLast().startsWith("Usage: /fstickadmin"));
|
||||
assertTrue(administrator.messages.getLast().contains("adventure-site"));
|
||||
assertTrue(administrator.messages.getLast().contains("character view"));
|
||||
administrator.messages.clear();
|
||||
f.adminCommand.execute(administrator.port, "fstickadmin", new String[]{"inspect"});
|
||||
assertTrue(administrator.messages.getLast().startsWith("Usage: /fstickadmin"));
|
||||
|
||||
var player = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(player.core.id, player.player);
|
||||
assertEquals(List.of("adventure", "feedback", "particles"),
|
||||
f.playerCommand.tabComplete(player.player, "fstick", new String[]{""}));
|
||||
assertEquals(List.of("reroll"),
|
||||
f.adminCommand.tabComplete(administrator.port, "fstickadmin", new String[]{"adventure", ""}));
|
||||
assertFalse(f.adminCommand.tabComplete(administrator.port, "fstickadmin", new String[]{""})
|
||||
.stream().anyMatch(route -> route.equals("feedback") || route.equals("particles")));
|
||||
assertTrue(f.playerCommand.tabComplete(player.player, "fstick", new String[]{"adventure", "r"}).isEmpty());
|
||||
assertTrue(f.playerCommand.tabComplete(player.player, "fstick", new String[]{"config", ""}).isEmpty());
|
||||
assertTrue(f.playerCommand.execute(player.player, "fstick", new String[]{"adventure", "reroll", "confirm"}));
|
||||
assertTrue(player.messages.getLast().startsWith("Usage: /fstick adventure"));
|
||||
assertFalse(String.join("\n", player.messages).contains("fstickadmin"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void registeredGivePersistsOneFreshSheetThenDeliversActivatesAndReloads() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
@@ -109,7 +137,7 @@ class EquipmentAdminCommandNativeTest {
|
||||
assertEquals(List.of("true"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "adventure-mode", "t"}));
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "adventure-mode", "true", ""}).isEmpty());
|
||||
sender.allowed = false;
|
||||
assertEquals(List.of("adventure"), f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
|
||||
assertTrue(f.command.tabComplete(sender.port, "fstickadmin", new String[]{""}).isEmpty());
|
||||
for (String[] args : List.of(new String[]{"inspect", ""}, new String[]{"config", ""},
|
||||
new String[]{"config", "adventure-mode", ""})) {
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", args).isEmpty());
|
||||
|
||||
@@ -49,10 +49,10 @@ class FantasyFeedbackCommandNativeTest {
|
||||
assertTrue(fixture.feedbackCommand.tabComplete(console.port, "fantasyfeedback", new String[]{""}).isEmpty());
|
||||
var player = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(player.core.id, player.player);
|
||||
assertFalse(fixture.feedbackCommand.getExecutor().onCommand(player.player, fixture.feedbackCommand,
|
||||
"fantasyfeedback", new String[]{"OFF"}));
|
||||
assertFalse(fixture.feedbackCommand.getExecutor().onCommand(player.player, fixture.feedbackCommand,
|
||||
"fantasyfeedback", new String[]{"off", "extra"}));
|
||||
assertFalse(fixture.playerCommand.getExecutor().onCommand(player.player, fixture.playerCommand,
|
||||
"fstick", new String[]{"feedback", "OFF"}));
|
||||
assertFalse(fixture.playerCommand.getExecutor().onCommand(player.player, fixture.playerCommand,
|
||||
"fstick", new String[]{"feedback", "off", "extra"}));
|
||||
assertTrue(((games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences"))
|
||||
.enabled(player.core.id));
|
||||
|
||||
@@ -86,7 +86,7 @@ class FantasyHealthNativeTest {
|
||||
var death = new org.bukkit.event.entity.PlayerDeathEvent(a.player, source, new java.util.ArrayList<>(), 0,
|
||||
net.kyori.adventure.text.Component.empty(), true);
|
||||
f.dispatcher.accept(death); assertTrue(death.isCancelled());
|
||||
PartyBookNativeTest.drain(f);
|
||||
for(int attempt=0;attempt<80&&a.core.inventory.getItemInOffHand().getType()!=org.bukkit.Material.WRITTEN_BOOK;attempt++){var task=f.tasks.poll(20,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}
|
||||
assertEquals(1, receivedDust(a, org.bukkit.Color.RED), "Aura opt-out must not hide the defeat burst");
|
||||
assertTrue(a.particleCalls.stream().anyMatch(call -> java.util.Arrays.asList(call).contains(defeatLocation)));
|
||||
assertEquals(entry, a.player.getLocation()); assertEquals(28, a.player.getHealth());
|
||||
|
||||
@@ -65,8 +65,10 @@ class FantasyParticlesNativeTest {
|
||||
for (String[] invalid : List.of(new String[]{}, new String[]{"unknown"},
|
||||
new String[]{"players", "ON"}, new String[]{"all", "ON"},
|
||||
new String[]{"players", "on", "extra"}, new String[]{"all", "off", "extra"})) {
|
||||
assertFalse(fixture.particlesCommand.getExecutor().onCommand(player.player,
|
||||
fixture.particlesCommand, "fantasyparticles", invalid));
|
||||
var routed = java.util.stream.Stream.concat(java.util.stream.Stream.of("particles"),
|
||||
java.util.Arrays.stream(invalid)).toArray(String[]::new);
|
||||
assertFalse(fixture.playerCommand.getExecutor().onCommand(player.player,
|
||||
fixture.playerCommand, "fstick", routed));
|
||||
}
|
||||
|
||||
fixture.particlesCommand.execute(player.player, "fantasyparticles",
|
||||
|
||||
@@ -60,8 +60,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertTrue(fixture.order.indexOf("CharacterChatNames") >= 0);
|
||||
assertTrue(fixture.order.indexOf("CharacterChatNames") < recipe);
|
||||
assertNotNull(fixture.packetListener);
|
||||
assertEquals(15, fixture.timers.size(),
|
||||
"Presentation, ability/bond/rally/banner cleanup, provenance, reward retry, recovery, passive effects, armor/adventure/dungeon refresh and session particles");
|
||||
assertEquals(16, fixture.timers.size(),
|
||||
"Presentation, ability/bond/rally/banner cleanup, provenance, reward retry, recovery, passive effects, armor/adventure/fishing/dungeon refresh and session particles");
|
||||
assertTrue(fixture.order.indexOf("PartyCombat") >= 0);
|
||||
assertTrue(fixture.order.indexOf("PartyCombat") < recipe);
|
||||
assertTrue(fixture.order.indexOf("CombatExperience") >= 0);
|
||||
@@ -78,8 +78,13 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertTrue(fixture.order.indexOf("OutOfCombatRecovery") < recipe);
|
||||
assertTrue(fixture.order.indexOf("RecipeDiscovery") > recipe,
|
||||
"Discovery must not become active before successful recipe registration");
|
||||
assertNotNull(fixture.command);
|
||||
assertEquals("EquipmentAdminCommand", fixture.command.getExecutor().getClass().getSimpleName());
|
||||
assertNotNull(fixture.playerCommand);
|
||||
assertNotNull(fixture.adminCommand);
|
||||
assertEquals("FantasyPlayerCommand", fixture.playerCommand.getExecutor().getClass().getSimpleName());
|
||||
assertEquals("EquipmentAdminCommand", fixture.adminCommand.getExecutor().getClass().getSimpleName());
|
||||
assertNull(fixture.server.getPluginCommand("fantasystick"));
|
||||
assertNull(fixture.server.getPluginCommand("fantasyfeedback"));
|
||||
assertNull(fixture.server.getPluginCommand("fantasyparticles"));
|
||||
assertTrue(fixture.tasks.isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -1144,8 +1149,10 @@ class FantasyStickLifecycleNativeTest {
|
||||
final Thread main = Thread.currentThread();
|
||||
final Map<java.util.UUID, org.bukkit.entity.Player> players = new HashMap<>();
|
||||
org.bukkit.command.PluginCommand command;
|
||||
org.bukkit.command.PluginCommand feedbackCommand;
|
||||
org.bukkit.command.PluginCommand particlesCommand;
|
||||
org.bukkit.command.PluginCommand playerCommand;
|
||||
org.bukkit.command.PluginCommand adminCommand;
|
||||
RoutedPlayerCommand feedbackCommand;
|
||||
RoutedPlayerCommand particlesCommand;
|
||||
int playerLookups;
|
||||
String failRegistrationAt;
|
||||
boolean failRemoval;
|
||||
@@ -1229,9 +1236,9 @@ class FantasyStickLifecycleNativeTest {
|
||||
.filter(world -> java.util.Objects.equals(world.getUID(), args[0])).findFirst().orElse(null);
|
||||
case "getPlayer" -> { assertSame(main, Thread.currentThread()); playerLookups++; yield players.get(args[0]); }
|
||||
case "getPluginCommand" -> switch ((String) args[0]) {
|
||||
case "fantasyfeedback" -> feedbackCommand;
|
||||
case "fantasyparticles" -> particlesCommand;
|
||||
default -> command;
|
||||
case "fstick" -> playerCommand;
|
||||
case "fstickadmin" -> adminCommand;
|
||||
default -> null;
|
||||
};
|
||||
case "createInventory" -> args[1] instanceof Integer size
|
||||
? new org.bukkit.craftbukkit.inventory.CraftInventoryCustom((org.bukkit.inventory.InventoryHolder) args[0], size, (net.kyori.adventure.text.Component) args[2])
|
||||
@@ -1256,28 +1263,44 @@ class FantasyStickLifecycleNativeTest {
|
||||
plugin.getLogger().addHandler(logHandler);
|
||||
var constructor = org.bukkit.command.PluginCommand.class.getDeclaredConstructor(String.class, org.bukkit.plugin.Plugin.class);
|
||||
constructor.setAccessible(true);
|
||||
var definition = plugin.getDescription().getCommands().get("fantasystick");
|
||||
if (definition != null) {
|
||||
// Bukkit normally constructs this from the packaged descriptor during plugin loading.
|
||||
command = constructor.newInstance("fantasystick", plugin);
|
||||
command.setPermission((String) definition.get("permission"));
|
||||
// Explicit server-message port: native permission dispatch otherwise reads global Bukkit.server.
|
||||
command.permissionMessage(net.kyori.adventure.text.Component.text("Permission denied"));
|
||||
command.setUsage((String) definition.get("usage"));
|
||||
var playerDefinition = plugin.getDescription().getCommands().get("fstick");
|
||||
if (playerDefinition != null) {
|
||||
// Bukkit normally constructs these from the packaged descriptor during plugin loading.
|
||||
playerCommand = constructor.newInstance("fstick", plugin);
|
||||
playerCommand.setPermission((String) playerDefinition.get("permission"));
|
||||
playerCommand.permissionMessage(net.kyori.adventure.text.Component.text("Permission denied"));
|
||||
playerCommand.setUsage((String) playerDefinition.get("usage"));
|
||||
}
|
||||
var feedbackDefinition = plugin.getDescription().getCommands().get("fantasyfeedback");
|
||||
if (feedbackDefinition != null) {
|
||||
feedbackCommand = constructor.newInstance("fantasyfeedback", plugin);
|
||||
feedbackCommand.setPermission((String) feedbackDefinition.get("permission"));
|
||||
feedbackCommand.permissionMessage(net.kyori.adventure.text.Component.text("Permission denied"));
|
||||
feedbackCommand.setUsage((String) feedbackDefinition.get("usage"));
|
||||
var adminDefinition = plugin.getDescription().getCommands().get("fstickadmin");
|
||||
if (adminDefinition != null) {
|
||||
adminCommand = constructor.newInstance("fstickadmin", plugin);
|
||||
adminCommand.setPermission((String) adminDefinition.get("permission"));
|
||||
adminCommand.permissionMessage(net.kyori.adventure.text.Component.text("Permission denied"));
|
||||
adminCommand.setUsage((String) adminDefinition.get("usage"));
|
||||
}
|
||||
var particlesDefinition = plugin.getDescription().getCommands().get("fantasyparticles");
|
||||
if (particlesDefinition != null) {
|
||||
particlesCommand = constructor.newInstance("fantasyparticles", plugin);
|
||||
particlesCommand.setPermission((String) particlesDefinition.get("permission"));
|
||||
particlesCommand.permissionMessage(net.kyori.adventure.text.Component.text("Permission denied"));
|
||||
particlesCommand.setUsage((String) particlesDefinition.get("usage"));
|
||||
command = adminCommand;
|
||||
feedbackCommand = new RoutedPlayerCommand(playerCommand, "feedback");
|
||||
particlesCommand = new RoutedPlayerCommand(playerCommand, "particles");
|
||||
}
|
||||
|
||||
static final class RoutedPlayerCommand {
|
||||
private final org.bukkit.command.PluginCommand command;
|
||||
private final String route;
|
||||
RoutedPlayerCommand(org.bukkit.command.PluginCommand command, String route) {
|
||||
this.command = command;
|
||||
this.route = route;
|
||||
}
|
||||
boolean execute(org.bukkit.command.CommandSender sender, String label, String[] args) {
|
||||
return command.execute(sender, "fstick", prefixed(args));
|
||||
}
|
||||
java.util.List<String> tabComplete(org.bukkit.command.CommandSender sender, String label, String[] args) {
|
||||
return command.tabComplete(sender, "fstick", prefixed(args));
|
||||
}
|
||||
private String[] prefixed(String[] args) {
|
||||
var routed = new String[args.length + 1];
|
||||
routed[0] = route;
|
||||
System.arraycopy(args, 0, routed, 1, args.length);
|
||||
return routed;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.adventure.*;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.entity.*;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.event.player.*;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
final class FishingQuestNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@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");
|
||||
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();
|
||||
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"});drain(f);
|
||||
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",""}));
|
||||
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"});drain(f);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);
|
||||
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 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()));
|
||||
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));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();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")));
|
||||
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")));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());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nonleaderMemberSuppliesWholeRequirementAndOfflinePartySharePersists() 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);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()));
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> plain(ItemStack item){return item.getItemMeta().lore().stream().map(PlainTextComponentSerializer.plainText()::serialize).toList();}
|
||||
private static ItemStack offer(org.bukkit.inventory.Inventory menu,String name){return java.util.stream.IntStream.range(0,menu.getSize()).mapToObj(menu::getItem).filter(Objects::nonNull).filter(item->item.getItemMeta().displayName()!=null&&PlainTextComponentSerializer.plainText().serialize(item.getItemMeta().displayName()).equals(name)).findFirst().orElseThrow();}
|
||||
private static void click(AdventureQuestController controller,FantasyStickLifecycleNativeTest.Actor actor,org.bukkit.inventory.Inventory menu,ItemStack item){var event=mock(InventoryClickEvent.class);when(event.getWhoClicked()).thenReturn(actor.player);when(event.getInventory()).thenReturn(menu);when(event.getCurrentItem()).thenReturn(item);controller.click(event);}
|
||||
private static void clickFishing(FishingController controller,FantasyStickLifecycleNativeTest.Actor actor,org.bukkit.inventory.Inventory menu,ItemStack item){var event=mock(InventoryClickEvent.class);when(event.getWhoClicked()).thenReturn(actor.player);when(event.getInventory()).thenReturn(menu);when(event.getCurrentItem()).thenReturn(item);controller.click(event);}
|
||||
private static void pulse(FantasyStickLifecycleNativeTest.Fixture f){for(var pulse:List.copyOf(f.pulses))pulse.run();}
|
||||
private static void drain(FantasyStickLifecycleNativeTest.Fixture f)throws Exception{for(int attempt=0;attempt<30;attempt++){var task=f.tasks.poll(50,TimeUnit.MILLISECONDS);if(task==null)break;task.run();}}
|
||||
private static void await(FantasyStickLifecycleNativeTest.Fixture f,java.util.function.BooleanSupplier condition)throws Exception{for(int attempt=0;attempt<80&&!condition.getAsBoolean();attempt++){drain(f);Thread.sleep(5);}assertTrue(condition.getAsBoolean());}
|
||||
}
|
||||
@@ -31,8 +31,8 @@ class QuickerRegenerationNativeTest {
|
||||
var pages = nativePages(a);
|
||||
int active = first(pages, "Active abilities"), passive = first(pages, "Passive abilities");
|
||||
int activeIndex = first(pages, "Ability index"), passiveIndex = first(pages, "Passive index");
|
||||
int party = first(pages, "Party");
|
||||
assertTrue(active > 0 && active < passive && passive < activeIndex && activeIndex < passiveIndex && passiveIndex < party);
|
||||
int inventory = first(pages, "Inventory"), party = first(pages, "Party");
|
||||
assertTrue(active > 0 && active < passive && passive < activeIndex && activeIndex < passiveIndex && passiveIndex < inventory && inventory < party);
|
||||
assertTrue(pages.get(passive).getString().contains("1: No Passive"));
|
||||
assertTrue(pages.get(passiveIndex).getString().replace("\n", "").contains("Quicker Regeneration I"));
|
||||
var indexPageByPassive = new java.util.HashMap<String, Integer>();
|
||||
@@ -48,7 +48,7 @@ class QuickerRegenerationNativeTest {
|
||||
}
|
||||
assertEquals(passiveRegistry.definitions().size(), detailStartByPassive.size());
|
||||
for (var entry : detailStartByPassive.entrySet()) {
|
||||
int end = party;
|
||||
int end = inventory;
|
||||
for (int other : detailStartByPassive.values()) if (other > entry.getValue()) end = Math.min(end, other);
|
||||
var backPages = new java.util.ArrayList<Integer>();
|
||||
for (int candidate = entry.getValue(); candidate < end; candidate++)
|
||||
@@ -110,7 +110,7 @@ class QuickerRegenerationNativeTest {
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var before = storage(f).find(a.core.record.id()).get().orElseThrow(); PartyBookNativeTest.open(f, a);
|
||||
var pages = nativePages(a); assertTrue(pages.size() <= 100);
|
||||
int passiveIndex = first(pages, "Passive index"), party = first(pages, "Party");
|
||||
int passiveIndex = first(pages, "Passive index"), inventory = first(pages, "Inventory");
|
||||
var indexPageByPassive = new java.util.HashMap<String, Integer>();
|
||||
var detailStartByPassive = new java.util.HashMap<String, Integer>();
|
||||
int indexPages = 0;
|
||||
@@ -125,7 +125,7 @@ class QuickerRegenerationNativeTest {
|
||||
assertTrue(indexPages >= 2); assertEquals(registry.definitions().size(), detailStartByPassive.size());
|
||||
boolean wrapped = false;
|
||||
for (var entry : detailStartByPassive.entrySet()) {
|
||||
int end = party;
|
||||
int end = inventory;
|
||||
for (int other : detailStartByPassive.values()) if (other > entry.getValue()) end = Math.min(end, other);
|
||||
wrapped |= end - entry.getValue() > 1;
|
||||
var backPages = new java.util.ArrayList<Integer>();
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Registered saved-character lookup and exact-UUID read-only inspection. */
|
||||
class SavedCharacterAdminNativeTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void findsInactiveSavedCharactersThenShowsExactSavedStateWithoutMutation() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var administrator = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
administrator.accountName = "Admin";
|
||||
fixture.players.put(administrator.core.id, administrator.player);
|
||||
var first = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var second = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var storage = characters(fixture);
|
||||
var renamedFirst = storage.renameCharacter(first.core.record.id(), "Écho Scout").get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
storage.renameCharacter(second.core.record.id(), "Echo Keeper").get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
byte[] before = java.nio.file.Files.readAllBytes(data.resolve("characters").resolve(renamedFirst.id() + ".yml"));
|
||||
|
||||
assertTrue(fixture.adminCommand.execute(administrator.player, "fstickadmin",
|
||||
new String[]{"character", "find", "echo"}));
|
||||
drain(fixture);
|
||||
assertTrue(administrator.messages.stream().anyMatch(message -> message.contains("Echo Keeper")));
|
||||
assertFalse(administrator.messages.stream().anyMatch(message -> message.contains("Écho Scout")),
|
||||
"Literal case-insensitive matching does not strip accents");
|
||||
assertTrue(administrator.messages.stream().anyMatch(message -> message.contains(second.core.record.id().toString())));
|
||||
assertEquals(java.util.List.of("Echo Keeper"), fixture.adminCommand.tabComplete(administrator.player,
|
||||
"fstickadmin", new String[]{"character", "find", "ech"}));
|
||||
assertEquals(java.util.List.of(second.core.record.id().toString()), fixture.adminCommand.tabComplete(
|
||||
administrator.player, "fstickadmin", new String[]{"character", "show",
|
||||
second.core.record.id().toString().substring(0, 8)}));
|
||||
|
||||
administrator.messages.clear();
|
||||
fixture.adminCommand.execute(administrator.player, "fstickadmin",
|
||||
new String[]{"character", "find", "Écho"});
|
||||
fixture.adminCommand.execute(administrator.player, "fstickadmin",
|
||||
new String[]{"character", "find", "Keeper"});
|
||||
drain(fixture);
|
||||
assertFalse(administrator.messages.stream().anyMatch(message -> message.contains("Écho Scout")),
|
||||
"A superseded asynchronous search must not publish stale results");
|
||||
assertTrue(administrator.messages.stream().anyMatch(message -> message.contains("Echo Keeper")));
|
||||
|
||||
administrator.messages.clear();
|
||||
assertTrue(fixture.adminCommand.execute(administrator.player, "fstickadmin",
|
||||
new String[]{"character", "show", renamedFirst.id().toString()}));
|
||||
drain(fixture);
|
||||
assertEquals(1, administrator.books.size());
|
||||
String text = pages(administrator).stream().map(Component::getString)
|
||||
.collect(java.util.stream.Collectors.joining("\n"));
|
||||
assertTrue(text.contains("Saved state"), text);
|
||||
assertTrue(text.contains("Inactive"), text);
|
||||
String unwrapped = text.replace("\n", "");
|
||||
assertTrue(unwrapped.contains("Saved health: 10/10"), text);
|
||||
assertTrue(unwrapped.contains(renamedFirst.id().toString()), text);
|
||||
assertTrue(text.contains("Écho Scout"), text);
|
||||
assertArrayEquals(before, java.nio.file.Files.readAllBytes(
|
||||
data.resolve("characters").resolve(renamedFirst.id() + ".yml")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void savedViewUsesSavedRatherThanLiveHealthAndRevalidatesPermissionAndLatestRequest() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var administrator = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var first = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var second = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
administrator.accountName = "Admin";
|
||||
fixture.players.put(administrator.core.id, administrator.player);
|
||||
fixture.players.put(first.core.id, first.player);
|
||||
fixture.players.put(second.core.id, second.player);
|
||||
fixture.activate(first);
|
||||
first.core.health = 3;
|
||||
|
||||
fixture.adminCommand.execute(administrator.player, "fstickadmin",
|
||||
new String[]{"character", "show", first.core.record.id().toString()});
|
||||
fixture.adminCommand.execute(administrator.player, "fstickadmin",
|
||||
new String[]{"character", "show", second.core.record.id().toString()});
|
||||
drain(fixture);
|
||||
assertEquals(1, administrator.books.size(), "Only the latest saved-state request may open");
|
||||
String latest = pages(administrator).stream().map(Component::getString)
|
||||
.collect(java.util.stream.Collectors.joining("\n")).replace("\n", "");
|
||||
assertTrue(latest.contains(second.core.record.id().toString()));
|
||||
assertTrue(latest.contains("Inactive"));
|
||||
|
||||
fixture.adminCommand.execute(administrator.player, "fstickadmin",
|
||||
new String[]{"character", "show", first.core.record.id().toString()});
|
||||
characters(fixture).find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
|
||||
var response = fixture.tasks.poll();
|
||||
assertNotNull(response);
|
||||
administrator.allowed = false;
|
||||
response.run();
|
||||
assertEquals(1, administrator.books.size());
|
||||
assertTrue(administrator.messages.getLast().contains("permission changed"));
|
||||
administrator.allowed = true;
|
||||
|
||||
fixture.adminCommand.execute(administrator.player, "fstickadmin",
|
||||
new String[]{"character", "show", first.core.record.id().toString()});
|
||||
drain(fixture);
|
||||
String active = pages(administrator).stream().map(Component::getString)
|
||||
.collect(java.util.stream.Collectors.joining("\n")).replace("\n", "");
|
||||
assertTrue(active.contains("Active"));
|
||||
assertTrue(active.contains("Saved health: 10/10"), active);
|
||||
assertFalse(active.contains("Saved health: 3/10"), active);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void searchPaginatesDuplicateNamesReportsUnreadableRecordsAndSupportsConsole() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var administrator = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
administrator.accountName = "Admin";
|
||||
fixture.players.put(administrator.core.id, administrator.player);
|
||||
var storage = characters(fixture);
|
||||
var records = new java.util.ArrayList<games.dmg.fantasystick.persistence.CharacterRecord>();
|
||||
for (int index = 0; index < 10; index++) {
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
records.add(storage.renameCharacter(actor.core.record.id(), "Shared Name")
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow());
|
||||
if (index == 0) {
|
||||
actor.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets()
|
||||
.create(records.getLast()));
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
}
|
||||
}
|
||||
var dead = storage.recordDeath(records.get(1).id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
UUID corrupt = UUID.randomUUID();
|
||||
java.nio.file.Files.writeString(data.resolve("characters").resolve(corrupt + ".yml"),
|
||||
"schema-version: malformed\n");
|
||||
|
||||
fixture.adminCommand.execute(administrator.player, "fstickadmin",
|
||||
new String[]{"character", "find", "shared name"});
|
||||
drain(fixture);
|
||||
assertTrue(administrator.messages.contains("Saved character results 1/2 (incomplete: 1 unreadable)"));
|
||||
assertTrue(administrator.messages.contains("Saved character results 2/2 (incomplete: 1 unreadable)"));
|
||||
assertEquals(10, administrator.messages.stream().filter(message -> message.contains("Shared Name — ")).count());
|
||||
assertTrue(administrator.messages.stream().anyMatch(message -> message.contains(" — active")));
|
||||
assertTrue(administrator.messages.stream().anyMatch(message -> message.contains(dead.id().toString())
|
||||
&& message.contains(" — dead — inactive")));
|
||||
assertEquals(10, administrator.messageComponents.stream()
|
||||
.filter(component -> component.clickEvent() != null && component.clickEvent().action()
|
||||
== net.kyori.adventure.text.event.ClickEvent.Action.SUGGEST_COMMAND)
|
||||
.count());
|
||||
|
||||
var console = new EquipmentAdminCommandNativeTest.Sender();
|
||||
fixture.adminCommand.execute(console.port, "fstickadmin",
|
||||
new String[]{"character", "find", "Shared"});
|
||||
drain(fixture);
|
||||
assertTrue(console.messages.stream().anyMatch(message -> message.contains("Saved character results 1/2")));
|
||||
fixture.adminCommand.execute(console.port, "fstickadmin",
|
||||
new String[]{"character", "show", records.getFirst().id().toString()});
|
||||
assertTrue(console.messages.getLast().contains("in-game administrator"));
|
||||
|
||||
int opened = administrator.books.size();
|
||||
fixture.adminCommand.execute(administrator.player, "fstickadmin",
|
||||
new String[]{"character", "show", dead.id().toString()});
|
||||
drain(fixture);
|
||||
assertEquals(opened + 1, administrator.books.size());
|
||||
String deadText = pages(administrator).stream().map(Component::getString)
|
||||
.collect(java.util.stream.Collectors.joining("\n")).replace("\n", "");
|
||||
assertTrue(deadText.contains("Dead"));
|
||||
assertTrue(deadText.contains(dead.deathId().toString()));
|
||||
assertTrue(deadText.contains(dead.diedAt().toString()));
|
||||
|
||||
fixture.adminCommand.execute(administrator.player, "fstickadmin",
|
||||
new String[]{"character", "show", corrupt.toString()});
|
||||
drain(fixture);
|
||||
assertEquals(opened + 1, administrator.books.size());
|
||||
assertTrue(administrator.messages.getLast().contains("could not be loaded"));
|
||||
fixture.adminCommand.execute(administrator.player, "fstickadmin",
|
||||
new String[]{"character", "show", UUID.randomUUID().toString()});
|
||||
drain(fixture);
|
||||
assertEquals(opened + 1, administrator.books.size());
|
||||
assertTrue(administrator.messages.getLast().contains("not found"));
|
||||
}
|
||||
}
|
||||
|
||||
private static games.dmg.fantasystick.persistence.CharacterStorage characters(
|
||||
FantasyStickLifecycleNativeTest.Fixture fixture) {
|
||||
return (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
|
||||
}
|
||||
|
||||
private static void drain(FantasyStickLifecycleNativeTest.Fixture fixture) throws Exception {
|
||||
characters(fixture).find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
|
||||
Runnable callback;
|
||||
while ((callback = fixture.tasks.poll()) != null) callback.run();
|
||||
}
|
||||
|
||||
private static java.util.List<Component> pages(FantasyStickLifecycleNativeTest.Actor actor) {
|
||||
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
|
||||
assertNotNull(content);
|
||||
return content.pages().stream().map(page -> page.raw()).toList();
|
||||
}
|
||||
}
|
||||
@@ -29,23 +29,31 @@ class DistributionTest {
|
||||
assertEquals("games.dmg.fantasystick.FantasyStickPlugin", metadata.getMain());
|
||||
assertNotNull(metadata.getAPIVersion(), "Declare the native API compatibility contract");
|
||||
assertEquals(java.util.List.of("ProtocolLib"), metadata.getDepend());
|
||||
assertEquals(java.util.Set.of("fantasystick", "fantasyfeedback", "fantasyparticles"),
|
||||
metadata.getCommands().keySet());
|
||||
assertNull(metadata.getCommands().get("fantasystick").get("permission"),
|
||||
"Player adventure inspection must reach command-level authorization");
|
||||
assertNull(metadata.getCommands().get("fantasyfeedback").get("permission"));
|
||||
assertNull(metadata.getCommands().get("fantasyparticles").get("permission"));
|
||||
assertEquals("/fantasyfeedback [on|off]", metadata.getCommands().get("fantasyfeedback").get("usage"));
|
||||
assertEquals("/fantasyparticles <all|players> [on|off]",
|
||||
metadata.getCommands().get("fantasyparticles").get("usage"));
|
||||
assertTrue(metadata.getCommands().get("fantasystick").get("usage").toString().contains("adventure-site"));
|
||||
assertTrue(metadata.getCommands().get("fantasystick").get("usage").toString().contains("quest-giver"));
|
||||
assertTrue(metadata.getCommands().get("fantasystick").get("usage").toString()
|
||||
assertEquals(java.util.Set.of("fstick", "fstickadmin"), metadata.getCommands().keySet());
|
||||
assertNull(metadata.getCommands().get("fstick").get("permission"),
|
||||
"Personal player controls perform their own sender/context checks");
|
||||
assertEquals("fantasystick.admin", metadata.getCommands().get("fstickadmin").get("permission"));
|
||||
assertTrue(metadata.getCommands().get("fstick").get("usage").toString().contains("feedback [on|off]"));
|
||||
assertTrue(metadata.getCommands().get("fstick").get("usage").toString()
|
||||
.contains("particles <all|players> [on|off]"));
|
||||
assertTrue(metadata.getCommands().get("fstickadmin").get("usage").toString().contains("adventure-site"));
|
||||
assertTrue(metadata.getCommands().get("fstickadmin").get("usage").toString().contains("quest-giver"));
|
||||
assertTrue(metadata.getCommands().get("fstickadmin").get("usage").toString()
|
||||
.contains("character view <online-player>"));
|
||||
assertEquals(1, metadata.getPermissions().size());
|
||||
assertEquals("fantasystick.admin", metadata.getPermissions().getFirst().getName());
|
||||
assertEquals(org.bukkit.permissions.PermissionDefault.OP, metadata.getPermissions().getFirst().getDefault());
|
||||
var directParticleEmitters = jar.stream().filter(entry -> entry.getName().endsWith(".class"))
|
||||
var staleCommandReferences = jar.stream().filter(entry -> entry.getName().endsWith(".class"))
|
||||
.filter(entry -> {
|
||||
try (var input = jar.getInputStream(entry)) {
|
||||
String bytes = new String(input.readAllBytes(), java.nio.charset.StandardCharsets.ISO_8859_1);
|
||||
return bytes.contains("/fantasystick ") || bytes.contains("/fantasyfeedback")
|
||||
|| bytes.contains("/fantasyparticles");
|
||||
} catch (java.io.IOException failure) { throw new java.io.UncheckedIOException(failure); }
|
||||
}).map(java.util.zip.ZipEntry::getName).collect(java.util.stream.Collectors.toSet());
|
||||
assertTrue(staleCommandReferences.isEmpty(),
|
||||
"Packaged runtime guidance must not advertise removed command roots: " + staleCommandReferences);
|
||||
var directParticleEmitters = jar.stream().filter(entry -> entry.getName().endsWith(".class")).filter(entry -> entry.getName().endsWith(".class"))
|
||||
.filter(entry -> {
|
||||
try (var input = jar.getInputStream(entry)) {
|
||||
return new String(input.readAllBytes(), java.nio.charset.StandardCharsets.ISO_8859_1)
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package games.dmg.fantasystick.adventure;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.persistence.AdventureQuestSettings;
|
||||
import games.dmg.fantasystick.persistence.YamlAdventureQuestRepository;
|
||||
import java.math.BigInteger;
|
||||
import java.time.*;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
final class AdventureLegacyRewardRetentionTest {
|
||||
@TempDir java.nio.file.Path data;
|
||||
|
||||
@Test void existingBankedAllocationSurvivesRerollSiteRemovalAndRestartWithoutRetroactiveTrophy() throws Exception {
|
||||
var rules=new AdventureQuestSettings.Rules(1,16,30,8,BigInteger.valueOf(100),BigInteger.valueOf(20),100,8,BigInteger.valueOf(150),BigInteger.valueOf(40));
|
||||
var clock=Clock.fixed(Instant.parse("2026-09-23T12:00:00Z"),ZoneOffset.UTC);UUID character=UUID.randomUUID(),siteId,claimId;
|
||||
try(var directory=new AdventureQuestDirectory(new YamlAdventureQuestRepository(data),clock,()->rules,new Random(1))){
|
||||
var site=directory.placeSite(AdventureQuestState.SiteType.CHAMPION,"Legacy Summit",UUID.randomUUID(),0,64,0).get(5,TimeUnit.SECONDS);siteId=site.id();
|
||||
var catalog=directory.ensureToday().get(5,TimeUnit.SECONDS);var quest=directory.accept(catalog.revision(),site.id(),AdventureQuestState.UnitType.SOLO,character,Set.of(character)).get(5,TimeUnit.SECONDS).quest().orElseThrow();
|
||||
assertTrue(directory.bankAndComplete(quest.id(),UUID.randomUUID(),Map.of(character,new AdventureQuestDirectory.Reward(BigInteger.valueOf(150),BigInteger.valueOf(40)))).get(5,TimeUnit.SECONDS));
|
||||
assertEquals(new AdventureQuestDirectory.Reward(BigInteger.valueOf(150),BigInteger.valueOf(40)),directory.unclaimedAll(character));
|
||||
claimId=directory.beginLegacyClaim(character).get(5,TimeUnit.SECONDS).orElseThrow().id();
|
||||
directory.rerollToday(catalog.revision()).get(5,TimeUnit.SECONDS);directory.completeReset(directory.snapshot().reset().operation()).get(5,TimeUnit.SECONDS);directory.removeSite(site.id()).get(5,TimeUnit.SECONDS);
|
||||
assertEquals(new AdventureQuestDirectory.Reward(BigInteger.valueOf(150),BigInteger.valueOf(40)),directory.unclaimedAll(character));
|
||||
}
|
||||
try(var restarted=new AdventureQuestDirectory(new YamlAdventureQuestRepository(data),clock,()->rules,new Random(2))){
|
||||
assertTrue(restarted.findSite(siteId).isEmpty());
|
||||
var claim=restarted.beginLegacyClaim(character).get(5,TimeUnit.SECONDS).orElseThrow();assertEquals(claimId,claim.id(),"in-progress claim proof survives reset and restart");
|
||||
assertEquals(BigInteger.valueOf(150),claim.xp());assertEquals(BigInteger.valueOf(40),claim.coins());
|
||||
assertEquals(claim,restarted.beginLegacyClaim(character).get(5,TimeUnit.SECONDS).orElseThrow());
|
||||
assertTrue(restarted.finishClaim(claim.id()).get(5,TimeUnit.SECONDS));
|
||||
assertEquals(AdventureQuestDirectory.Reward.empty(),restarted.unclaimedAll(character));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package games.dmg.fantasystick.adventure;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.dungeon.DungeonDirectory;
|
||||
import games.dmg.fantasystick.persistence.AdventureQuestSettings;
|
||||
import games.dmg.fantasystick.persistence.YamlAdventureQuestRepository;
|
||||
import games.dmg.fantasystick.persistence.YamlDungeonRepository;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
final class AdventureParticipationModeTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void sitesDefaultPersistUpdateAndCaptureParticipationMode() throws Exception {
|
||||
var rules = new AdventureQuestSettings.Rules(3, 16, 30, 8, BigInteger.valueOf(100),
|
||||
BigInteger.valueOf(20), 100, 8, BigInteger.valueOf(150), BigInteger.valueOf(40));
|
||||
var clock = Clock.fixed(Instant.parse("2026-09-23T19:00:00Z"), ZoneOffset.UTC);
|
||||
UUID siteId;
|
||||
try (var directory = new AdventureQuestDirectory(new YamlAdventureQuestRepository(data), clock,
|
||||
() -> rules, new Random(1))) {
|
||||
var site = directory.placeSite(AdventureQuestState.SiteType.CAMP, "Mode Camp", UUID.randomUUID(),
|
||||
0, 64, 0).get(5, TimeUnit.SECONDS);
|
||||
siteId = site.id();
|
||||
assertEquals(AdventureQuestState.ParticipationMode.PARTY_AVAILABLE, site.participationMode());
|
||||
var changed = directory.setSiteMode(site.id(), site.participationMode(),
|
||||
AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY).get(5, TimeUnit.SECONDS);
|
||||
assertEquals(AdventureQuestDirectory.ModeStatus.APPLIED, changed.status());
|
||||
assertEquals(AdventureQuestDirectory.ModeStatus.STALE, directory.setSiteMode(site.id(),
|
||||
AdventureQuestState.ParticipationMode.PARTY_AVAILABLE,
|
||||
AdventureQuestState.ParticipationMode.PARTY_REQUIRED).get(5, TimeUnit.SECONDS).status());
|
||||
var catalog = directory.ensureToday().get(5, TimeUnit.SECONDS);
|
||||
UUID character = UUID.randomUUID(), party = UUID.randomUUID();
|
||||
assertEquals(AdventureQuestDirectory.Acceptance.POLICY, directory.accept(catalog.revision(), site.id(),
|
||||
AdventureQuestState.UnitType.PARTY, party, Set.of(character),
|
||||
AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY).get(5, TimeUnit.SECONDS).status());
|
||||
var quest = directory.accept(catalog.revision(), site.id(), AdventureQuestState.UnitType.SOLO,
|
||||
character, Set.of(character), AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY)
|
||||
.get(5, TimeUnit.SECONDS).quest().orElseThrow();
|
||||
assertEquals(AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY, quest.participationMode());
|
||||
}
|
||||
try (var restarted = new AdventureQuestDirectory(new YamlAdventureQuestRepository(data), clock,
|
||||
() -> rules, new Random(2))) {
|
||||
assertEquals(AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY,
|
||||
restarted.findSite(siteId).orElseThrow().participationMode());
|
||||
assertEquals(AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY,
|
||||
restarted.active().getFirst().participationMode());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void compatibleSiteAndDungeonSavesDefaultToPartyAvailable() throws Exception {
|
||||
Files.writeString(data.resolve("adventure-quests.yml"), """
|
||||
schema-version: 1
|
||||
sites:
|
||||
- id: '00000000-0000-0000-0000-000000000001'
|
||||
type: CAMP
|
||||
name: Legacy
|
||||
world: '00000000-0000-0000-0000-000000000002'
|
||||
x: '0.0'
|
||||
y: '64.0'
|
||||
z: '0.0'
|
||||
givers: []
|
||||
catalog: null
|
||||
quests: []
|
||||
unit-claims: []
|
||||
character-claims: []
|
||||
rosters: []
|
||||
tracking: []
|
||||
rewards: {allocations: [], operations: [], receipts: []}
|
||||
reset: null
|
||||
""");
|
||||
assertEquals(AdventureQuestState.ParticipationMode.PARTY_AVAILABLE,
|
||||
new YamlAdventureQuestRepository(data).load().sites().getFirst().participationMode());
|
||||
|
||||
UUID dungeonId;
|
||||
try (var dungeons = new DungeonDirectory(new YamlDungeonRepository(data))) {
|
||||
var dungeon = dungeons.create("Modes", UUID.randomUUID(), 0, 64, 0, 0, 0,
|
||||
AdventureQuestState.ParticipationMode.PARTY_REQUIRED).get(5, TimeUnit.SECONDS);
|
||||
dungeonId = dungeon.id();
|
||||
assertEquals(AdventureQuestState.ParticipationMode.PARTY_REQUIRED, dungeon.participationMode());
|
||||
var updated = dungeons.setMode(dungeon.id(), dungeon.revision(),
|
||||
AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY).get(5, TimeUnit.SECONDS);
|
||||
assertEquals(AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY, updated.participationMode());
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () -> dungeons.setMode(dungeon.id(),
|
||||
dungeon.revision(), AdventureQuestState.ParticipationMode.PARTY_AVAILABLE).get(5, TimeUnit.SECONDS));
|
||||
}
|
||||
var dungeonFile = data.resolve("dungeons.yml");
|
||||
Files.writeString(dungeonFile, Files.readString(dungeonFile)
|
||||
.replaceFirst("(?m)^ participation-mode: individual-only\\R", ""));
|
||||
assertEquals(AdventureQuestState.ParticipationMode.PARTY_AVAILABLE,
|
||||
new YamlDungeonRepository(data).load().dungeons().stream()
|
||||
.filter(value -> value.id().equals(dungeonId)).findFirst().orElseThrow().participationMode());
|
||||
}
|
||||
|
||||
@Test void legacyAcceptedDungeonTermsMigrateToTheBossCompletionPoolOnce() throws Exception {
|
||||
var repository=new YamlAdventureQuestRepository(data);UUID siteId=UUID.randomUUID(),catalogId=UUID.randomUUID(),character=UUID.randomUUID(),questId=UUID.randomUUID();var expires=Instant.parse("2026-09-24T00:00:00Z");
|
||||
var site=new AdventureQuestState.Site(siteId,AdventureQuestState.SiteType.DUNGEON,"Legacy",UUID.randomUUID(),0,64,0,null);
|
||||
var quest=new AdventureQuestState.Quest(questId,catalogId,siteId,AdventureQuestState.UnitType.SOLO,character,Set.of(character),expires,BigInteger.valueOf(130),BigInteger.valueOf(13));
|
||||
repository.save(new AdventureQuestState(List.of(site),List.of(),new AdventureQuestState.Catalog(java.time.LocalDate.parse("2026-09-23"),catalogId,Instant.parse("2026-09-23T00:00:00Z"),List.of(siteId)),List.of(quest),Set.of(),Set.of(),java.util.Map.of()));
|
||||
var file=data.resolve("adventure-quests.yml");Files.writeString(file,Files.readString(file).replaceFirst("schema-version: 2","schema-version: 1"));var legacyRepository=new YamlAdventureQuestRepository(data);var bossRoom=UUID.randomUUID();
|
||||
var rules=new AdventureQuestSettings.Rules(1,16,30,8,BigInteger.valueOf(100),BigInteger.valueOf(20),100,8,BigInteger.valueOf(150),BigInteger.valueOf(40));try(var directory=new AdventureQuestDirectory(legacyRepository,Clock.systemUTC(),()->rules,new Random(1))){assertTrue(legacyRepository.requiresDungeonRewardMigration());assertTrue(directory.migrateLegacyDungeonRewards(id->id.equals(siteId)?Optional.of(new AdventureQuestDirectory.LegacyDungeonReward(new AdventureQuestDirectory.Reward(BigInteger.valueOf(40),BigInteger.valueOf(4)),bossRoom,false)):Optional.empty()).get(5,TimeUnit.SECONDS));assertEquals(BigInteger.valueOf(40),directory.findQuest(questId).orElseThrow().rewardXp());assertEquals(BigInteger.valueOf(4),directory.findQuest(questId).orElseThrow().rewardCoins());assertFalse(legacyRepository.requiresDungeonRewardMigration());}
|
||||
var currentRepository=new YamlAdventureQuestRepository(data);currentRepository.load();assertFalse(currentRepository.requiresDungeonRewardMigration());
|
||||
}
|
||||
|
||||
@Test void legacyBankedBossSourceCompletesWithoutMintingAnotherEntitlement() throws Exception {
|
||||
var root=data.resolve("banked");Files.createDirectories(root);var repository=new YamlAdventureQuestRepository(root);UUID siteId=UUID.randomUUID(),catalogId=UUID.randomUUID(),character=UUID.randomUUID(),questId=UUID.randomUUID(),bossRoom=UUID.randomUUID();var expires=Instant.parse("2026-09-24T00:00:00Z");var site=new AdventureQuestState.Site(siteId,AdventureQuestState.SiteType.DUNGEON,"Legacy",UUID.randomUUID(),0,64,0,null);var quest=new AdventureQuestState.Quest(questId,catalogId,siteId,AdventureQuestState.UnitType.SOLO,character,Set.of(character),expires,BigInteger.valueOf(130),BigInteger.valueOf(13));var source=UUID.nameUUIDFromBytes((catalogId+":"+siteId+":"+bossRoom+":"+questId).getBytes(java.nio.charset.StandardCharsets.UTF_8));var allocation=new AdventureQuestState.RewardAllocation(UUID.randomUUID(),catalogId,siteId,questId,source,character,BigInteger.valueOf(40),BigInteger.valueOf(4));var state=new AdventureQuestState(List.of(site),List.of(),new AdventureQuestState.Catalog(java.time.LocalDate.parse("2026-09-23"),catalogId,Instant.parse("2026-09-23T00:00:00Z"),List.of(siteId)),List.of(quest),Set.of(),Set.of(),java.util.Map.of(),Set.of(),new AdventureQuestState.RewardLedger(List.of(allocation),List.of(),Set.of()),null);repository.save(state);var file=root.resolve("adventure-quests.yml");Files.writeString(file,Files.readString(file).replaceFirst("schema-version: 2","schema-version: 1"));var legacyRepository=new YamlAdventureQuestRepository(root);var rules=new AdventureQuestSettings.Rules(1,16,30,8,BigInteger.valueOf(100),BigInteger.valueOf(20),100,8,BigInteger.valueOf(150),BigInteger.valueOf(40));try(var directory=new AdventureQuestDirectory(legacyRepository,Clock.systemUTC(),()->rules,new Random(1))){assertTrue(directory.migrateLegacyDungeonRewards(id->Optional.of(new AdventureQuestDirectory.LegacyDungeonReward(new AdventureQuestDirectory.Reward(BigInteger.valueOf(40),BigInteger.valueOf(4)),bossRoom,false))).get(5,TimeUnit.SECONDS));assertTrue(directory.findQuest(questId).isEmpty());assertTrue(directory.characterClaimed(catalogId,siteId,character));assertEquals(new AdventureQuestDirectory.Reward(BigInteger.valueOf(40),BigInteger.valueOf(4)),directory.unclaimed(catalogId,siteId,character));}
|
||||
}
|
||||
|
||||
@Test void failedModeWritesRetainLastAcknowledgedSiteAndDungeonState() throws Exception {
|
||||
var rules = new AdventureQuestSettings.Rules(1, 16, 30, 8, BigInteger.valueOf(100),
|
||||
BigInteger.valueOf(20), 100, 8, BigInteger.valueOf(150), BigInteger.valueOf(40));
|
||||
try (var directory = new AdventureQuestDirectory(new YamlAdventureQuestRepository(data), Clock.systemUTC(),
|
||||
() -> rules, new Random(1))) {
|
||||
var site = directory.placeSite(AdventureQuestState.SiteType.CAMP, "Retained", UUID.randomUUID(),
|
||||
0, 64, 0).get(5, TimeUnit.SECONDS);
|
||||
var file = data.resolve("adventure-quests.yml");
|
||||
Files.delete(file); Files.createDirectory(file);
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () -> directory.setSiteMode(site.id(),
|
||||
AdventureQuestState.ParticipationMode.PARTY_AVAILABLE,
|
||||
AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY).get(5, TimeUnit.SECONDS));
|
||||
assertEquals(AdventureQuestState.ParticipationMode.PARTY_AVAILABLE,
|
||||
directory.findSite(site.id()).orElseThrow().participationMode());
|
||||
}
|
||||
var dungeonRoot = data.resolve("dungeon-failure");
|
||||
try (var dungeons = new DungeonDirectory(new YamlDungeonRepository(dungeonRoot))) {
|
||||
var dungeon = dungeons.create("Retained", UUID.randomUUID(), 0, 64, 0, 0, 0)
|
||||
.get(5, TimeUnit.SECONDS);
|
||||
var file = dungeonRoot.resolve("dungeons.yml");
|
||||
Files.delete(file); Files.createDirectory(file);
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () -> dungeons.setMode(dungeon.id(),
|
||||
dungeon.revision(), AdventureQuestState.ParticipationMode.PARTY_REQUIRED)
|
||||
.get(5, TimeUnit.SECONDS));
|
||||
assertEquals(AdventureQuestState.ParticipationMode.PARTY_AVAILABLE,
|
||||
dungeons.find(dungeon.id()).orElseThrow().participationMode());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void liveModeChangesGovernNewAcceptanceWithoutReinterpretingExistingQuest() throws Exception {
|
||||
var rules = new AdventureQuestSettings.Rules(1, 16, 30, 8, BigInteger.valueOf(100),
|
||||
BigInteger.valueOf(20), 100, 8, BigInteger.valueOf(150), BigInteger.valueOf(40));
|
||||
var clock = Clock.fixed(Instant.parse("2026-09-23T19:00:00Z"), ZoneOffset.UTC);
|
||||
try (var directory = new AdventureQuestDirectory(new YamlAdventureQuestRepository(data), clock,
|
||||
() -> rules, new Random(1))) {
|
||||
var site = directory.placeSite(AdventureQuestState.SiteType.CHAMPION, "Changing", UUID.randomUUID(),
|
||||
0, 64, 0).get(5, TimeUnit.SECONDS);
|
||||
var catalog = directory.ensureToday().get(5, TimeUnit.SECONDS);
|
||||
UUID first = UUID.randomUUID(), second = UUID.randomUUID(), party = UUID.randomUUID();
|
||||
var existing = directory.accept(catalog.revision(), site.id(), AdventureQuestState.UnitType.SOLO,
|
||||
first, Set.of(first), AdventureQuestState.ParticipationMode.PARTY_AVAILABLE)
|
||||
.get(5, TimeUnit.SECONDS).quest().orElseThrow();
|
||||
directory.setSiteMode(site.id(), AdventureQuestState.ParticipationMode.PARTY_AVAILABLE,
|
||||
AdventureQuestState.ParticipationMode.PARTY_REQUIRED).get(5, TimeUnit.SECONDS);
|
||||
assertEquals(AdventureQuestState.ParticipationMode.PARTY_AVAILABLE, existing.participationMode());
|
||||
assertEquals(AdventureQuestDirectory.Acceptance.STALE, directory.accept(catalog.revision(), site.id(),
|
||||
AdventureQuestState.UnitType.PARTY, party, Set.of(second),
|
||||
AdventureQuestState.ParticipationMode.PARTY_AVAILABLE).get(5, TimeUnit.SECONDS).status());
|
||||
var replacement = directory.accept(catalog.revision(), site.id(), AdventureQuestState.UnitType.PARTY,
|
||||
party, Set.of(second), AdventureQuestState.ParticipationMode.PARTY_REQUIRED)
|
||||
.get(5, TimeUnit.SECONDS).quest().orElseThrow();
|
||||
assertEquals(AdventureQuestState.ParticipationMode.PARTY_REQUIRED, replacement.participationMode());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void personalAndPartyCommitmentsCannotOverlapCharacterSnapshots() throws Exception {
|
||||
var rules = new AdventureQuestSettings.Rules(2, 16, 30, 8, BigInteger.valueOf(100),
|
||||
BigInteger.valueOf(20), 100, 8, BigInteger.valueOf(150), BigInteger.valueOf(40));
|
||||
var clock = Clock.fixed(Instant.parse("2026-09-23T19:00:00Z"), ZoneOffset.UTC);
|
||||
try (var directory = new AdventureQuestDirectory(new YamlAdventureQuestRepository(data), clock,
|
||||
() -> rules, new Random(1))) {
|
||||
var first = directory.placeSite(AdventureQuestState.SiteType.CAMP, "First", UUID.randomUUID(),
|
||||
0, 64, 0).get(5, TimeUnit.SECONDS);
|
||||
var second = directory.placeSite(AdventureQuestState.SiteType.CHAMPION, "Second", UUID.randomUUID(),
|
||||
20, 64, 0).get(5, TimeUnit.SECONDS);
|
||||
var catalog = directory.ensureToday().get(5, TimeUnit.SECONDS);
|
||||
UUID leader = UUID.randomUUID(), member = UUID.randomUUID(), party = UUID.randomUUID();
|
||||
assertEquals(AdventureQuestDirectory.Acceptance.APPLIED, directory.accept(catalog.revision(), first.id(),
|
||||
AdventureQuestState.UnitType.PARTY, party, Set.of(leader, member)).get(5, TimeUnit.SECONDS).status());
|
||||
assertEquals(AdventureQuestDirectory.Acceptance.CONFLICT, directory.accept(catalog.revision(), second.id(),
|
||||
AdventureQuestState.UnitType.SOLO, member, Set.of(member)).get(5, TimeUnit.SECONDS).status());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,9 +239,9 @@ class AdventureQuestDirectoryTest {
|
||||
var otherSite=catalog.sites().stream().filter(value->!value.equals(site)).findFirst().orElseThrow();UUID solo=UUID.randomUUID();var partial=restarted.accept(catalog.revision(),otherSite,AdventureQuestState.UnitType.SOLO,solo,Set.of(solo)).get(5,TimeUnit.SECONDS).quest().orElseThrow();
|
||||
assertTrue(restarted.bank(partial.id(),UUID.randomUUID(),Map.of(solo,new AdventureQuestDirectory.Reward(BigInteger.ONE,BigInteger.ONE))).get(5,TimeUnit.SECONDS));
|
||||
assertTrue(restarted.abandon(partial.id(),solo).get(5,TimeUnit.SECONDS));assertEquals(BigInteger.ONE,restarted.unclaimed(catalog.revision(),otherSite,solo).xp(),"ordinary quest ending retains earned allocations");
|
||||
var reset=restarted.rerollToday(catalog.revision()).get(5,TimeUnit.SECONDS);assertEquals(AdventureQuestDirectory.RerollStatus.APPLIED,reset.status());assertTrue(restarted.snapshot().rewards().allocations().isEmpty());assertTrue(restarted.snapshot().rewards().operations().isEmpty());assertTrue(restarted.snapshot().rewards().receipts().isEmpty());var replacement=reset.catalog().orElseThrow();assertTrue(restarted.completeReset(restarted.snapshot().reset().operation()).get(5,TimeUnit.SECONDS));
|
||||
var reset=restarted.rerollToday(catalog.revision()).get(5,TimeUnit.SECONDS);assertEquals(AdventureQuestDirectory.RerollStatus.APPLIED,reset.status());assertEquals(2,restarted.snapshot().rewards().allocations().size(),"earned legacy allocations no longer expire on full reroll");assertTrue(restarted.snapshot().rewards().operations().isEmpty());assertFalse(restarted.snapshot().rewards().receipts().isEmpty());assertEquals(BigInteger.valueOf(49),restarted.unclaimedAll(second).xp());assertEquals(BigInteger.ONE,restarted.unclaimedAll(solo).xp());var replacement=reset.catalog().orElseThrow();assertTrue(restarted.completeReset(restarted.snapshot().reset().operation()).get(5,TimeUnit.SECONDS));
|
||||
UUID zero=UUID.randomUUID();var zeroQuest=restarted.accept(replacement.revision(),replacement.sites().getFirst(),AdventureQuestState.UnitType.SOLO,zero,Set.of(zero)).get(5,TimeUnit.SECONDS).quest().orElseThrow();assertTrue(restarted.bankAndComplete(zeroQuest.id(),zeroQuest.id(),Map.of(zero,new AdventureQuestDirectory.Reward(BigInteger.ZERO,BigInteger.ZERO))).get(5,TimeUnit.SECONDS));assertEquals(BigInteger.ZERO,restarted.unclaimed(replacement.revision(),zeroQuest.site(),zero).xp());assertTrue(restarted.characterClaimed(replacement.revision(),zeroQuest.site(),zero));assertFalse(restarted.rewardClaimed(replacement.revision(),zeroQuest.site(),zero));
|
||||
UUID expiring=UUID.randomUUID();var expiringQuest=restarted.accept(replacement.revision(),replacement.sites().getLast(),AdventureQuestState.UnitType.SOLO,expiring,Set.of(expiring)).get(5,TimeUnit.SECONDS).quest().orElseThrow();assertTrue(restarted.bank(expiringQuest.id(),UUID.randomUUID(),Map.of(expiring,new AdventureQuestDirectory.Reward(BigInteger.TEN,BigInteger.ONE))).get(5,TimeUnit.SECONDS));clock.advance(Duration.ofDays(1));restarted.ensureToday().get(5,TimeUnit.SECONDS);assertTrue(restarted.snapshot().rewards().allocations().isEmpty(),"midnight expires unclaimed rewards");
|
||||
UUID expiring=UUID.randomUUID();var expiringQuest=restarted.accept(replacement.revision(),replacement.sites().getLast(),AdventureQuestState.UnitType.SOLO,expiring,Set.of(expiring)).get(5,TimeUnit.SECONDS).quest().orElseThrow();assertTrue(restarted.bank(expiringQuest.id(),UUID.randomUUID(),Map.of(expiring,new AdventureQuestDirectory.Reward(BigInteger.TEN,BigInteger.ONE))).get(5,TimeUnit.SECONDS));clock.advance(Duration.ofDays(1));restarted.ensureToday().get(5,TimeUnit.SECONDS);assertEquals(BigInteger.TEN,restarted.unclaimedAll(expiring).xp(),"midnight preserves already earned legacy rewards");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package games.dmg.fantasystick.adventure;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.persistence.YamlFishingRepository;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
final class ChampionTrophyDirectoryTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void earnedHeadPersistsWithFrozenCarrierBeneficiariesAndUnlocksExactlyOnce() throws Exception {
|
||||
UUID trophyId=UUID.randomUUID(),carrier=UUID.randomUUID(),beneficiary=UUID.randomUUID();
|
||||
var rewards=new LinkedHashMap<UUID,FishingState.Reward>();
|
||||
rewards.put(beneficiary,new FishingState.Reward(BigInteger.valueOf(150),BigInteger.valueOf(40)));
|
||||
var trophy=new FishingState.Trophy(trophyId,FishingState.TrophyType.CHAMPION,carrier,UUID.randomUUID(),
|
||||
UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),"Daybreak Champion Head","Summit",rewards);
|
||||
try(var directory=new FishingDirectory(new YamlFishingRepository(data))){
|
||||
assertEquals(FishingDirectory.TrophyPrepareStatus.APPLIED,directory.prepareTrophy(trophy).get(5,TimeUnit.SECONDS));
|
||||
assertEquals(FishingDirectory.TrophyPrepareStatus.EXISTING,directory.prepareTrophy(trophy).get(5,TimeUnit.SECONDS));
|
||||
assertTrue(directory.trophies(carrier).isEmpty(),"prepared proof is not redeemable before quest completion");
|
||||
var laterAttempt=new FishingState.Trophy(trophy.id(),trophy.type(),trophy.carrier(),trophy.catalog(),trophy.site(),trophy.quest(),UUID.randomUUID(),trophy.name(),trophy.origin(),trophy.rewards());
|
||||
assertEquals(FishingDirectory.TrophyPrepareStatus.EXISTING,directory.prepareTrophy(laterAttempt).get(5,TimeUnit.SECONDS));assertEquals(trophy.source(),directory.snapshot().trophies().getFirst().source(),"first durable completion authority wins repeated attempt races");
|
||||
assertEquals(FishingDirectory.TrophyCommitStatus.APPLIED,directory.commitTrophy(trophyId).get(5,TimeUnit.SECONDS));
|
||||
assertEquals(trophy.earned(),directory.trophies(carrier).getFirst());
|
||||
assertEquals(FishingState.Reward.empty(),directory.unclaimed(beneficiary),"custody does not unlock or redirect shares");
|
||||
assertEquals(FishingDirectory.TrophyRedeemStatus.WRONG_CARRIER,directory.redeemTrophy(trophyId,beneficiary).get(5,TimeUnit.SECONDS));
|
||||
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));
|
||||
assertEquals(FishingDirectory.TrophyRedeemStatus.REDEEMED,directory.redeemTrophy(trophyId,carrier).get(5,TimeUnit.SECONDS));
|
||||
assertEquals(FishingDirectory.TrophyPrepareStatus.REDEEMED,directory.prepareTrophy(trophy).get(5,TimeUnit.SECONDS));
|
||||
}
|
||||
try(var restarted=new FishingDirectory(new YamlFishingRepository(data))){
|
||||
assertTrue(restarted.trophies(carrier).isEmpty());
|
||||
assertEquals(new FishingState.Reward(BigInteger.valueOf(150),BigInteger.valueOf(40)),restarted.unclaimed(beneficiary));
|
||||
assertEquals(FishingDirectory.TrophyRedeemStatus.REDEEMED,restarted.redeemTrophy(trophyId,carrier).get(5,TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@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());}
|
||||
}
|
||||
|
||||
@Test void preparedTrophyProofSurvivesRestartWithoutBecomingRedeemable() throws Exception {
|
||||
UUID carrier=UUID.randomUUID();var trophy=new FishingState.Trophy(UUID.randomUUID(),FishingState.TrophyType.CHAMPION,carrier,UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),"Daybreak Champion Head","Restart Summit",java.util.Map.of(carrier,new FishingState.Reward(BigInteger.ONE,BigInteger.ONE)));
|
||||
try(var directory=new FishingDirectory(new YamlFishingRepository(data))){assertEquals(FishingDirectory.TrophyPrepareStatus.APPLIED,directory.prepareTrophy(trophy).get(5,TimeUnit.SECONDS));assertTrue(directory.trophies(carrier).isEmpty());}
|
||||
try(var restarted=new FishingDirectory(new YamlFishingRepository(data))){assertEquals(FishingState.TrophyPhase.PREPARED,restarted.snapshot().trophies().getFirst().phase());assertTrue(restarted.trophies(carrier).isEmpty());assertEquals(FishingDirectory.TrophyRedeemStatus.NOT_FOUND,restarted.redeemTrophy(trophy.id(),carrier).get(5,TimeUnit.SECONDS));assertEquals(FishingDirectory.TrophyCommitStatus.APPLIED,restarted.commitTrophy(trophy.id()).get(5,TimeUnit.SECONDS));assertEquals(trophy.earned(),restarted.trophies(carrier).getFirst());}
|
||||
}
|
||||
|
||||
@Test void sameNameDungeonTrophiesRemainDistinctByStableSiteAndReceiptIdentity() throws Exception {
|
||||
UUID carrier=UUID.randomUUID(),beneficiary=UUID.randomUUID();
|
||||
try(var directory=new FishingDirectory(new YamlFishingRepository(data))){
|
||||
for(int index=0;index<2;index++){var trophy=new FishingState.Trophy(UUID.randomUUID(),FishingState.TrophyType.DUNGEON,carrier,UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),"Trophy of Twin Vault","Twin Vault",java.util.Map.of(beneficiary,new FishingState.Reward(BigInteger.TEN,BigInteger.ONE)));assertEquals(FishingDirectory.TrophyPrepareStatus.APPLIED,directory.prepareTrophy(trophy).get(5,TimeUnit.SECONDS));assertEquals(FishingDirectory.TrophyCommitStatus.APPLIED,directory.commitTrophy(trophy.id()).get(5,TimeUnit.SECONDS));}
|
||||
assertEquals(2,directory.trophies(carrier).size());assertNotEquals(directory.trophies(carrier).get(0).id(),directory.trophies(carrier).get(1).id());assertNotEquals(directory.trophies(carrier).get(0).site(),directory.trophies(carrier).get(1).site());
|
||||
}
|
||||
try(var restarted=new FishingDirectory(new YamlFishingRepository(data))){assertEquals(2,restarted.trophies(carrier).size());assertTrue(restarted.trophies(carrier).stream().allMatch(value->value.type()==FishingState.TrophyType.DUNGEON));}
|
||||
}
|
||||
|
||||
@Test void sameNameCampSpoilsRemainDistinctAndFailedRedemptionRetainsBoth() throws Exception {
|
||||
UUID carrier=UUID.randomUUID(),beneficiary=UUID.randomUUID();
|
||||
try(var directory=new FishingDirectory(new YamlFishingRepository(data))){
|
||||
for(int index=0;index<2;index++){
|
||||
var rewards=new LinkedHashMap<UUID,FishingState.Reward>();
|
||||
rewards.put(beneficiary,new FishingState.Reward(BigInteger.valueOf(10+index),BigInteger.ONE));
|
||||
var trophy=new FishingState.Trophy(UUID.randomUUID(),FishingState.TrophyType.CAMP,carrier,UUID.randomUUID(),
|
||||
UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),"Spoils of Twin Camp","Twin Camp",rewards);
|
||||
assertEquals(FishingDirectory.TrophyPrepareStatus.APPLIED,directory.prepareTrophy(trophy).get(5,TimeUnit.SECONDS));
|
||||
assertEquals(FishingDirectory.TrophyCommitStatus.APPLIED,directory.commitTrophy(trophy.id()).get(5,TimeUnit.SECONDS));
|
||||
}
|
||||
assertEquals(2,directory.trophies(carrier).size());
|
||||
var first=directory.trophies(carrier).getFirst();
|
||||
var file=data.resolve("fishing.yml");byte[] saved=Files.readAllBytes(file);Files.delete(file);Files.createDirectory(file);
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,()->directory.redeemTrophy(first.id(),carrier).get(5,TimeUnit.SECONDS));
|
||||
assertEquals(2,directory.trophies(carrier).size());
|
||||
assertEquals(FishingState.Reward.empty(),directory.unclaimed(beneficiary));
|
||||
Files.delete(file);Files.write(file,saved);
|
||||
assertEquals(FishingDirectory.TrophyRedeemStatus.APPLIED,directory.redeemTrophy(first.id(),carrier).get(5,TimeUnit.SECONDS));
|
||||
assertEquals(1,directory.trophies(carrier).size());
|
||||
assertEquals(first.total(),directory.unclaimed(beneficiary));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package games.dmg.fantasystick.adventure;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.persistence.AdventureQuestSettings;
|
||||
import games.dmg.fantasystick.persistence.YamlAdventureQuestRepository;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Random;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
final class FishingAdventureDirectoryTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void fishingDefinitionPersistsAndAcceptanceCapturesTermsAcrossLaterUpdates() throws Exception {
|
||||
var rules = new AdventureQuestSettings.Rules(3, 16, 30, 8, BigInteger.valueOf(100),
|
||||
BigInteger.valueOf(20), 100, 8, BigInteger.valueOf(150), BigInteger.valueOf(40), 10,
|
||||
BigInteger.ONE, BigInteger.valueOf(3));
|
||||
var clock = Clock.fixed(Instant.parse("2026-09-23T20:00:00Z"), ZoneOffset.UTC);
|
||||
UUID siteId, itemId; AdventureQuestState.Quest accepted;
|
||||
try (var directory = new AdventureQuestDirectory(new YamlAdventureQuestRepository(data), clock,
|
||||
() -> rules, new Random(1))) {
|
||||
var site = directory.placeFishingSite("Moonlit Pond", "Silver Scale", UUID.randomUUID(), 1, 64, 2,
|
||||
12, new BigDecimal("37.5"), BigInteger.valueOf(9), BigInteger.valueOf(120),
|
||||
BigInteger.valueOf(25), AdventureQuestState.ParticipationMode.PARTY_AVAILABLE)
|
||||
.get(5, TimeUnit.SECONDS);
|
||||
siteId = site.id(); itemId = site.fishing().itemId();
|
||||
assertEquals(AdventureQuestState.SiteType.FISHING, site.type());
|
||||
assertEquals(AdventureQuestState.Arena.circle(12), site.arena());
|
||||
var catalog = directory.ensureToday().get(5, TimeUnit.SECONDS);
|
||||
UUID character = UUID.randomUUID();
|
||||
accepted = directory.accept(catalog.revision(), site.id(), AdventureQuestState.UnitType.SOLO,
|
||||
character, Set.of(character)).get(5, TimeUnit.SECONDS).quest().orElseThrow();
|
||||
assertEquals(site.fishing().terms(), accepted.fishing());
|
||||
var update = new AdventureQuestState.FishingDefinition(itemId, "Golden Scale", BigInteger.valueOf(14),
|
||||
new BigDecimal("80"), BigInteger.valueOf(200), BigInteger.valueOf(40), 20);
|
||||
assertEquals(AdventureQuestDirectory.FishingUpdateStatus.APPLIED,
|
||||
directory.updateFishing(site.id(), site.fishing(), update).get(5, TimeUnit.SECONDS).status());
|
||||
assertEquals("Silver Scale", accepted.fishing().itemName());
|
||||
assertEquals(BigInteger.valueOf(9), accepted.fishing().required());
|
||||
}
|
||||
try (var restarted = new AdventureQuestDirectory(new YamlAdventureQuestRepository(data), clock,
|
||||
() -> rules, new Random(2))) {
|
||||
var site = restarted.findSite(siteId).orElseThrow();
|
||||
assertEquals(itemId, site.fishing().itemId());
|
||||
assertEquals("Golden Scale", site.fishing().itemName());
|
||||
assertEquals(20, site.fishing().radius());
|
||||
var quest = restarted.findQuest(accepted.id()).orElseThrow();
|
||||
assertEquals("Silver Scale", quest.fishing().itemName());
|
||||
assertEquals(12, quest.fishing().radius());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void invalidFishingDefinitionsAndStaleUpdatesCannotPublishPartialState() throws Exception {
|
||||
var rules = new AdventureQuestSettings.Rules(1, 16, 30, 8, BigInteger.valueOf(100),
|
||||
BigInteger.valueOf(20), 100, 8, BigInteger.valueOf(150), BigInteger.valueOf(40), 10,
|
||||
BigInteger.ONE, BigInteger.valueOf(3));
|
||||
try (var directory = new AdventureQuestDirectory(new YamlAdventureQuestRepository(data), Clock.systemUTC(),
|
||||
() -> rules, new Random(1))) {
|
||||
assertThrows(IllegalArgumentException.class, () -> directory.placeFishingSite("Bad", "Item",
|
||||
UUID.randomUUID(), 0, 64, 0, 3, BigDecimal.TEN, BigInteger.ONE, BigInteger.ONE,
|
||||
BigInteger.ONE, AdventureQuestState.ParticipationMode.PARTY_AVAILABLE));
|
||||
assertTrue(directory.snapshot().sites().isEmpty());
|
||||
var site = directory.placeFishingSite("Pond", "Pearl", UUID.randomUUID(), 0, 64, 0, 16,
|
||||
BigDecimal.TEN, BigInteger.ONE, BigInteger.TEN, BigInteger.ONE,
|
||||
AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY).get(5, TimeUnit.SECONDS);
|
||||
var first = new AdventureQuestState.FishingDefinition(site.fishing().itemId(), "Pearl", BigInteger.TWO,
|
||||
BigDecimal.valueOf(20), BigInteger.TEN, BigInteger.ONE, 16);
|
||||
assertEquals(AdventureQuestDirectory.FishingUpdateStatus.APPLIED,
|
||||
directory.updateFishing(site.id(), site.fishing(), first).get(5, TimeUnit.SECONDS).status());
|
||||
var stale = new AdventureQuestState.FishingDefinition(site.fishing().itemId(), "Stale", BigInteger.TWO,
|
||||
BigDecimal.valueOf(30), BigInteger.TEN, BigInteger.ONE, 16);
|
||||
assertEquals(AdventureQuestDirectory.FishingUpdateStatus.STALE,
|
||||
directory.updateFishing(site.id(), site.fishing(), stale).get(5, TimeUnit.SECONDS).status());
|
||||
assertEquals("Pearl", directory.findSite(site.id()).orElseThrow().fishing().itemName());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package games.dmg.fantasystick.adventure;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.persistence.YamlFishingRepository;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
final class FishingDirectoryTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@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);
|
||||
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(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());
|
||||
restarted.registerLabels(value->"Renamed Scale");
|
||||
assertEquals("Renamed Scale",restarted.inventory(character).getFirst().name());
|
||||
assertEquals(BigInteger.ONE,restarted.inventory(character).getFirst().amount());
|
||||
assertEquals(FishingDirectory.CompleteStatus.DUPLICATE,restarted.completeCatch(operation).get(5,TimeUnit.SECONDS));
|
||||
assertEquals(BigInteger.ONE,restarted.quantity(character,item));
|
||||
}
|
||||
}
|
||||
|
||||
@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();
|
||||
try(var directory=new FishingDirectory(new YamlFishingRepository(data))){
|
||||
for(int index=0;index<7;index++){
|
||||
var catchPlan=new FishingState.Catch(index==0?catchOperation:UUID.randomUUID(),character,quest,site,item,
|
||||
"Pearl",BigInteger.ONE,true);
|
||||
directory.prepareCatch(catchPlan).get(5,TimeUnit.SECONDS);
|
||||
directory.completeCatch(catchPlan.id()).get(5,TimeUnit.SECONDS);
|
||||
}
|
||||
var allocations=new LinkedHashMap<UUID,FishingState.Reward>();
|
||||
allocations.put(character,new FishingState.Reward(BigInteger.valueOf(51),BigInteger.valueOf(11)));
|
||||
allocations.put(other,new FishingState.Reward(BigInteger.valueOf(49),BigInteger.valueOf(9)));
|
||||
var plan=new FishingState.Submission(submission,quest,catalog,site,character,item,BigInteger.valueOf(5),
|
||||
allocations,FishingState.SubmissionPhase.PREPARED);
|
||||
assertEquals(FishingDirectory.PrepareStatus.APPLIED,directory.prepareSubmission(plan).get(5,TimeUnit.SECONDS));
|
||||
assertEquals(FishingDirectory.CommitStatus.APPLIED,directory.commitSubmission(submission).get(5,TimeUnit.SECONDS));
|
||||
assertEquals(BigInteger.TWO,directory.quantity(character,item));
|
||||
assertTrue(directory.completed(catalog,site,character));assertTrue(directory.completed(catalog,site,other));
|
||||
assertEquals(new FishingState.Reward(BigInteger.valueOf(51),BigInteger.valueOf(11)),directory.unclaimed(character));
|
||||
assertEquals(new FishingState.Reward(BigInteger.valueOf(49),BigInteger.valueOf(9)),directory.unclaimed(other));
|
||||
assertEquals(FishingDirectory.CommitStatus.DUPLICATE,directory.commitSubmission(submission).get(5,TimeUnit.SECONDS));
|
||||
assertTrue(directory.finishSubmission(submission).get(5,TimeUnit.SECONDS));
|
||||
}
|
||||
try(var restarted=new FishingDirectory(new YamlFishingRepository(data))){
|
||||
assertEquals(BigInteger.TWO,restarted.quantity(character,item));
|
||||
assertEquals(new FishingState.Reward(BigInteger.valueOf(49),BigInteger.valueOf(9)),restarted.unclaimed(other),
|
||||
"earned shares do not depend on a current catalog, site, quest or party");
|
||||
var claim=restarted.beginClaim(other).get(5,TimeUnit.SECONDS).orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(49),claim.xp());assertEquals(BigInteger.valueOf(9),claim.coins());
|
||||
assertEquals(claim,restarted.beginClaim(other).get(5,TimeUnit.SECONDS).orElseThrow());
|
||||
assertTrue(restarted.finishClaim(claim.id()).get(5,TimeUnit.SECONDS));
|
||||
assertEquals(FishingState.Reward.empty(),restarted.unclaimed(other));
|
||||
assertFalse(restarted.finishClaim(claim.id()).get(5,TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void insufficientOrDuplicateCompletionNeverConsumesInventory() throws Exception {
|
||||
UUID character=UUID.randomUUID(),site=UUID.randomUUID(),item=UUID.randomUUID(),catalog=UUID.randomUUID();
|
||||
try(var directory=new FishingDirectory(new YamlFishingRepository(data))){
|
||||
var catchPlan=new FishingState.Catch(UUID.randomUUID(),character,UUID.randomUUID(),site,item,"Relic",
|
||||
BigInteger.ZERO,true);
|
||||
directory.prepareCatch(catchPlan).get();directory.completeCatch(catchPlan.id()).get();
|
||||
var rewards=Map.of(character,new FishingState.Reward(BigInteger.ZERO,BigInteger.ZERO));
|
||||
var tooMany=new FishingState.Submission(UUID.randomUUID(),UUID.randomUUID(),catalog,site,character,item,
|
||||
BigInteger.TWO,rewards,FishingState.SubmissionPhase.PREPARED);
|
||||
assertEquals(FishingDirectory.PrepareStatus.INSUFFICIENT,directory.prepareSubmission(tooMany).get());
|
||||
assertEquals(BigInteger.ONE,directory.quantity(character,item));
|
||||
var exact=new FishingState.Submission(UUID.randomUUID(),UUID.randomUUID(),catalog,site,character,item,
|
||||
BigInteger.ONE,rewards,FishingState.SubmissionPhase.PREPARED);
|
||||
assertEquals(FishingDirectory.PrepareStatus.APPLIED,directory.prepareSubmission(exact).get());
|
||||
assertEquals(FishingDirectory.CommitStatus.APPLIED,directory.commitSubmission(exact.id()).get());
|
||||
var replay=new FishingState.Submission(UUID.randomUUID(),UUID.randomUUID(),catalog,site,character,item,
|
||||
BigInteger.ONE,rewards,FishingState.SubmissionPhase.PREPARED);
|
||||
assertEquals(FishingDirectory.PrepareStatus.COMPLETED,directory.prepareSubmission(replay).get());
|
||||
assertEquals(BigInteger.ZERO,directory.quantity(character,item));
|
||||
var savedCatch=new FishingState.Catch(UUID.randomUUID(),character,UUID.randomUUID(),site,item,"Relic",BigInteger.ZERO,true);
|
||||
directory.prepareCatch(savedCatch).get();directory.completeCatch(savedCatch.id()).get();
|
||||
var freshGeneration=new FishingState.Submission(UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),site,character,item,
|
||||
BigInteger.ONE,rewards,FishingState.SubmissionPhase.PREPARED);
|
||||
assertEquals(FishingDirectory.PrepareStatus.APPLIED,directory.prepareSubmission(freshGeneration).get(),
|
||||
"a new catalog generation permits the same character and site again");
|
||||
assertEquals(FishingDirectory.CommitStatus.APPLIED,directory.commitSubmission(freshGeneration.id()).get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedPersistenceRetainsPublishedInventoryAndPreparedAuthority() throws Exception {
|
||||
var repository=new YamlFishingRepository(data);UUID character=UUID.randomUUID(),operation=UUID.randomUUID();
|
||||
try(var directory=new FishingDirectory(repository)){
|
||||
var plan=new FishingState.Catch(operation,character,UUID.randomUUID(),UUID.randomUUID(),UUID.randomUUID(),
|
||||
"Catch",BigInteger.ONE,true);
|
||||
directory.prepareCatch(plan).get(5,TimeUnit.SECONDS);
|
||||
var file=data.resolve("fishing.yml");byte[] saved=Files.readAllBytes(file);Files.delete(file);Files.createDirectory(file);
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,()->directory.completeCatch(operation).get(5,TimeUnit.SECONDS));
|
||||
assertEquals(plan,directory.pendingCatches().getFirst());assertTrue(directory.inventory(character).isEmpty());
|
||||
Files.delete(file);Files.write(file,saved);
|
||||
assertEquals(FishingDirectory.CompleteStatus.APPLIED,directory.completeCatch(operation).get(5,TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
import games.dmg.fantasystick.persistence.YamlDungeonRepository;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.bukkit.entity.EntityType;
|
||||
@@ -29,17 +30,42 @@ final class DungeonDirectoryTest {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,()->directory.removeRoom(dungeon.id(),second.id()).get(5,TimeUnit.SECONDS));
|
||||
var mob=directory.addMob(dungeon.id(),first.id(),EntityType.HUSK,"Guard",10.5,65,-4.5,0,0).get(5,TimeUnit.SECONDS);
|
||||
var effect=directory.addEffect(dungeon.id(),first.id(),DungeonState.EffectTarget.BOTH,"minecraft:speed",2,false).get(5,TimeUnit.SECONDS);
|
||||
directory.setReward(dungeon.id(),first.id(),BigInteger.valueOf(50),BigInteger.TEN).get(5,TimeUnit.SECONDS);
|
||||
directory.setCompletionReward(dungeon.id(),BigInteger.valueOf(50),BigInteger.TEN).get(5,TimeUnit.SECONDS);
|
||||
directory.setBoss(dungeon.id(),third.id(),EntityType.VINDICATOR,"Keeper",17.5,65,-4.5,180,0).get(5,TimeUnit.SECONDS);
|
||||
assertNotNull(mob.id());assertNotNull(effect.id());
|
||||
assertTrue(directory.enable(dungeon.id(),java.util.List.of()).get(5,TimeUnit.SECONDS).enabled());
|
||||
var overlap=directory.create("Overlap Draft",world,10,65,-5,0,0).get(5,TimeUnit.SECONDS);var overlapRoom=directory.addRadiusRoom(overlap.id(),"Boss",12,64,-5,1,3).get(5,TimeUnit.SECONDS);directory.setBoss(overlap.id(),overlapRoom.id(),EntityType.HUSK,"",12,65,-5,0,0).get(5,TimeUnit.SECONDS);assertThrows(java.util.concurrent.ExecutionException.class,()->directory.enable(overlap.id(),java.util.List.of()).get(5,TimeUnit.SECONDS));assertFalse(directory.find(overlap.id()).orElseThrow().enabled());
|
||||
var overlap=directory.create("Overlap Draft",world,10,65,-5,0,0).get(5,TimeUnit.SECONDS);var overlapRoom=directory.addRadiusRoom(overlap.id(),"Boss",12,64,-5,1,3).get(5,TimeUnit.SECONDS);directory.setCompletionReward(overlap.id(),BigInteger.ZERO,BigInteger.ZERO).get(5,TimeUnit.SECONDS);directory.setBoss(overlap.id(),overlapRoom.id(),EntityType.HUSK,"",12,65,-5,0,0).get(5,TimeUnit.SECONDS);assertThrows(java.util.concurrent.ExecutionException.class,()->directory.enable(overlap.id(),java.util.List.of()).get(5,TimeUnit.SECONDS));assertFalse(directory.find(overlap.id()).orElseThrow().enabled());
|
||||
}
|
||||
var reloaded=repository.load().dungeons().stream().filter(value->value.id().equals(dungeonId)).findFirst().orElseThrow();
|
||||
assertTrue(reloaded.enabled());assertEquals(3,reloaded.rooms().size());assertEquals(BigInteger.valueOf(50),reloaded.maximumXp());assertEquals(BigInteger.TEN,reloaded.maximumCoins());
|
||||
assertEquals(EntityType.VINDICATOR,reloaded.rooms().stream().filter(room->room.boss()!=null).findFirst().orElseThrow().boss().type());
|
||||
}
|
||||
|
||||
@Test void requiresOneExplicitDungeonCompletionPoolAndPersistsZeroValue() throws Exception {
|
||||
var repository=new YamlDungeonRepository(data);UUID id;
|
||||
try(var directory=new DungeonDirectory(repository)){
|
||||
var dungeon=directory.create("Rewarded",UUID.randomUUID(),0,65,0,0,0).get(5,TimeUnit.SECONDS);id=dungeon.id();
|
||||
var room=directory.addRadiusRoom(id,"Boss",0,64,0,2,3).get(5,TimeUnit.SECONDS);
|
||||
directory.setBoss(id,room.id(),EntityType.VINDICATOR,"Boss",0,65,0,0,0).get(5,TimeUnit.SECONDS);
|
||||
assertFalse(directory.find(id).orElseThrow().rewardConfigured());
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,()->directory.enable(id,List.of()).get(5,TimeUnit.SECONDS));
|
||||
assertEquals(DungeonState.Reward.empty(),directory.setCompletionReward(id,BigInteger.ZERO,BigInteger.ZERO).get(5,TimeUnit.SECONDS));
|
||||
assertTrue(directory.find(id).orElseThrow().rewardConfigured());
|
||||
assertTrue(directory.enable(id,List.of()).get(5,TimeUnit.SECONDS).enabled());
|
||||
}
|
||||
var loaded=repository.load().dungeons().stream().filter(value->value.id().equals(id)).findFirst().orElseThrow();
|
||||
assertTrue(loaded.rewardConfigured());assertEquals(DungeonState.Reward.empty(),loaded.completionReward());
|
||||
}
|
||||
|
||||
@Test void migratesLegacyBossRoomRewardWithoutSummingEarlierRooms() throws Exception {
|
||||
var repository=new YamlDungeonRepository(data);var position=new DungeonState.Position(0,65,0,0,0);var bounds=new DungeonState.Bounds(0,64,0,1,66,1);
|
||||
var earlier=new DungeonState.Room(UUID.randomUUID(),"Earlier",bounds,List.of(),List.of(),new DungeonState.Reward(BigInteger.valueOf(90),BigInteger.valueOf(9)),null);
|
||||
var boss=new DungeonState.Room(UUID.randomUUID(),"Boss",new DungeonState.Bounds(2,64,0,3,66,1),List.of(),List.of(),new DungeonState.Reward(BigInteger.valueOf(40),BigInteger.valueOf(4)),new DungeonState.Boss(EntityType.HUSK,"Boss",position));
|
||||
var legacy=new DungeonState.Dungeon(UUID.randomUUID(),"Legacy",UUID.randomUUID(),position,true,UUID.randomUUID(),List.of(earlier,boss));repository.save(new DungeonState(List.of(legacy),List.of()));
|
||||
var file=data.resolve("dungeons.yml");var lines=java.nio.file.Files.readAllLines(file);java.nio.file.Files.write(file,lines.stream().map(line->line.equals("schema-version: 2")?"schema-version: 1":line).filter(line->!line.startsWith(" completion-reward-")).toList());
|
||||
var migrated=repository.load().dungeons().getFirst();assertTrue(migrated.rewardConfigured());assertEquals(new DungeonState.Reward(BigInteger.valueOf(40),BigInteger.valueOf(4)),migrated.completionReward());assertTrue(migrated.rooms().stream().allMatch(room->room.reward().equals(DungeonState.Reward.empty())),"legacy room pools are retired after deriving the boss completion pool");
|
||||
}
|
||||
|
||||
@Test void configuredDistanceLinksHorizontalVerticalDiagonalAndTransitiveRooms() throws Exception {
|
||||
var distance=new java.util.concurrent.atomic.AtomicInteger(10);
|
||||
try(var directory=new DungeonDirectory(new YamlDungeonRepository(data),distance::get)){
|
||||
@@ -55,6 +81,7 @@ final class DungeonDirectoryTest {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,()->directory.addCornerRoom(dungeon.id(),"Overlap",0,0,0,1,1,1).get(5,TimeUnit.SECONDS));
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,()->directory.removeRoom(dungeon.id(),horizontal.id()).get(5,TimeUnit.SECONDS));
|
||||
distance.set(4);assertThrows(java.util.concurrent.ExecutionException.class,()->directory.addCornerRoom(dungeon.id(),"Partial Repair",4,0,0,1,1,1).get(5,TimeUnit.SECONDS),"An addition must leave the complete graph linked under the current setting");distance.set(10);
|
||||
directory.setCompletionReward(dungeon.id(),BigInteger.ZERO,BigInteger.ZERO).get(5,TimeUnit.SECONDS);
|
||||
directory.setBoss(dungeon.id(),first.id(),EntityType.HUSK,"Boss",0,0,0,0,0).get(5,TimeUnit.SECONDS);
|
||||
assertTrue(directory.enable(dungeon.id(),java.util.List.of()).get(5,TimeUnit.SECONDS).enabled());var generation=UUID.randomUUID();directory.run(dungeon.id(),generation).get(5,TimeUnit.SECONDS);
|
||||
distance.set(4);
|
||||
@@ -75,6 +102,7 @@ final class DungeonDirectoryTest {
|
||||
var room=directory.addRadiusRoom(dungeon.id(),"Core",0,64,0,2,3).get(5,TimeUnit.SECONDS);
|
||||
var slot=directory.addMob(dungeon.id(),room.id(),EntityType.HUSK,"",0,65,0,0,0).get(5,TimeUnit.SECONDS);
|
||||
directory.setBoss(dungeon.id(),room.id(),EntityType.VINDICATOR,"Boss",0,65,0,0,0).get(5,TimeUnit.SECONDS);
|
||||
directory.setCompletionReward(dungeon.id(),BigInteger.ZERO,BigInteger.ZERO).get(5,TimeUnit.SECONDS);
|
||||
directory.enable(dungeon.id(),java.util.List.of()).get(5,TimeUnit.SECONDS);
|
||||
directory.defeatSlot(dungeon.id(),generation,slot.id()).get(5,TimeUnit.SECONDS);
|
||||
directory.clearRoom(dungeon.id(),generation,room.id(),UUID.randomUUID(),java.util.Set.of(UUID.randomUUID())).get(5,TimeUnit.SECONDS);
|
||||
|
||||
@@ -699,6 +699,164 @@ class CharacterStorageTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void ordinaryMutationsPreserveAnAcknowledgedSheetGeneration() throws Exception {
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var generated = new CharacterRecord(base.id(), base.name(), base.revision(), base.stats(), base.level(),
|
||||
base.experience(), base.unspentPoints(), base.health(), base.learned(), base.loadout(), base.passives(),
|
||||
base.cooldowns(), base.restrictedKills(), false, null, null, BigInteger.valueOf(100), true,
|
||||
base.rewardReceipts(), BigInteger.ZERO, base.gear(), BigInteger.valueOf(7));
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(generated);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var renamed = storage.renameCharacter(generated.id(), "Generation keeper").join().orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(7), renamed.sheetGeneration());
|
||||
var purchased = storage.purchaseGear(generated.id(), renamed.revision(), UUID.randomUUID(),
|
||||
GearSlot.CHESTPLATE, GearTier.LEATHER, BigInteger.ONE).join().record().orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(7), purchased.sheetGeneration());
|
||||
var dead = storage.recordDeath(generated.id(), false).join().orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(7), dead.sheetGeneration());
|
||||
var restored = storage.restoreDeathRecordAdministratively(
|
||||
generated.id(), dead.deathId(), dead.maximumHealth()).join().orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(7), restored.sheetGeneration());
|
||||
assertEquals(BigInteger.valueOf(7), new YamlCharacterRepository(data).load(generated.id())
|
||||
.orElseThrow().sheetGeneration());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void preparedRecoveryWithUnchangedCharacterReconcilesWithoutRevocationAndAllowsNewConfirmation() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var character = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
repository.create(character);
|
||||
var operation = recovery(character, UUID.randomUUID());
|
||||
var store = new CharacterRecoveryStore(data);
|
||||
store.prepare(new CharacterRecoveryStore.Operation(operation.operation(), character.id(),
|
||||
operation.administrator(), operation.recipient(), character.revision(), character.sheetGeneration(),
|
||||
character.sheetGeneration().add(BigInteger.ONE), false, null, CharacterRecoveryStore.Phase.PREPARED));
|
||||
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
assertEquals(character, storage.find(character.id()).join().orElseThrow());
|
||||
var replacement = storage.beginRecovery(recovery(character, UUID.randomUUID())).join();
|
||||
assertEquals(CharacterStorage.RecoveryStatus.APPLIED, replacement.status());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void recoveryConfirmationCannotChangeMeaningAfterThePreviewedLifeOrDeathStateChanges() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var living = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
repository.create(living);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var livingRequest = recovery(living, UUID.randomUUID());
|
||||
var dead = storage.recordDeath(living.id(), false).join().orElseThrow();
|
||||
assertEquals(CharacterStorage.RecoveryStatus.STALE, storage.beginRecovery(livingRequest).join().status());
|
||||
|
||||
var reviveRequest = new CharacterStorage.RecoveryRequest(UUID.randomUUID(), dead.id(), UUID.randomUUID(),
|
||||
UUID.randomUUID(), dead.revision(), dead.sheetGeneration(), true, dead.deathId(), dead.maximumHealth());
|
||||
storage.restoreDeathRecordAdministratively(dead.id(), dead.deathId(), dead.maximumHealth()).join().orElseThrow();
|
||||
assertEquals(CharacterStorage.RecoveryStatus.STALE, storage.beginRecovery(reviveRequest).join().status());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedCharacterCommitCancelsPreparedRecoveryWithoutRevokingTheCurrentGeneration() throws Exception {
|
||||
var moves = new java.util.concurrent.atomic.AtomicInteger();
|
||||
var repository = new YamlCharacterRepository(data, (source, target) -> {
|
||||
if (moves.incrementAndGet() == 2) throw new IOException("synthetic recovery commit failure");
|
||||
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
});
|
||||
var character = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
repository.create(character);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
assertThrows(CompletionException.class,
|
||||
() -> storage.beginRecovery(recovery(character, UUID.randomUUID())).join());
|
||||
}
|
||||
assertEquals(character, new YamlCharacterRepository(data).load(character.id()).orElseThrow());
|
||||
try (var retry = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
|
||||
assertEquals(CharacterStorage.RecoveryStatus.APPLIED,
|
||||
retry.beginRecovery(recovery(character, UUID.randomUUID())).join().status());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void restartPromotesProvedPreparedCommitToUnresolvedWithoutReissuing() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var character = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
repository.create(character);
|
||||
var request = recovery(character, UUID.randomUUID());
|
||||
var store = new CharacterRecoveryStore(data);
|
||||
var operation = new CharacterRecoveryStore.Operation(request.operation(), character.id(),
|
||||
request.administrator(), request.recipient(), character.revision(), character.sheetGeneration(),
|
||||
character.sheetGeneration().add(BigInteger.ONE), false, null, CharacterRecoveryStore.Phase.PREPARED);
|
||||
store.prepare(operation);
|
||||
var replacement = new CharacterRecord(character.id(), character.name(), character.revision().add(BigInteger.ONE),
|
||||
character.stats(), character.level(), character.experience(), character.unspentPoints(), character.health(),
|
||||
character.learned(), character.loadout(), character.passives(), character.cooldowns(), character.restrictedKills(),
|
||||
false, character.deathId(), character.diedAt(), character.coins(), character.coinsInitialized(),
|
||||
character.rewardReceipts(), character.pvpCoinEscrow(), character.gear(), BigInteger.ONE);
|
||||
repository.replaceForRecovery(replacement, character.revision());
|
||||
|
||||
try (var reloaded = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
|
||||
assertThrows(CompletionException.class, () -> reloaded.find(character.id()).join());
|
||||
assertEquals(CharacterRecoveryStore.Phase.COMMITTED,
|
||||
store.find(character.id()).orElseThrow().phase());
|
||||
assertThrows(CompletionException.class,
|
||||
() -> reloaded.beginRecovery(recovery(replacement, UUID.randomUUID())).join());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void committedRecoveryBlocksLoadingAndMutationAcrossRestartUntilExactCompletion() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var character = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
repository.create(character);
|
||||
var request = recovery(character, UUID.randomUUID());
|
||||
CharacterRecord committed;
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
committed = storage.beginRecovery(request).join().record().orElseThrow();
|
||||
assertThrows(CompletionException.class, () -> storage.find(character.id()).join());
|
||||
assertThrows(CompletionException.class, () -> storage.renameCharacter(character.id(), "Unsafe rename").join());
|
||||
}
|
||||
try (var reloaded = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
|
||||
assertThrows(CompletionException.class, () -> reloaded.find(character.id()).join());
|
||||
assertTrue(reloaded.completeRecovery(character.id(), request.operation()).join());
|
||||
assertEquals(committed, reloaded.find(character.id()).join().orElseThrow());
|
||||
assertThrows(CompletionException.class,
|
||||
() -> reloaded.completeRecovery(character.id(), request.operation()).join());
|
||||
assertEquals(CharacterStorage.RecoveryStatus.STALE,
|
||||
reloaded.beginRecovery(request).join().status());
|
||||
assertEquals(committed, reloaded.find(character.id()).join().orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void committedRecoveryProofRejectsARecordDowngradedToLegacyGeneration() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var character = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
repository.create(character);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
storage.beginRecovery(recovery(character, UUID.randomUUID())).join();
|
||||
}
|
||||
Path path = data.resolve("characters").resolve(character.id() + ".yml");
|
||||
Files.writeString(path, Files.readString(path).replaceFirst("(?m)^sheet-generation:.*\\n", ""));
|
||||
try (var reloaded = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
|
||||
assertThrows(CompletionException.class, () -> reloaded.find(character.id()).join());
|
||||
}
|
||||
}
|
||||
|
||||
private static CharacterStorage.RecoveryRequest recovery(CharacterRecord record, UUID operation) {
|
||||
return new CharacterStorage.RecoveryRequest(operation, record.id(), UUID.randomUUID(), UUID.randomUUID(),
|
||||
record.revision(), record.sheetGeneration(), false, null, java.math.BigDecimal.valueOf(20));
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoresOnlyFromTheSavedDeadCharacterAndPreservesProgressAndCooldowns() throws Exception {
|
||||
var saved = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
|
||||
|
||||
@@ -98,6 +98,28 @@ class YamlCharacterRepositoryTest {
|
||||
assertEquals(Map.of(), CharacterYaml.decode(legacy).restrictedKills());
|
||||
}
|
||||
|
||||
@Test
|
||||
void catalogReadsOnlyCanonicalRegularRecordsAndReportsUnreadableEntries() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var first = sample(UUID.randomUUID());
|
||||
var second = sample(UUID.randomUUID());
|
||||
repository.create(first); repository.create(second);
|
||||
var directory = data.resolve("characters");
|
||||
Files.writeString(directory.resolve(UUID.randomUUID() + ".yml"), "schema-version: malformed\n");
|
||||
Files.writeString(directory.resolve("not-a-uuid.yml"), "ignored");
|
||||
var outside = data.resolve("outside-character.yml");
|
||||
Files.writeString(outside, CharacterYaml.encode(sample(UUID.randomUUID())));
|
||||
var link = directory.resolve(UUID.randomUUID() + ".yml");
|
||||
Files.createSymbolicLink(link, outside);
|
||||
|
||||
var catalog = repository.catalog();
|
||||
assertEquals(Set.of(first.id(), second.id()), catalog.records().stream()
|
||||
.map(CharacterRecord::id).collect(java.util.stream.Collectors.toSet()));
|
||||
assertEquals(2, catalog.unreadable());
|
||||
assertTrue(Files.isSymbolicLink(link));
|
||||
assertEquals(CharacterYaml.encode(CharacterYaml.decode(Files.readString(outside))), Files.readString(outside));
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatedCreationAndAbandonmentDoNotAccumulatePerCharacterCoordinationFiles() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
|
||||
Reference in new issue
Block a user