Compare commits

...
42 Commits
Author SHA1 Message Date
dmg 185de89c85 fix(test): await champion respawn condition
Release / release (push) Failing after 10m11s
2026-09-23 19:08:43 -04:00
dmg 618a6a2776 feat(dungeons): persist kill and trophy rewards
Release / release (push) Failing after 6m11s
2026-09-23 18:57:54 -04:00
dmg e308f2f884 fix(test): await next champion encounter
Release / release (push) Successful in 10m51s
2026-09-23 18:02:52 -04:00
dmg cf2e013f74 fix(test): await durable native publications
Release / release (push) Failing after 10m28s
2026-09-23 17:49:28 -04:00
dmg 720fdc1687 feat(adventure): add persistent camp trophies
Release / release (push) Successful in 6m33s
2026-09-23 17:39:40 -04:00
dmg 4d7ce211f5 fix(adventure): await dungeon mode publication
Release / release (push) Failing after 5m35s
2026-09-23 17:37:05 -04:00
dmg bca3033fd9 feat(adventure): add persistent champion trophies
Release / release (push) Failing after 5m30s
2026-09-23 17:19:46 -04:00
dmg 1e3c7f28e2 fix(adventure): await party acceptance publication
Release / release (push) Successful in 11m30s
2026-09-23 17:03:44 -04:00
dmg dc68811a56 fix(adventure): preview fishing submission terms
Release / release (push) Failing after 6m14s
2026-09-23 16:29:26 -04:00
dmg afc596e727 feat(adventure): add persistent fishing quests 2026-09-23 16:25:14 -04:00
dmg 421e44d86f feat(adventure): add site participation modes
Release / release (push) Successful in 11m36s
2026-09-23 15:23:18 -04:00
dmg 871258070b test(dungeon): await asynchronous creation
Release / release (push) Successful in 6m41s
2026-09-23 15:17:41 -04:00
dmg cbd0f70a8b feat(admin): recover saved character sheets
Release / release (push) Failing after 11m7s
2026-09-23 14:47:25 -04:00
dmg 7b588e08d2 feat(admin): inspect saved characters
Release / release (push) Failing after 6m1s
2026-09-23 12:50:00 -04:00
dmg 86f248d80c feat(commands): split player and admin roots
Release / release (push) Failing after 5m54s
2026-09-23 12:20:39 -04:00
dmg f8fed8998b fix(rewards): apply mob spacing to coins
Release / release (push) Successful in 11m12s
2026-09-23 08:35:39 -04:00
dmg 9837afc549 fix(sessions): preserve cross-world Tail delivery
Release / release (push) Successful in 11m37s
2026-09-23 08:22:11 -04:00
dmg 1ea4e8c13f feat(particles): add account-wide visibility control
Release / release (push) Successful in 11m33s
2026-09-23 07:11:10 -04:00
dmg e3f2f2481b feat(particles): add viewer-specific active auras
Release / release (push) Successful in 11m38s
2026-09-23 06:28:58 -04:00
dmg 6ec97569d3 feat(admin): add read-only active character view
Release / release (push) Successful in 11m51s
2026-09-23 00:02:07 -04:00
dmg fd5961ccd8 fix(sessions): simplify switch cooldown feedback
Release / release (push) Successful in 11m8s
2026-09-22 23:26:29 -04:00
dmg c2d84458c7 feat(adventure): add admin site teleports
Release / release (push) Successful in 10m59s
2026-09-22 18:25:14 -04:00
dmg f2a6043d08 feat(adventure): hide quest coordinates
Release / release (push) Successful in 11m29s
2026-09-22 18:09:30 -04:00
dmg 793245e890 test(dungeon): await durable global defeat
Release / release (push) Successful in 11m7s
2026-09-22 16:13:40 -04:00
dmg 6092567778 feat(adventure): add quest reward turn-ins
Release / release (push) Failing after 6m41s
2026-09-22 16:01:15 -04:00
dmg ff55fbace7 feat(adventure): add quest compass tracking
Release / release (push) Successful in 11m24s
2026-09-22 15:19:40 -04:00
dmg a637e4090c fix(training): let projectiles hit dummies
Release / release (push) Successful in 11m2s
2026-09-22 14:04:03 -04:00
dmg 0f3365c6ee feat(dungeon): allow proximity-linked rooms
Release / release (push) Successful in 11m21s
2026-09-22 12:50:01 -04:00
dmg af354a3097 feat(dungeon): add character-sheet bosses
Release / release (push) Successful in 11m47s
2026-09-22 08:57:38 -04:00
dmg e111d3919e feat(dungeon): add shared daily dungeons
Release / release (push) Failing after 10m1s
2026-09-21 23:34:39 -04:00
dmg 35418dbef5 feat(adventure): reset current adventure day
Release / release (push) Failing after 8m38s
2026-09-21 22:25:57 -04:00
dmg 3a2c4b8451 feat(adventure): add shaped site arenas
Release / release (push) Successful in 9m40s
2026-09-21 21:59:45 -04:00
dmg 60a29221f7 feat(adventure): present readable quest locations
Release / release (push) Successful in 9m23s
2026-09-21 21:40:05 -04:00
dmg 23179c62ae feat(feedback): render routine outcomes in gray
Release / release (push) Successful in 9m9s
2026-09-21 21:29:12 -04:00
dmg f0dc304f60 feat(adventure): add confirmed catalog rerolls
Release / release (push) Successful in 9m17s
2026-09-21 20:33:16 -04:00
dmg 3444c786c3 feat(adventure): add daily champion quests
Release / release (push) Successful in 9m59s
2026-09-21 19:56:35 -04:00
dmg 3ba3ef4a0b feat(adventure): add daily monster-camp quests
Release / release (push) Successful in 9m9s
2026-09-21 19:34:44 -04:00
dmg bc1a0b1f29 feat(passives): add basic-stick Knockback
Release / release (push) Successful in 8m54s
2026-09-21 18:34:13 -04:00
dmg fa219d9313 feat(rewards): add Witch hard-mob XP
Release / release (push) Successful in 8m30s
2026-09-21 18:21:10 -04:00
dmg d98a2a80a3 feat(combat): add base damage to basic attack
Release / release (push) Successful in 9m29s
2026-09-21 18:06:37 -04:00
dmg 7a85eea769 feat(book): add passive return navigation
Release / release (push) Successful in 9m24s
2026-09-21 17:19:47 -04:00
dmg 6d0d695052 feat(book): categorize active abilities
Release / release (push) Successful in 9m53s
2026-09-21 17:09:03 -04:00
133 changed files with 8935 additions and 436 deletions

No files matched your search

+7 -4
View File
@@ -136,9 +136,12 @@ Optional `character-swap-cooldown-seconds` defaults to `900`; zero bypasses the
Administration (permission `fantasystick.admin`, default operators):
- `/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.
@@ -166,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 shows public blue particles. 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,8 +53,11 @@ 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;
private games.dmg.fantasystick.presentation.ParticleDelivery particleDelivery;
private games.dmg.fantasystick.presentation.CombatFeedback combatFeedback;
private volatile Object lifetime;
private CharacterPresentation presentation;
@@ -119,6 +122,16 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.persistence.PartyEconomySettings partyEconomySettings;
private games.dmg.fantasystick.persistence.GearSettings gearSettings;
private games.dmg.fantasystick.persistence.CoinSettings coinSettings;
private games.dmg.fantasystick.persistence.HardMobXpSettings hardMobXpSettings;
private games.dmg.fantasystick.persistence.AdventureQuestSettings adventureQuestSettings;
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;
private games.dmg.fantasystick.persistence.SheetResourcePackSettings sheetResourcePackSettings;
private games.dmg.fantasystick.presentation.SheetResourcePack sheetResourcePack;
private games.dmg.fantasystick.session.NutritionPolicy nutritionPolicy;
@@ -170,6 +183,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
coinConfiguration.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.CoinSettings.defaultValue(key)));
}
var adventureQuestConfiguration = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.AdventureQuestSettings.keys()) {
adventureQuestConfiguration.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.AdventureQuestSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.AdventureQuestSettings.parse(adventureQuestConfiguration);
var recoveryConfiguration = outOfCombatRecoveryConfiguration();
var recoveryInitial = games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.parse(recoveryConfiguration);
var quickerRegenerationInitial = games.dmg.fantasystick.persistence.QuickerRegenerationSettings.parse(
@@ -186,7 +205,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
getConfig().get(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY),
getConfig().get(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY), sheetPackDefaults);
var rules = progressionRules();
var rewardRules = combatRewardRules();
var hardMobXpConfiguration = getConfig().getString(
games.dmg.fantasystick.persistence.HardMobXpSettings.KEY,
games.dmg.fantasystick.persistence.HardMobXpSettings.DEFAULT);
var hardMobXp = games.dmg.fantasystick.persistence.HardMobXpSettings.parse(hardMobXpConfiguration);
var rewardRules = combatRewardRules(hardMobXp);
var fireballConfiguration = fireballConfiguration();
var fireballInitial = games.dmg.fantasystick.persistence.FireballSettings.parse(fireballConfiguration);
var greatSwordSettings = greatSwordSettings();
@@ -230,6 +253,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
getDataFolder().toPath(), partyEconomyConfiguration);
gearSettings=new games.dmg.fantasystick.persistence.GearSettings(getDataFolder().toPath(),gearConfiguration);
coinSettings = new games.dmg.fantasystick.persistence.CoinSettings(getDataFolder().toPath(), coinConfiguration);
hardMobXpSettings = new games.dmg.fantasystick.persistence.HardMobXpSettings(
getDataFolder().toPath(), hardMobXp.toString());
adventureQuestSettings = new games.dmg.fantasystick.persistence.AdventureQuestSettings(
getDataFolder().toPath(), adventureQuestConfiguration);
var factory = new CharacterFactory(coinSettings::rules);
characters = new CharacterStorage(new YamlCharacterRepository(getDataFolder().toPath()), Clock.systemUTC(),
coinSettings::rules, java.util.random.RandomGenerator.getDefault());
@@ -250,8 +277,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
} catch (java.io.IOException invalidPreferences) {
throw new java.io.UncheckedIOException("Player feedback preferences could not be loaded", invalidPreferences);
}
particleDelivery = new games.dmg.fantasystick.presentation.ParticleDelivery(getServer(), feedbackPreferences,
failure -> getLogger().log(Level.WARNING, "Player particle delivery failed", failure));
feedbackCommand = new games.dmg.fantasystick.session.FantasyFeedbackCommand(feedbackPreferences, nextTick,
failure -> getLogger().log(Level.WARNING, "Player feedback preference failed", failure));
particlesCommand = new games.dmg.fantasystick.session.FantasyParticlesCommand(feedbackPreferences, nextTick,
failure -> getLogger().log(Level.WARNING, "Player particle preference failed", failure));
java.util.function.Consumer<Throwable> presentationFailures = problem ->
getLogger().log(Level.WARNING, "Character presentation failed", problem);
// This supplier belongs to one enable, never the mutable plugin field after re-enable.
@@ -290,9 +321,18 @@ 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(
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 or training state could not be loaded", invalidManagedState);
throw new java.io.UncheckedIOException("Persistent party, shop, training or adventure state could not be loaded", invalidManagedState);
}
characterParties.suspend();
sheetResourcePack = new games.dmg.fantasystick.presentation.SheetResourcePack(sheetResourcePackSettings,
@@ -328,15 +368,16 @@ public final class FantasyStickPlugin extends JavaPlugin {
combatExperience = new games.dmg.fantasystick.party.CombatExperience(characters, rules, rewardRules,
sessions, parties, coinSettings::rules, java.util.random.RandomGenerator.getDefault(),
Clock.systemUTC(), nextTick, id -> getServer().getPlayer(id),
problem -> getLogger().log(Level.WARNING, "Combat reward failed for character " + problem.character(), problem.cause()));
problem -> getLogger().log(Level.WARNING, "Combat reward failed for character " + problem.character(), problem.cause()),
particleDelivery);
sessions.attachPvpDefeat(combatExperience::consumePvpDefeat);
abilityRegistry = new games.dmg.fantasystick.ability.AbilityRegistry();
fireballSettings = new games.dmg.fantasystick.persistence.FireballSettings(
getDataFolder().toPath(), fireballConfiguration);
fireball = new games.dmg.fantasystick.ability.Fireball(fireballInitial);
fireball = new games.dmg.fantasystick.ability.Fireball(fireballInitial, particleDelivery);
frostShardSettings = new games.dmg.fantasystick.persistence.FrostShardSettings(
getDataFolder().toPath(), frostShardConfiguration);
frostShard = new games.dmg.fantasystick.ability.FrostShard(frostShardInitial);
frostShard = new games.dmg.fantasystick.ability.FrostShard(frostShardInitial, particleDelivery);
longbowSettings = new games.dmg.fantasystick.persistence.LongbowSettings(
getDataFolder().toPath(), longbowConfiguration);
longbow = new games.dmg.fantasystick.ability.Longbow(longbowInitial);
@@ -386,21 +427,24 @@ public final class FantasyStickPlugin extends JavaPlugin {
rallyingCryInitial, sessions, parties, outgoingDamageBonus);
frostNovaSettings = new games.dmg.fantasystick.persistence.FrostNovaSettings(
getDataFolder().toPath(), frostNovaConfiguration);
frostNova = new games.dmg.fantasystick.ability.FrostNova(frostNovaInitial, sessions, parties);
frostNova = new games.dmg.fantasystick.ability.FrostNova(frostNovaInitial, sessions, parties, particleDelivery);
chainLightningSettings = new games.dmg.fantasystick.persistence.ChainLightningSettings(
getDataFolder().toPath(), chainLightningConfiguration);
chainLightning = new games.dmg.fantasystick.ability.ChainLightning(chainLightningInitial, sessions, parties);
chainLightning = new games.dmg.fantasystick.ability.ChainLightning(
chainLightningInitial, sessions, parties, particleDelivery);
sanctuarySettings = new games.dmg.fantasystick.persistence.SanctuarySettings(
getDataFolder().toPath(), sanctuaryConfiguration);
sanctuary = new games.dmg.fantasystick.ability.Sanctuary(sanctuaryInitial, characters, sessions, parties,
combatMitigation, nextTick, event -> getServer().getPluginManager().callEvent(event),
games.dmg.fantasystick.session.NativeHealthLimit::read,
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()));
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()),
particleDelivery);
warBannerSettings = new games.dmg.fantasystick.persistence.WarBannerSettings(
getDataFolder().toPath(), warBannerConfiguration);
warBanner = new games.dmg.fantasystick.ability.WarBanner(warBannerInitial, sessions, parties, outgoingDamageBonus);
warBanner = new games.dmg.fantasystick.ability.WarBanner(
warBannerInitial, sessions, parties, outgoingDamageBonus, particleDelivery);
meteorSettings = new games.dmg.fantasystick.persistence.MeteorSettings(getDataFolder().toPath(), meteorConfiguration);
meteor = new games.dmg.fantasystick.ability.Meteor(meteorInitial, sessions, parties);
meteor = new games.dmg.fantasystick.ability.Meteor(meteorInitial, sessions, parties, particleDelivery);
greatSword = new games.dmg.fantasystick.ability.GreatSword(greatSwordSettings);
revivals = new games.dmg.fantasystick.persistence.RevivalStore(getDataFolder().toPath());
revive = new games.dmg.fantasystick.ability.Revive(characters, revivals, Clock.systemUTC(), nextTick,
@@ -458,6 +502,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
passiveRegistry.register(healthSteal.definition());
passiveRegistry.register(speedI.definition());
passiveRegistry.register(lowLightVision.definition());
passiveRegistry.register(new games.dmg.fantasystick.passive.StickKnockback().definition());
passiveEngine = new games.dmg.fantasystick.passive.PassiveEngine(passiveRegistry, characters, sessions, nextTick,
problem -> getLogger().log(Level.WARNING, "Passive change failed for character " + problem.character()
+ "; passive " + problem.passive(), problem.cause()));
@@ -479,7 +524,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
abilityScheduler::later, (size, title) -> getServer().createInventory(null, size, title),
problem -> getLogger().log(Level.WARNING, "Admin revival failed", problem));
abilityEngine = new games.dmg.fantasystick.ability.AbilityEngine(abilityRegistry, characters, sessions, parties,
Clock.systemUTC(), nextTick, abilityScheduler, report -> { }, combatFeedback, trainingDummyController,
Clock.systemUTC(), nextTick, abilityScheduler, report -> {var current=dungeonController;if(current!=null)current.accept(report);}, combatFeedback, trainingDummyController,
problem -> getLogger().log(Level.WARNING, "Ability engine failed for character " + problem.character()
+ "; ability " + problem.ability(), problem.cause()));
abilityEngine.install(this);
@@ -515,8 +560,35 @@ public final class FantasyStickPlugin extends JavaPlugin {
if(lifetime==generation)localArmorMenus.refreshOpen();});}
catch(RuntimeException failure){if(lifetime==generation)getLogger().log(Level.WARNING,"Fantasy Armorer menu refresh failed",failure);}});
armorerController=new games.dmg.fantasystick.shop.FantasyArmorerController(this,armorers,armorShopMenus::open);
adventureQuestController = new games.dmg.fantasystick.adventure.AdventureQuestController(this,
adventureQuests, adventureQuestSettings, sessions, parties, characters, rules, nextTick,
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(),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.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));
|| armorerController.tagged(entity) || adventureQuestController.giver(entity));
admin = new EquipmentAdminCommand(receipts, sessions, getServer(), nextTick,
problem -> getLogger().log(Level.WARNING, "Equipment admin " + problem.operation()
+ " for player " + problem.player(), problem.cause()), adventureSettings,
@@ -538,7 +610,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
admin.attachExperienceGrant(new games.dmg.fantasystick.session.AdminExperienceGrant(
characters, rules, sessions, nextTick, getServer()::getPlayer,
problem -> getLogger().log(Level.WARNING,
"Admin experience grant for character " + problem.character(), problem.cause())));
"Admin experience grant for character " + problem.character(), problem.cause()), particleDelivery));
admin.attachSwitchDelay(switchDelaySettings);
admin.attachRoleplaySettings(roleplaySettings);
admin.attachPartySettings(partySettings);
@@ -549,6 +621,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
admin.attachArmorers(armorers,armorerController);
admin.attachArmorMarket(armorMarket);
admin.attachCoinSettings(coinSettings);
admin.attachHardMobXpSettings(hardMobXpSettings, combatExperience);
admin.attachAdventureQuests(adventureQuestCommands, adventureQuestSettings);
admin.attachDungeons(dungeonCommands);
admin.attachSheetResourcePack(sheetResourcePackSettings, sheetResourcePack);
admin.attachOutOfCombatRecovery(outOfCombatRecoverySettings, outOfCombatRecovery);
admin.attachQuickerRegeneration(quickerRegenerationSettings, quickerRegeneration, passiveRegistry);
@@ -558,17 +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())));
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);
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);
@@ -577,9 +663,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(partyRegistrarMenus, this);
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)->{
@@ -617,7 +709,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(books, this);
getServer().getPluginManager().registerEvents(gameModes, this);
getServer().getPluginManager().registerEvents(sheetResourcePack, this);
sessionVisuals = new games.dmg.fantasystick.session.SessionVisuals(sessions,
sessionVisuals = new games.dmg.fantasystick.session.SessionVisuals(sessions, particleDelivery,
failure -> getLogger().log(Level.WARNING, "Fantasy session particle delivery failed", failure));
sessionVisuals.install(this);
recipeRegistered = getServer().addRecipe(new CharacterSheets().recipe());
@@ -642,8 +734,11 @@ 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;
particleDelivery = null;
combatFeedback = null;
var closingEvents = sessionEvents; sessionEvents = null;
var closingBooks = books; books = null;
@@ -655,6 +750,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingPartyRegistrarController = partyRegistrarController; partyRegistrarController = null;
var closingTrainingDummies = trainingDummies; trainingDummies = null;
var closingTrainingDummyController = trainingDummyController; trainingDummyController = null;
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;
var closingArmorers=armorers;armorers=null;var closingArmorerController=armorerController;armorerController=null;
@@ -716,6 +816,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingPartyEconomySettings = partyEconomySettings; partyEconomySettings = null;
var closingGearSettings=gearSettings;gearSettings=null;
var closingCoinSettings = coinSettings; coinSettings = null;
var closingHardMobXpSettings = hardMobXpSettings; hardMobXpSettings = null;
var closingAdventureQuestSettings = adventureQuestSettings; adventureQuestSettings = null;
var closingSheetResourcePackSettings = sheetResourcePackSettings; sheetResourcePackSettings = null;
var closingSheetResourcePack = sheetResourcePack; sheetResourcePack = null;
var closingTemporaryHealth = temporaryHealth; temporaryHealth = null;
@@ -745,6 +847,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingProtocol != null) cleanup("protocol presentation", closingProtocol::close);
if (closingAdmin != null) cleanup("admin command", closingAdmin::close);
if (closingFeedbackCommand != null) cleanup("feedback command", closingFeedbackCommand::close);
if (closingParticlesCommand != null) cleanup("particles command", closingParticlesCommand::close);
if (closingFeedbackPreferences != null) cleanup("feedback preferences", closingFeedbackPreferences::close);
if (closingSettings != null) cleanup("configuration", closingSettings::close);
if (closingFireballSettings != null) cleanup("Fireball configuration", closingFireballSettings::close);
@@ -780,6 +883,14 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingPartyEconomySettings != null) cleanup("party economy configuration", closingPartyEconomySettings::close);
if(closingGearSettings!=null)cleanup("gear configuration",closingGearSettings::close);
if (closingCoinSettings != null) cleanup("coin configuration", closingCoinSettings::close);
if (closingHardMobXpSettings != null) cleanup("hard-mob XP configuration", closingHardMobXpSettings::close);
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);
if (closingSheetResourcePack != null) cleanup("sheet resource-pack", closingSheetResourcePack::close);
if (closingOutOfCombatRecoverySettings != null) cleanup("out-of-combat recovery configuration", closingOutOfCombatRecoverySettings::close);
@@ -802,6 +913,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
closingGearSettings==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingGearSettings.drained().toCompletableFuture(),
closingPartyEconomySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingPartyEconomySettings.drained().toCompletableFuture(),
closingHardMobXpSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingHardMobXpSettings.drained().toCompletableFuture(),
closingAdventureQuestSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: 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)
: closingSettings.drained().toCompletableFuture(),
closingFireballSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
@@ -910,6 +1030,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
unregister(closingTrainingDummyController);
unregister(closingPartyRegistrarMenus);
unregister(closingArmorerController);unregister(closingArmorShopMenus);
unregister(closingAdventureQuestController);
unregister(closingFishingController);
unregister(closingDungeonController);
unregister(closingCombatExperience);
unregister(closingHealthSteal);
unregister(closingFireball);
@@ -923,6 +1046,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
unregister(closingGreatSword);
unregister(closingRevive);
unregister(closingAdminRevival);
unregister(closingCharacterRecovery);
unregister(closingBasicAttack);
unregister(closingNutrition);
unregister(closingOutOfCombatRecovery);
@@ -969,13 +1093,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
}
}
private games.dmg.fantasystick.progression.CombatRewardRules combatRewardRules() {
private games.dmg.fantasystick.progression.CombatRewardRules combatRewardRules(java.math.BigInteger hard) {
try {
var passive = new java.math.BigInteger(getConfig().getString("passive-xp", "1"));
var ordinary = new java.math.BigInteger(getConfig().getString("ordinary-xp", "10"));
var boss = new java.math.BigInteger(getConfig().getString("boss-xp", "100"));
var spacing = new java.math.BigDecimal(getConfig().getString("restricted-kill-spacing-blocks", "3"));
return new games.dmg.fantasystick.progression.CombatRewardRules(passive, ordinary, boss, spacing);
return new games.dmg.fantasystick.progression.CombatRewardRules(passive, ordinary, hard, boss, spacing);
} catch (NumberFormatException invalid) {
throw new IllegalArgumentException("Combat reward settings must be decimal numbers", invalid);
}
@@ -1190,8 +1314,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
default -> notice.message();
};
player.sendMessage(message);
if (notice.code().equals("DEFEAT")) games.dmg.fantasystick.session.SessionVisuals.burst(player, org.bukkit.Color.RED, 12);
else player.spawnParticle(org.bukkit.Particle.ANGRY_VILLAGER, player.getLocation().add(0, 1, 0), 8, 0.3, 0.4, 0.3, 0);
var particles = particleDelivery;
if (particles == null) return;
if (notice.code().equals("DEFEAT")) particles.world(org.bukkit.Particle.DUST,
player.getLocation().add(0, 1, 0), 12, 0.3, 0.4, 0.3, 0,
new org.bukkit.Particle.DustOptions(org.bukkit.Color.RED, 1));
else particles.viewer(player, org.bukkit.Particle.ANGRY_VILLAGER,
player.getLocation().add(0, 1, 0), 8, 0.3, 0.4, 0.3, 0);
};
if (lifetime != generation) return;
if (getServer().isPrimaryThread()) delivery.run();
@@ -13,9 +13,10 @@ import org.bukkit.Material;
/** Immutable internal ability metadata and focused effect boundary; not an external add-on API. */
public record AbilityDefinition(String id, String name, Material icon, List<String> lore, String instructions,
Map<Stat, BigInteger> requirements, BigInteger pointCost, Activation activation,
Map<Stat, BigInteger> requirements, BigInteger pointCost, Category category, Activation activation,
ControlInput.Gesture gesture, Duration cooldown, Map<String, BigDecimal> parameters,
TargetPolicy targetPolicy, AbilityEffect effect) {
public enum Category { ATK, BUF, HEAL }
public enum Activation { IMMEDIATE, DELAYED, PROJECTILE, AREA, SELECTION }
public enum TargetPolicy { SELF, HOSTILE, PROJECTILE_HOSTILE, ALLY, PARTY, AREA, SELECTION, EXCEPTIONAL_NONCOMBAT,
EXCEPTIONAL_NONCOMBAT_SELECTION }
@@ -25,7 +26,7 @@ public record AbilityDefinition(String id, String name, Material icon, List<Stri
public AbilityDefinition {
Objects.requireNonNull(id); Objects.requireNonNull(name); Objects.requireNonNull(icon);
Objects.requireNonNull(lore); Objects.requireNonNull(instructions); Objects.requireNonNull(requirements);
Objects.requireNonNull(pointCost); Objects.requireNonNull(activation); Objects.requireNonNull(gesture);
Objects.requireNonNull(pointCost); Objects.requireNonNull(category); Objects.requireNonNull(activation); Objects.requireNonNull(gesture);
Objects.requireNonNull(cooldown); Objects.requireNonNull(parameters); Objects.requireNonNull(targetPolicy);
Objects.requireNonNull(effect);
lore = List.copyOf(lore); requirements = Map.copyOf(requirements); parameters = Map.copyOf(parameters);
@@ -55,12 +55,17 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
private volatile Settings settings;
private final EquipmentSessions sessions;
private final PartyRoster parties;
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private Attempt damaging;
private boolean closed;
public ChainLightning(Settings settings, EquipmentSessions sessions, PartyRoster parties) {
this(settings, sessions, parties, games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());
}
public ChainLightning(Settings settings, EquipmentSessions sessions, PartyRoster parties,
games.dmg.fantasystick.presentation.ParticleDelivery particles) {
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
this.parties = Objects.requireNonNull(parties);
this.parties = Objects.requireNonNull(parties); this.particles = Objects.requireNonNull(particles);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
@@ -72,7 +77,7 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
"Each jump deals " + decimal(current.jumpMultiplier().movePointRight(2))
+ "% of the previous defined damage within " + decimal(BigDecimal.valueOf(current.jumpRange())) + " blocks."),
"Use while aiming at the first visible hostile within " + decimal(BigDecimal.valueOf(current.initialRange())) + " blocks.",
Map.of(Stat.INT, BigInteger.valueOf(20), Stat.DEX, BigInteger.valueOf(12)), BigInteger.ONE,
Map.of(Stat.INT, BigInteger.valueOf(20), Stat.DEX, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-damage", new BigDecimal(current.baseDamage()),
"initial-range", BigDecimal.valueOf(current.initialRange()).stripTrailingZeros(),
@@ -181,10 +186,10 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
attempt.execution.report(AbilityReport.Type.DAMAGE, attempt.target, BigDecimal.valueOf(event.getFinalDamage()));
}
private static void visual(Player caster, LivingEntity target) {
private void visual(Player caster, LivingEntity target) {
try {
var location = target.getLocation().clone().add(0, target.getHeight() / 2, 0);
caster.getWorld().spawnParticle(Particle.ELECTRIC_SPARK, location, 18, 0.3, 0.5, 0.3, 0.02);
particles.world(Particle.ELECTRIC_SPARK, location, 18, 0.3, 0.5, 0.3, 0.02);
caster.getWorld().playSound(location, Sound.ENTITY_LIGHTNING_BOLT_THUNDER, 0.35f, 1.7f);
} catch (RuntimeException ignored) { }
}
@@ -44,6 +44,7 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
}
private volatile Settings settings;
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private final Map<UUID, State> projectiles = new HashMap<>();
private State damaging;
private boolean closed;
@@ -68,7 +69,12 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
}
}
public Fireball(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public Fireball(Settings settings) {
this(settings, games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());
}
public Fireball(Settings settings, games.dmg.fantasystick.presentation.ParticleDelivery particles) {
this.settings = Objects.requireNonNull(settings); this.particles = Objects.requireNonNull(particles);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
@@ -76,7 +82,7 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
return new AbilityDefinition("fireball", "Fireball", Material.SNOWBALL,
List.of("Launch a bounded fiery arrow flight.", "Deals " + current.baseDamage() + " + INT modifier damage."),
"Use the equipped snowball; a miss still spends the cooldown.",
Map.of(Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE,
Map.of(Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-damage", new BigDecimal(current.baseDamage()),
"range", BigDecimal.valueOf(current.range()).stripTrailingZeros(),
@@ -119,7 +125,7 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
long rangeTicks = Math.max(1, (long) Math.ceil(current.range() / current.speedPerTick()));
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), execution::finish);
execution.later(current.lifetime(), execution::finish);
player.getWorld().spawnParticle(Particle.FLAME, origin, 8, 0.15, 0.15, 0.15, 0.01);
particles.world(Particle.FLAME, origin, 8, 0.15, 0.15, 0.15, 0.01);
}
@EventHandler(priority = EventPriority.MONITOR)
@@ -85,13 +85,18 @@ public final class FrostNova implements AbilityEffect, Listener, AutoCloseable {
private volatile Settings settings;
private final EquipmentSessions sessions;
private final PartyRoster parties;
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private final Map<AbilityExecution, LeaseGroup> active = new java.util.IdentityHashMap<>();
private Attempt damaging;
private boolean closed;
public FrostNova(Settings settings, EquipmentSessions sessions, PartyRoster parties) {
this(settings, sessions, parties, games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());
}
public FrostNova(Settings settings, EquipmentSessions sessions, PartyRoster parties,
games.dmg.fantasystick.presentation.ParticleDelivery particles) {
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
this.parties = Objects.requireNonNull(parties);
this.parties = Objects.requireNonNull(parties); this.particles = Objects.requireNonNull(particles);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
@@ -102,7 +107,7 @@ public final class FrostNova implements AbilityEffect, Listener, AutoCloseable {
+ current.baseDamage() + " + INT modifier damage.",
"Applies Slowness " + level(current.slownessAmplifier()) + " for " + seconds(current.slownessDuration()) + " seconds."),
"Use to affect exposed enemies; caster, party and ordinary players are excluded.",
Map.of(Stat.INT, BigInteger.valueOf(16), Stat.DEX, BigInteger.TEN), BigInteger.ONE,
Map.of(Stat.INT, BigInteger.valueOf(16), Stat.DEX, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-damage", new BigDecimal(current.baseDamage()),
"radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
@@ -145,7 +150,7 @@ public final class FrostNova implements AbilityEffect, Listener, AutoCloseable {
captured.slownessAmplifier(), false, true, true));
if (lease.apply()) group.leases.add(lease);
}
try { source.getWorld().spawnParticle(Particle.SNOWFLAKE, origin, 32,
try { particles.world(Particle.SNOWFLAKE, origin, 32,
captured.radius() / 2, captured.radius() / 2, captured.radius() / 2, 0.01); }
catch (RuntimeException ignored) { }
if (group.leases.isEmpty()) execution.finish();
@@ -99,11 +99,17 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
}
private volatile Settings settings;
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private final Map<UUID, State> projectiles = new HashMap<>();
private State damaging;
private boolean closed;
public FrostShard(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public FrostShard(Settings settings) {
this(settings, games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());
}
public FrostShard(Settings settings, games.dmg.fantasystick.presentation.ParticleDelivery particles) {
this.settings = Objects.requireNonNull(settings); this.particles = Objects.requireNonNull(particles);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
@@ -114,7 +120,7 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
"Deals " + current.baseDamage() + " + INT modifier damage and Slowness "
+ level(current.slownessAmplifier()) + " for " + seconds(current.slownessDuration()) + "s."),
"Use the equipped shard; a miss still spends the cooldown.",
Map.of(Stat.INT, BigInteger.valueOf(12), Stat.WIS, BigInteger.TEN), BigInteger.ONE,
Map.of(Stat.INT, BigInteger.valueOf(12), Stat.WIS, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-damage", new BigDecimal(current.baseDamage()),
"range", BigDecimal.valueOf(current.range()).stripTrailingZeros(),
@@ -161,7 +167,7 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
execution.later(current.lifetime(), () -> {
if (projectiles.containsKey(projectile.getUniqueId())) execution.finish();
});
player.getWorld().spawnParticle(Particle.SNOWFLAKE, origin, 8, 0.15, 0.15, 0.15, 0.01);
particles.world(Particle.SNOWFLAKE, origin, 8, 0.15, 0.15, 0.15, 0.01);
}
@EventHandler(priority = EventPriority.HIGHEST)
@@ -50,7 +50,7 @@ public final class GreatSword implements AbilityEffect, Listener, AutoCloseable
List.of("Charge a contained melee strike.",
"Fully charged: " + settings.baseDamage() + " + STR modifier damage."),
"Attack with the equipped sword; partial charge follows the native attack curve.",
Map.of(Stat.STR, BigInteger.valueOf(12)), BigInteger.ONE,
Map.of(Stat.STR, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.ATTACK, settings.recovery(),
Map.of("base-damage", new BigDecimal(settings.baseDamage())),
AbilityDefinition.TargetPolicy.HOSTILE, this);
@@ -130,7 +130,7 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
List.of("Redirect " + decimal(current.redirectPercent()) + "% of an aimed party ally's combat damage.",
"Range " + decimal(BigDecimal.valueOf(current.range())) + " blocks for " + seconds(current.duration()) + " seconds."),
"Use on an aimed active party ally; one guardian and one bond per ally.",
Map.of(Stat.CON, BigInteger.valueOf(16), Stat.WIS, BigInteger.TEN), BigInteger.ONE,
Map.of(Stat.CON, BigInteger.valueOf(16), Stat.WIS, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.BUF,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("range", BigDecimal.valueOf(current.range()).stripTrailingZeros(),
"duration-seconds", BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),
@@ -30,7 +30,7 @@ public final class Hex implements AbilityEffect,AutoCloseable{
public AbilityDefinition definition(){var current=settings;return new AbilityDefinition("hex","Hex",Material.SPIDER_EYE,
List.of("Reduce an aimed hostile's combat damage by "+current.reductionPercent().stripTrailingZeros().toPlainString()+"%.",
"Range "+BigDecimal.valueOf(current.range()).stripTrailingZeros().toPlainString()+" blocks; lasts "+seconds(current.duration())+" seconds."),
"Use while aiming at the first visible hostile; there is no fallback target.",Map.of(Stat.CHR,BigInteger.valueOf(14),Stat.WIS,BigInteger.valueOf(12)),BigInteger.ONE,
"Use while aiming at the first visible hostile; there is no fallback target.",Map.of(Stat.CHR,BigInteger.valueOf(14),Stat.WIS,BigInteger.valueOf(12)),BigInteger.ONE,AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,current.cooldown(),Map.of("range",BigDecimal.valueOf(current.range()).stripTrailingZeros(),
"duration-seconds",BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),"damage-reduction-percent",current.reductionPercent()),
AbilityDefinition.TargetPolicy.HOSTILE,this);}
@@ -5,7 +5,7 @@ public final class Inspire implements AbilityEffect,AutoCloseable{
public record Settings(BigInteger baseTemporaryHealth,Duration cooldown,double range,Duration duration){public Settings{Objects.requireNonNull(baseTemporaryHealth);Objects.requireNonNull(cooldown);Objects.requireNonNull(duration);if(baseTemporaryHealth.signum()<0||baseTemporaryHealth.compareTo(BigDecimal.valueOf(Double.MAX_VALUE).toBigInteger())>0||cooldown.isNegative()||cooldown.compareTo(Duration.ofDays(365))>0||!Double.isFinite(range)||range<=0||range>64||duration.isZero()||duration.isNegative()||duration.compareTo(Duration.ofDays(1))>0)throw new IllegalArgumentException("Invalid Inspire settings");}public static Settings defaults(){return new Settings(BigInteger.valueOf(4),Duration.ofSeconds(15),12,Duration.ofSeconds(6));}}
private volatile Settings settings;private final TemporaryHealth temporaryHealth;private boolean closed;
public Inspire(Settings settings,TemporaryHealth temporaryHealth){this.settings=Objects.requireNonNull(settings);this.temporaryHealth=Objects.requireNonNull(temporaryHealth);}public void configure(Settings settings){this.settings=Objects.requireNonNull(settings);}
public AbilityDefinition definition(){var s=settings;return new AbilityDefinition("inspire","Inspire",Material.AMETHYST_SHARD,List.of("Grant "+s.baseTemporaryHealth()+" + CHR modifier temporary health for "+seconds(s.duration())+" seconds.","Range "+BigDecimal.valueOf(s.range()).stripTrailingZeros().toPlainString()+" blocks for an aimed fantasy player."),"Sneak+Use selects self; normal Use requires an aimed fantasy player.",Map.of(Stat.CHR,BigInteger.valueOf(12),Stat.WIS,BigInteger.TEN),BigInteger.ONE,AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,s.cooldown(),Map.of("base-temporary-health",new BigDecimal(s.baseTemporaryHealth()),"range",BigDecimal.valueOf(s.range()).stripTrailingZeros(),"duration-seconds",BigDecimal.valueOf(s.duration().toNanos()).movePointLeft(9)),AbilityDefinition.TargetPolicy.ALLY,this);}
public AbilityDefinition definition(){var s=settings;return new AbilityDefinition("inspire","Inspire",Material.AMETHYST_SHARD,List.of("Grant "+s.baseTemporaryHealth()+" + CHR modifier temporary health for "+seconds(s.duration())+" seconds.","Range "+BigDecimal.valueOf(s.range()).stripTrailingZeros().toPlainString()+" blocks for an aimed fantasy player."),"Sneak+Use selects self; normal Use requires an aimed fantasy player.",Map.of(Stat.CHR,BigInteger.valueOf(12),Stat.WIS,BigInteger.TEN),BigInteger.ONE,AbilityDefinition.Category.BUF,AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,s.cooldown(),Map.of("base-temporary-health",new BigDecimal(s.baseTemporaryHealth()),"range",BigDecimal.valueOf(s.range()).stripTrailingZeros(),"duration-seconds",BigDecimal.valueOf(s.duration().toNanos()).movePointLeft(9)),AbilityDefinition.TargetPolicy.ALLY,this);}
@Override public boolean accepts(ControlInput input){if(closed||!(input.target() instanceof Player target))return false;if(input.player().isSneaking())return target==input.player();return target!=input.player()&&inRange(input.player(),target,settings.range());}
public static BigInteger amount(BigInteger charisma){return amount(BigInteger.valueOf(4),charisma);}static BigInteger amount(BigInteger base,BigInteger charisma){Objects.requireNonNull(base);Objects.requireNonNull(charisma);if(base.signum()<0||charisma.signum()<0)throw new IllegalArgumentException("Invalid Inspire amount");var delta=charisma.subtract(BigInteger.TEN);var divided=delta.divideAndRemainder(BigInteger.TWO);var modifier=divided[0];if(delta.signum()<0&&divided[1].signum()!=0)modifier=modifier.subtract(BigInteger.ONE);return base.add(modifier);}
@Override public void activate(AbilityExecution execution){if(closed||!execution.stillCurrent()||!(execution.input().target() instanceof Player target))return;var s=from(execution.definition());if(execution.input().player().isSneaking()?target!=execution.input().player():target==execution.input().player()||!inRange(execution.input().player(),target,s.range())){execution.finish();return;}var exact=amount(s.baseTemporaryHealth(),execution.character().stats().get(Stat.CHR));double nativeAmount=exact.bitLength()>1023?Double.MAX_VALUE:exact.doubleValue();if(!Double.isFinite(nativeAmount)||nativeAmount<=0){execution.finish();return;}execution.own(temporaryHealth.grant(execution,target,nativeAmount));execution.later(s.duration(),execution::finish);}
@@ -83,7 +83,7 @@ public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
List.of("Draw and release an ammunition-free owned arrow.",
"A full draw deals " + current.baseDamage() + " + DEX modifier damage."),
"Draw by holding use; release after at least " + current.minimumDrawTicks() + " ticks.",
Map.of(Stat.DEX, BigInteger.valueOf(14)), BigInteger.ONE,
Map.of(Stat.DEX, BigInteger.valueOf(14)), BigInteger.ONE, AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.DRAW_RELEASE, current.recovery(),
Map.of("base-damage", new BigDecimal(current.baseDamage()),
"minimum-draw-ticks", BigDecimal.valueOf(current.minimumDrawTicks()),
@@ -70,7 +70,7 @@ public final class MendingLight implements AbilityEffect {
List.of("Restore a party ally's missing fantasy health.",
"Heals " + current.baseHealing() + " + WIS modifier within " + decimal(current.range()) + " blocks."),
"Use while aiming at an injured active party ally; self and full-health targets are refused.",
Map.of(Stat.WIS, BigInteger.valueOf(12), Stat.CHR, BigInteger.TEN), BigInteger.ONE,
Map.of(Stat.WIS, BigInteger.valueOf(12), Stat.CHR, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.HEAL,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-healing", new BigDecimal(current.baseHealing()),
"range", BigDecimal.valueOf(current.range()).stripTrailingZeros()),
@@ -48,18 +48,20 @@ public final class Meteor implements AbilityEffect, Listener, AutoCloseable {
void step(){if(closed||impacted)return;if(!execution.stillCurrent()){execution.finish();return;}elapsed++;if(elapsed>=maximumTicks){impact();execution.finish();return;}warningVisual();execution.later(Duration.ofMillis(50),this::step);}
void impact(){if(closed||impacted)return;impacted=true;if(!groundCurrent()||!execution.stillCurrent())return;impactVisual();double amount=finite(damage(captured.baseDamage(),intelligence));int affected=0;for(var target:candidates(ground.origin(),captured.radius(),captured.verticalRange())){if(affected==64||!execution.stillCurrent())break;if(!eligible(execution,target,ground.origin(),captured.radius(),captured.verticalRange()))continue;var sourceDamage=DamageSource.builder(DamageType.MAGIC).withCausingEntity(source).withDirectEntity(source).withDamageLocation(ground.origin()).build();var attempt=new Attempt(execution,target);damaging=attempt;try{execution.damage(target,amount,sourceDamage);}finally{if(damaging==attempt)damaging=null;}affected++;}}
boolean groundCurrent(){try{return ground.origin().getWorld()!=null&&ground.block().getWorld()==ground.origin().getWorld()&&ground.block().getType().isSolid();}catch(RuntimeException invalid){return false;}}
void warningVisual(){try{var dust=new Particle.DustOptions(Color.fromRGB(255,90,20),1.6f);ground.origin().getWorld().spawnParticle(Particle.DUST,ground.origin().clone().add(0,.15,0),24,captured.radius()/2,.05,captured.radius()/2,0,dust);}catch(RuntimeException ignored){}}
void impactVisual(){try{var world=ground.origin().getWorld();world.spawnParticle(Particle.EXPLOSION_EMITTER,ground.origin(),2,0,0,0,0);world.spawnParticle(Particle.FLAME,ground.origin(),48,captured.radius()/2,1,captured.radius()/2,.04);world.playSound(ground.origin(),Sound.ENTITY_GENERIC_EXPLODE,1f,.7f);}catch(RuntimeException ignored){}}
void warningVisual(){try{var dust=new Particle.DustOptions(Color.fromRGB(255,90,20),1.6f);particles.world(Particle.DUST,ground.origin().clone().add(0,.15,0),24,captured.radius()/2,.05,captured.radius()/2,0,dust);}catch(RuntimeException ignored){}}
void impactVisual(){try{var world=ground.origin().getWorld();particles.world(Particle.EXPLOSION_EMITTER,ground.origin(),2,0,0,0,0);particles.world(Particle.FLAME,ground.origin(),48,captured.radius()/2,1,captured.radius()/2,.04);world.playSound(ground.origin(),Sound.ENTITY_GENERIC_EXPLODE,1f,.7f);}catch(RuntimeException ignored){}}
@Override public void close(){if(closed)return;closed=true;warnings.remove(execution,this);}
}
private volatile Settings settings;private final EquipmentSessions sessions;private final PartyRoster parties;
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private final Map<AbilityExecution,Warning> warnings=new IdentityHashMap<>();private Attempt damaging;private boolean closed;
public Meteor(Settings settings,EquipmentSessions sessions,PartyRoster parties){this.settings=Objects.requireNonNull(settings);this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);}
public Meteor(Settings settings,EquipmentSessions sessions,PartyRoster parties){this(settings,sessions,parties,games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());}
public Meteor(Settings settings,EquipmentSessions sessions,PartyRoster parties,games.dmg.fantasystick.presentation.ParticleDelivery particles){this.settings=Objects.requireNonNull(settings);this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.particles=Objects.requireNonNull(particles);}
public void configure(Settings settings){this.settings=Objects.requireNonNull(settings);}
public AbilityDefinition definition(){var current=settings;return new AbilityDefinition("meteor","Meteor",Material.FIRE_CHARGE,
List.of("Mark visible ground within "+decimal(current.targetRange())+" blocks for a "+seconds(current.warning())+"-second warning.","Impact exposed enemies in a "+decimal(current.radius())+"-block area for "+current.baseDamage()+" + INT modifier damage."),
"Use while looking at a solid top surface; the warning is visible and the impact never changes terrain.",Map.of(Stat.INT,BigInteger.valueOf(26),Stat.WIS,BigInteger.valueOf(20),Stat.CON,BigInteger.valueOf(12)),BigInteger.ONE,
"Use while looking at a solid top surface; the warning is visible and the impact never changes terrain.",Map.of(Stat.INT,BigInteger.valueOf(26),Stat.WIS,BigInteger.valueOf(20),Stat.CON,BigInteger.valueOf(12)),BigInteger.ONE,AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,current.cooldown(),Map.of("base-damage",new BigDecimal(current.baseDamage()),"target-range",BigDecimal.valueOf(current.targetRange()).stripTrailingZeros(),"warning-seconds",nanos(current.warning()),"radius",BigDecimal.valueOf(current.radius()).stripTrailingZeros(),"vertical-range",BigDecimal.valueOf(current.verticalRange()).stripTrailingZeros()),AbilityDefinition.TargetPolicy.AREA,this);}
public static BigInteger damage(BigInteger intelligence){return damage(BigInteger.valueOf(14),intelligence);}
static BigInteger damage(BigInteger base,BigInteger intelligence){Objects.requireNonNull(base);Objects.requireNonNull(intelligence);if(base.signum()<0||intelligence.signum()<0)throw new IllegalArgumentException("Invalid Meteor damage input");var delta=intelligence.subtract(BigInteger.TEN);var divided=delta.divideAndRemainder(BigInteger.TWO);var modifier=divided[0];if(delta.signum()<0&&divided[1].signum()!=0)modifier=modifier.subtract(BigInteger.ONE);return base.add(modifier);}
@@ -58,7 +58,7 @@ public final class Purify implements AbilityEffect {
List.of("Remove Poison, Wither, Slowness and Weakness.",
"Aimed party-ally range: " + decimal(current.range()) + " blocks; beneficial effects remain."),
"Sneak+Use cleanses self; normal Use requires an aimed active party ally.",
Map.of(Stat.WIS, BigInteger.valueOf(14), Stat.CHR, BigInteger.TEN), BigInteger.ONE,
Map.of(Stat.WIS, BigInteger.valueOf(14), Stat.CHR, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.BUF,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, current.cooldown(),
Map.of("range", BigDecimal.valueOf(current.range()).stripTrailingZeros()),
AbilityDefinition.TargetPolicy.PARTY, this);
@@ -80,7 +80,7 @@ public final class Quickstep implements AbilityEffect, Listener, AutoCloseable {
List.of("Dash forward with native horizontal movement.",
"Travel up to " + decimal(current.distance()) + " blocks while obstacles remain authoritative."),
"Use while grounded, on foot, and not flying or gliding.",
Map.of(Stat.DEX, BigInteger.valueOf(12)), BigInteger.ONE,
Map.of(Stat.DEX, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.BUF,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("distance", BigDecimal.valueOf(current.distance()).stripTrailingZeros(),
"speed", BigDecimal.valueOf(current.speedPerTick()).stripTrailingZeros()),
@@ -175,7 +175,7 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
+ " and " + decimal(current.damageBonusPercent()) + "% combat damage.",
"A " + decimal(BigDecimal.valueOf(current.radius())) + "-block sphere lasting " + seconds(current.duration()) + " seconds."),
"Use to rally yourself and nearby active party members; copies do not stack.",
Map.of(Stat.CHR, BigInteger.valueOf(16), Stat.CON, BigInteger.valueOf(12)), BigInteger.ONE,
Map.of(Stat.CHR, BigInteger.valueOf(16), Stat.CON, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.BUF,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
"duration-seconds", BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),
@@ -85,7 +85,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
List.of("Let another player continue one fallen character.", "A valid cast spends the daily opportunity."),
"Use the equipped shard while aiming at another player within 12 blocks.",
Map.of(Stat.WIS, BigInteger.valueOf(30), Stat.CHR, BigInteger.valueOf(24), Stat.CON, BigInteger.valueOf(12)),
BigInteger.ONE, AbilityDefinition.Activation.SELECTION, ControlInput.Gesture.USE, Duration.ofHours(24),
BigInteger.ONE, AbilityDefinition.Category.HEAL, AbilityDefinition.Activation.SELECTION, ControlInput.Gesture.USE, Duration.ofHours(24),
Map.of("range", BigDecimal.valueOf(12), "selection-time", BigDecimal.valueOf(300),
"death-age", BigDecimal.valueOf(86400)),
AbilityDefinition.TargetPolicy.EXCEPTIONAL_NONCOMBAT_SELECTION, this);
@@ -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);
@@ -137,7 +137,7 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
return record == null ? null : new Target(player, active, record);
}
void visual() {
try { source.getWorld().spawnParticle(Particle.END_ROD, origin, 12,
try { particles.world(Particle.END_ROD, origin, 12,
captured.radius() / 2, 0.1, captured.radius() / 2, 0); } catch (RuntimeException ignored) { }
}
void closeMitigation() {
@@ -153,18 +153,28 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
private final CharacterStorage characters; private final EquipmentSessions sessions; private final PartyRoster parties;
private final CombatMitigation combatMitigation; private final Consumer<Runnable> nextTick;
private final Consumer<EntityRegainHealthEvent> healingEvents; private final Supplier<BigDecimal> nativeLimit;
private final Consumer<MendingLight.Support> supports; private final List<State> states = new ArrayList<>();
private final Consumer<MendingLight.Support> supports;
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private final List<State> states = new ArrayList<>();
private boolean closed;
public Sanctuary(Settings settings, CharacterStorage characters, EquipmentSessions sessions, PartyRoster parties,
CombatMitigation combatMitigation, Consumer<Runnable> nextTick,
Consumer<EntityRegainHealthEvent> healingEvents, Supplier<BigDecimal> nativeLimit,
Consumer<MendingLight.Support> supports) {
this(settings, characters, sessions, parties, combatMitigation, nextTick, healingEvents, nativeLimit, supports,
games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());
}
public Sanctuary(Settings settings, CharacterStorage characters, EquipmentSessions sessions, PartyRoster parties,
CombatMitigation combatMitigation, Consumer<Runnable> nextTick,
Consumer<EntityRegainHealthEvent> healingEvents, Supplier<BigDecimal> nativeLimit,
Consumer<MendingLight.Support> supports,
games.dmg.fantasystick.presentation.ParticleDelivery particles) {
this.settings = Objects.requireNonNull(settings); this.characters = Objects.requireNonNull(characters);
this.sessions = Objects.requireNonNull(sessions); this.parties = Objects.requireNonNull(parties);
this.combatMitigation = Objects.requireNonNull(combatMitigation); this.nextTick = Objects.requireNonNull(nextTick);
this.healingEvents = Objects.requireNonNull(healingEvents); this.nativeLimit = Objects.requireNonNull(nativeLimit);
this.supports = Objects.requireNonNull(supports);
this.supports = Objects.requireNonNull(supports); this.particles = Objects.requireNonNull(particles);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
@@ -174,7 +184,7 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
"Party members inside heal " + decimal(current.healingPerPulse()) + " each second and take "
+ decimal(current.damageReductionPercent()) + "% less combat damage."),
"Use to hold exposed ground; healing starts after one second and the area does not follow you.",
Map.of(Stat.WIS, BigInteger.valueOf(24), Stat.CHR, BigInteger.valueOf(20), Stat.CON, BigInteger.TEN), BigInteger.ONE,
Map.of(Stat.WIS, BigInteger.valueOf(24), Stat.CHR, BigInteger.valueOf(20), Stat.CON, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.HEAL,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
"vertical-range", BigDecimal.valueOf(current.verticalRange()).stripTrailingZeros(),
@@ -51,7 +51,7 @@ public final class SecondWind implements AbilityEffect {
List.of("Restore missing fantasy health.",
"Heals " + current.baseHealing() + " + WIS modifier, capped at maximum health."),
"Use while injured; full health refuses without spending cooldown.",
Map.of(Stat.CON, BigInteger.valueOf(12), Stat.WIS, BigInteger.valueOf(12)), BigInteger.ONE,
Map.of(Stat.CON, BigInteger.valueOf(12), Stat.WIS, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.HEAL,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-healing", new BigDecimal(current.baseHealing())),
AbilityDefinition.TargetPolicy.SELF, this);
@@ -61,7 +61,7 @@ public final class ShieldBash implements AbilityEffect, Listener, AutoCloseable
List.of("Strike within " + decimal(current.range()) + " blocks for " + current.baseDamage() + " + STR modifier damage.",
"Accepted damage adds knockback; this shield never enables ordinary blocking."),
"Attack a visible hostile at short range.", Map.of(Stat.STR, BigInteger.valueOf(12), Stat.CON, BigInteger.valueOf(12)),
BigInteger.ONE, AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.ATTACK, current.cooldown(),
BigInteger.ONE, AbilityDefinition.Category.ATK, AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.ATTACK, current.cooldown(),
Map.of("base-damage", new BigDecimal(current.baseDamage()), "range", decimalValue(current.range()),
"horizontal-knockback", decimalValue(current.horizontalKnockback()),
"minimum-vertical-knockback", decimalValue(current.minimumVerticalKnockback())),
@@ -53,7 +53,7 @@ public final class StoneSkin implements AbilityEffect, AutoCloseable {
return new AbilityDefinition("stone_skin", "Stone Skin", Material.BRICK,
List.of("Reduce entity and projectile combat damage by " + decimal(current.reductionPercent()) + "%.",
"Lasts " + decimal(BigDecimal.valueOf(current.duration().toMillis()).movePointLeft(3)) + " seconds without stacking."),
"Use to protect yourself; environmental damage is unchanged.", Map.of(Stat.CON, BigInteger.valueOf(14)), BigInteger.ONE,
"Use to protect yourself; environmental damage is unchanged.", Map.of(Stat.CON, BigInteger.valueOf(14)), BigInteger.ONE, AbilityDefinition.Category.BUF,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("duration-seconds", BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),
"damage-reduction-percent", current.reductionPercent()), AbilityDefinition.TargetPolicy.SELF, this);
@@ -97,7 +97,7 @@ public final class WarBanner implements AbilityEffect, AutoCloseable {
var ordered = new LinkedHashMap<UUID, Player>(); for (var player : result) ordered.put(player.getUniqueId(), player); return ordered;
}
void visual() {
try { origin.getWorld().spawnParticle(Particle.DUST, origin.clone().add(0, 1.5, 0), 16,
try { particles.world(Particle.DUST, origin.clone().add(0, 1.5, 0), 16,
0.25, 1.5, 0.25, 0, new Particle.DustOptions(Color.RED, 1.5f)); }
catch (RuntimeException ignored) { }
}
@@ -157,12 +157,19 @@ public final class WarBanner implements AbilityEffect, AutoCloseable {
private volatile Settings settings;
private final EquipmentSessions sessions; private final PartyRoster parties; private final OutgoingDamageBonus bonuses;
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private final Map<Long, Area> areas = new java.util.HashMap<>(); private final Map<UUID, ResistanceState> resistances = new java.util.HashMap<>();
private long sequences; private long resistanceSequences; private org.bukkit.scheduler.BukkitTask sweepTask; private boolean closed;
public WarBanner(Settings settings, EquipmentSessions sessions, PartyRoster parties, OutgoingDamageBonus bonuses) {
this(settings, sessions, parties, bonuses,
games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());
}
public WarBanner(Settings settings, EquipmentSessions sessions, PartyRoster parties, OutgoingDamageBonus bonuses,
games.dmg.fantasystick.presentation.ParticleDelivery particles) {
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
this.parties = Objects.requireNonNull(parties); this.bonuses = Objects.requireNonNull(bonuses);
this.particles = Objects.requireNonNull(particles);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public void install(org.bukkit.plugin.Plugin plugin) {
@@ -181,7 +188,7 @@ public final class WarBanner implements AbilityEffect, AutoCloseable {
"Party members in its " + decimal(current.radius()) + "-block area gain " + decimal(current.damageBonusPercent())
+ "% combat damage and " + decimal(current.knockbackResistancePercent()) + "% knockback resistance."),
"Use while looking at the solid top surface where the fixed banner should stand; copies do not stack.",
Map.of(Stat.STR, BigInteger.valueOf(22), Stat.CHR, BigInteger.valueOf(22), Stat.CON, BigInteger.valueOf(12)), BigInteger.ONE,
Map.of(Stat.STR, BigInteger.valueOf(22), Stat.CHR, BigInteger.valueOf(22), Stat.CON, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.BUF,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("placement-range", BigDecimal.valueOf(current.placementRange()).stripTrailingZeros(),
"radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
@@ -0,0 +1,66 @@
package games.dmg.fantasystick.adventure;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.bukkit.Location;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
/** Shared asynchronous, revalidated administrator teleport for durable adventure locations. */
public final class AdventureAdminTeleport {
private static final String PERMISSION="fantasystick.admin";
private final Server server;
private final Consumer<Runnable> nextTick;
private final AdventureLocationPresentation locations;
public AdventureAdminTeleport(Plugin plugin,Consumer<Runnable> nextTick){
this.server=Objects.requireNonNull(plugin).getServer();
this.nextTick=Objects.requireNonNull(nextTick);
this.locations=new AdventureLocationPresentation(server);
}
public record Target(UUID id,String name,String type,UUID world,double x,double y,double z,float yaw,float pitch,boolean preserveFacing){
public Target{Objects.requireNonNull(id);Objects.requireNonNull(name);Objects.requireNonNull(type);Objects.requireNonNull(world);}
}
public void teleport(CommandSender sender,String route,Target expected,Supplier<Optional<Target>> current){
Objects.requireNonNull(sender);Objects.requireNonNull(route);Objects.requireNonNull(expected);Objects.requireNonNull(current);
if(!(sender instanceof Player player)){sender.sendMessage(route+" teleport requires a player.");return;}
World world=server.getWorld(expected.world());
if(world==null){sender.sendMessage(route+" teleport refused: destination world is unavailable.");return;}
CompletableFuture<?> loading;
try{loading=world.getChunkAtAsync(section(expected.x()),section(expected.z()));}
catch(RuntimeException failure){sender.sendMessage(route+" teleport failed; your location is unchanged.");return;}
loading.whenComplete((chunk,loadFailure)->schedule(()->{
if(loadFailure!=null){player.sendMessage(route+" teleport failed; your location is unchanged.");return;}
if(!currentPlayer(player)){player.sendMessage(route+" teleport refused: permission or player session changed.");return;}
var fresh=current.get();
if(fresh.isEmpty()||!fresh.orElseThrow().equals(expected)){
player.sendMessage(route+" teleport refused: destination changed or is no longer registered.");return;
}
World currentWorld=server.getWorld(expected.world());
if(currentWorld==null||currentWorld!=world){player.sendMessage(route+" teleport refused: destination world is unavailable.");return;}
var facing=player.getLocation();
var destination=new Location(world,expected.x(),expected.y(),expected.z(),
expected.preserveFacing()?facing.getYaw():expected.yaw(),expected.preserveFacing()?facing.getPitch():expected.pitch());
CompletableFuture<Boolean> moving;
try{moving=player.teleportAsync(destination);}
catch(RuntimeException failure){player.sendMessage(route+" teleport failed; your location is unchanged.");return;}
moving.whenComplete((moved,moveFailure)->schedule(()->{
if(moveFailure!=null||!Boolean.TRUE.equals(moved)){player.sendMessage(route+" teleport failed; your location is unchanged.");return;}
if(player.isOnline())player.sendMessage("Teleported to "+expected.name()+" ("+expected.type()+") at "+locations.format(expected.world(),expected.x(),expected.y(),expected.z())+".");
}));
}));
}
private boolean currentPlayer(Player player){return player.isOnline()&&player.hasPermission(PERMISSION)&&server.getPlayer(player.getUniqueId())==player;}
private void schedule(Runnable task){try{nextTick.accept(task);}catch(RuntimeException ignored){}}
private static int section(double coordinate){return((int)Math.floor(coordinate))>>4;}
}
@@ -0,0 +1,27 @@
package games.dmg.fantasystick.adventure;
import java.util.Objects;
import java.util.UUID;
import org.bukkit.Server;
import org.bukkit.World;
/** Readable presentation for authoritative UUID-backed adventure locations. */
public final class AdventureLocationPresentation {
private final Server server;
public AdventureLocationPresentation(Server server) {
this.server = Objects.requireNonNull(server);
}
public String format(UUID worldId, double x, double y, double z) {
Objects.requireNonNull(worldId);
World world = server.getWorld(worldId);
String worldName = world == null || world.getName() == null || world.getName().isBlank()
? "unavailable world" : world.getName();
return worldName + " (" + block(x) + ", " + block(y) + ", " + block(z) + ")";
}
private static int block(double coordinate) {
return (int) Math.floor(coordinate);
}
}
@@ -0,0 +1,44 @@
package games.dmg.fantasystick.adventure;
import java.math.BigInteger;
import java.util.*;
import java.util.function.Consumer;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
/** Registered command adapter for player inspection and acknowledged adventure administration. */
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: /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("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(),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,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: /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>");}
}
@@ -0,0 +1,127 @@
package games.dmg.fantasystick.adventure;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.persistence.AdventureQuestSettings;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.ProgressionRules;
import games.dmg.fantasystick.progression.RewardAllocator;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.time.Clock;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import org.bukkit.*;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.*;
import org.bukkit.attribute.Attribute;
import org.bukkit.boss.*;
import org.bukkit.event.*;
import org.bukkit.event.entity.*;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.event.entity.PlayerLeashEntityEvent;
import org.bukkit.inventory.*;
import org.bukkit.persistence.PersistentDataType;
import org.bukkit.plugin.Plugin;
/** Native Quest Givers, shared camp/champion attempts, eligibility containment and idempotent completion rewards. */
public final class AdventureQuestController implements Listener,AutoCloseable {
private static final String GIVER="quest_giver",ENEMY="adventure_enemy",PROJECTILE="adventure_projectile";
private final Plugin plugin;private final AdventureQuestDirectory directory;private final AdventureQuestSettings settings;
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,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;
private java.util.function.BiPredicate<UUID,Location> dungeonRoomPresence=(ignored,location)->false;
private org.bukkit.scheduler.BukkitTask pulseTask;private boolean closed;
private static final class MenuHolder implements InventoryHolder {final UUID catalog;final int page;MenuHolder(UUID catalog,int page){this.catalog=catalog;this.page=page;}@Override public Inventory getInventory(){return null;}}
private static final class Attempt {final UUID id=UUID.randomUUID(),site,catalog;final AdventureQuestState.SiteType type;final Set<UUID> entities=new HashSet<>(),represented=new HashSet<>();final AdventureQuestState.Arena arena;final int resetSeconds;BossBar bossBar;LivingEntity champion;Instant absentSince;Attempt(UUID site,UUID catalog,AdventureQuestState.SiteType type,AdventureQuestState.Arena arena,int resetSeconds){this.site=site;this.catalog=catalog;this.type=type;this.arena=arena;this.resetSeconds=resetSeconds;}}
public AdventureQuestController(Plugin plugin,AdventureQuestDirectory directory,AdventureQuestSettings settings,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,Clock clock,java.util.random.RandomGenerator random){this(plugin,directory,settings,sessions,parties,characters,progression,nextTick,clock,random,games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());}
public AdventureQuestController(Plugin plugin,AdventureQuestDirectory directory,AdventureQuestSettings settings,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,Clock clock,java.util.random.RandomGenerator random,games.dmg.fantasystick.presentation.ParticleDelivery particles){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.settings=Objects.requireNonNull(settings);this.particles=Objects.requireNonNull(particles);navigation=new AdventureQuestNavigation(plugin.getServer());this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.characters=Objects.requireNonNull(characters);this.progression=Objects.requireNonNull(progression);this.nextTick=Objects.requireNonNull(nextTick);this.clock=Objects.requireNonNull(clock);this.random=Objects.requireNonNull(random);typeKey=new NamespacedKey(plugin,"managed_npc_type");idKey=new NamespacedKey(plugin,"quest_giver_id");siteKey=new NamespacedKey(plugin,"adventure_site_id");catalogKey=new NamespacedKey(plugin,"adventure_catalog_id");attemptKey=new NamespacedKey(plugin,"adventure_attempt_id");encounterKindKey=new NamespacedKey(plugin,"adventure_encounter_kind");menuSiteKey=new NamespacedKey(plugin,"quest_offer_site");menuCatalogKey=new NamespacedKey(plugin,"quest_offer_catalog");menuPageKey=new NamespacedKey(plugin,"quest_offer_page");menuFishingKey=new NamespacedKey(plugin,"quest_fishing_turn_in");}
public void install(){for(var world:plugin.getServer().getWorlds()){var chunks=world.getLoadedChunks();if(chunks!=null)for(var chunk:chunks)reconcile(chunk);}pulseTask=plugin.getServer().getScheduler().runTaskTimer(plugin,this::pulse,20,20);publishPendingReset();}
public boolean giver(Entity entity){var data=entity.getPersistentDataContainer();return data!=null&&GIVER.equals(data.get(typeKey,PersistentDataType.STRING));}
public boolean enemy(Entity entity){var data=entity.getPersistentDataContainer();return data!=null&&ENEMY.equals(data.get(typeKey,PersistentDataType.STRING));}
public boolean managed(Entity entity){var data=entity.getPersistentDataContainer();return enemy(entity)||data!=null&&PROJECTILE.equals(data.get(typeKey,PersistentDataType.STRING));}
public Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin> encounterOrigin(Entity entity){if(!enemy(entity))return Optional.empty();var data=entity.getPersistentDataContainer();UUID site=site(entity),catalog=parse(data.get(catalogKey,PersistentDataType.STRING)),source=parse(data.get(attemptKey,PersistentDataType.STRING));String encoded=data.get(encounterKindKey,PersistentDataType.STRING);if(site==null||catalog==null||source==null||encoded==null)return Optional.empty();games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind kind;try{kind=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.valueOf(encoded);}catch(IllegalArgumentException invalid){return Optional.empty();}if(kind!=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CHAMPION&&kind!=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CAMP)return Optional.empty();var active=attempts.get(site);if(active!=null&&(!active.catalog.equals(catalog)||!active.id.equals(source)))return Optional.empty();return Optional.of(new games.dmg.fantasystick.progression.ManagedEncounterOrigin(kind,source));}
public void reconcile(Chunk chunk){if(closed)return;var desired=new HashMap<UUID,AdventureQuestState.Giver>();for(var giver:directory.snapshot().givers())if(giver.world().equals(chunk.getWorld().getUID())&&section(giver.x())==chunk.getX()&&section(giver.z())==chunk.getZ())desired.put(giver.id(),giver);var retained=new HashSet<UUID>();for(var entity:chunk.getEntities()){if(managed(entity)){entity.remove();continue;}if(!giver(entity))continue;UUID id=id(entity,idKey);var value=id==null?null:desired.get(id);if(!(entity instanceof Villager villager)||value==null||!retained.add(id)){entity.remove();continue;}configure(villager,value);var expected=location(value);var current=villager.getLocation();if(current.distanceSquared(expected)>0.000001||Math.abs(current.getYaw()-expected.getYaw())>0.001||Math.abs(current.getPitch()-expected.getPitch())>0.001){relocating.add(villager);boolean moved;try{moved=villager.teleport(expected);}finally{relocating.remove(villager);}if(!moved){villager.remove();spawnGiver(value);}}}for(var value:desired.values())if(!retained.contains(value.id()))spawnGiver(value);}
public void removeGiverLoaded(UUID id){for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(giver(entity)&&id.equals(id(entity,idKey)))entity.remove();}
public void removeSiteLoaded(UUID id){var attempt=attempts.remove(id);if(attempt!=null)cleanup(attempt);readyAt.remove(id);refreshTrackingPlayers();}
public boolean hasActiveEncounter(){return !attempts.isEmpty()||!starting.isEmpty();}
public AdventureQuestState.Arena arena(AdventureQuestState.Site site){return site.arenaOrDefault(settings.rules().radius());}
public void registerFishing(FishingController fishing){if(closed||this.fishing!=null)throw new IllegalStateException("Fishing already attached or adventure controller closed");this.fishing=Objects.requireNonNull(fishing);}
public void registerResetParticipant(AdventureResetParticipant participant){if(closed)throw new IllegalStateException("Adventure controller closed");resetParticipants.add(Objects.requireNonNull(participant));}
public void registerDungeonRoomPresence(java.util.function.BiPredicate<UUID,Location> presence){if(closed)throw new IllegalStateException("Adventure controller closed");dungeonRoomPresence=Objects.requireNonNull(presence);}
public CompletionStage<Boolean> publishPendingReset(){var reset=directory.snapshot().reset();if(reset==null)return CompletableFuture.completedFuture(true);if(resetWork!=null)return resetWork;for(var attempt:new ArrayList<>(attempts.values()))cleanup(attempt);attempts.clear();starting.clear();readyAt.clear();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(managed(entity))entity.remove();refreshTrackingPlayers();var work=resetParticipants.stream().map(participant->{try{return participant.reset(reset).toCompletableFuture();}catch(RuntimeException failure){return CompletableFuture.<Void>failedFuture(failure);}}).toArray(CompletableFuture[]::new);resetWork=CompletableFuture.allOf(work).thenCompose(ignored->directory.completeReset(reset.operation())).whenComplete((ignored,failure)->resetWork=null);return resetWork;}
private void spawnGiver(AdventureQuestState.Giver value){var world=plugin.getServer().getWorld(value.world());if(world==null||!world.isChunkLoaded(section(value.x()),section(value.z())))return;world.spawn(location(value),Villager.class,villager->configure(villager,value));}
private void configure(Villager villager,AdventureQuestState.Giver value){var data=villager.getPersistentDataContainer();data.set(typeKey,PersistentDataType.STRING,GIVER);data.set(idKey,PersistentDataType.STRING,value.id().toString());villager.setAI(false);villager.setGravity(false);villager.setSilent(true);villager.setInvulnerable(true);villager.setCollidable(false);villager.setCanPickupItems(false);villager.setLeashHolder(null);villager.setRemoveWhenFarAway(false);villager.setAdult();villager.setAgeLock(true);villager.customName(net.kyori.adventure.text.Component.text(value.displayName()));villager.setCustomNameVisible(true);}
public void open(Player player){open(player,0);}
private void open(Player player,int requestedPage){var context=context(player);if(context==null){player.sendMessage("Quest Giver refused: actively play a living fantasy character first.");return;}var pending=directory.pendingClaim(context.character);if(pending.isPresent()){var record=sessions.currentRecord(context.active).orElse(null);if(record==null){player.sendMessage("Reward claim recovery requires the exact active character.");return;}attemptClaim(player,context.active,pending.orElseThrow(),record);return;}directory.ensureToday().whenComplete((catalog,failure)->schedule(()->{var currentContext=context(player);if(failure!=null||currentContext==null||!currentContext.active.equals(context.active)){player.sendMessage("Daily quests are unavailable.");return;}if(directory.snapshot().reset()!=null){publishPendingReset();player.sendMessage("Daily adventure reset is completing; try again.");return;}int pages=Math.max(1,(catalog.sites().size()+44)/45),page=Math.max(0,Math.min(requestedPage,pages-1));int count=Math.min(45,Math.max(0,catalog.sites().size()-page*45));int size=pages>1?54:Math.min(54,Math.max(18,((count+8)/9)*9+9));var inventory=plugin.getServer().createInventory(new MenuHolder(catalog.revision(),page),size,net.kyori.adventure.text.Component.text("Daily Adventure Quests"));int slot=0;for(UUID siteId:catalog.sites().stream().skip(page*45L).limit(45).toList()){var site=directory.findSite(siteId).orElse(null);var item=new ItemStack(Material.PAPER);var meta=item.getItemMeta();String name=site==null?"Unavailable site":site.displayName();meta.displayName(net.kyori.adventure.text.Component.text(name));var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog.revision(),siteId,context.character);boolean rewardsReady=unclaimed.xp().signum()>0||unclaimed.coins().signum()>0;String state=rewardsReady?"Rewards ready — click to claim":active!=null&&active.site().equals(siteId)?"Active — right-click to abandon":active!=null?"Another quest is active":directory.characterClaimed(catalog.revision(),siteId,context.character)?directory.rewardClaimed(catalog.revision(),siteId,context.character)?"Already claimed":"Completed with nothing owed":site==null||!directory.offerOpen(siteId)||!directory.available(site)?"Unavailable / cleared":"Available — click to accept";String kind=site==null?"Unavailable":switch(site.type()){case CAMP->"Monster camp";case CHAMPION->"Champion";case DUNGEON->"Dungeon";case FISHING->"Fishing";};var reward=site==null?new AdventureQuestDirectory.Reward(BigInteger.ZERO,BigInteger.ZERO):directory.reward(site);var rewardXp=rewardsReady?unclaimed.xp():active!=null&&active.site().equals(siteId)?active.rewardXp():reward.xp();var rewardCoins=rewardsReady?unclaimed.coins():active!=null&&active.site().equals(siteId)?active.rewardCoins():reward.coins();String rewardLabel=rewardsReady?"Banked reward: ":site!=null&&site.type()==AdventureQuestState.SiteType.DUNGEON?"Completion trophy pool: ":"Reward: ";var lore=new ArrayList<net.kyori.adventure.text.Component>();lore.add(net.kyori.adventure.text.Component.text(kind));var fishingTerms=active!=null&&active.site().equals(siteId)&&active.fishing()!=null?active.fishing():site==null||site.fishing()==null?null:site.fishing().terms();if(fishingTerms!=null){lore.add(net.kyori.adventure.text.Component.text("Item: "+fishingTerms.itemName()+" × "+fishingTerms.required()));lore.add(net.kyori.adventure.text.Component.text("Catch chance: "+fishingTerms.chancePercent().toPlainString()+"%"));if(active!=null&&active.site().equals(siteId)&&fishing!=null)lore.add(net.kyori.adventure.text.Component.text("Held: "+fishing.quantity(context.character,fishingTerms.itemId())+" / "+fishingTerms.required()));}lore.add(net.kyori.adventure.text.Component.text(rewardLabel+rewardXp+" XP, "+rewardCoins+" coins"));if(site!=null&&site.type()==AdventureQuestState.SiteType.DUNGEON)lore.add(net.kyori.adventure.text.Component.text("Mob kills: additional variable XP, coins and native loot"));if(site!=null){lore.add(net.kyori.adventure.text.Component.text("Mode: "+site.participationMode().value()));if(active==null&&!rewardsReady&&!directory.characterClaimed(catalog.revision(),siteId,context.character)&&directory.offerOpen(siteId)&&directory.available(site))acceptanceLore(lore,site.participationMode(),context);}lore.add(net.kyori.adventure.text.Component.text("Expires: "+catalog.expiresAt()));lore.add(net.kyori.adventure.text.Component.text(state));meta.lore(lore);meta.getPersistentDataContainer().set(menuSiteKey,PersistentDataType.STRING,siteId.toString());meta.getPersistentDataContainer().set(menuCatalogKey,PersistentDataType.STRING,catalog.revision().toString());item.setItemMeta(meta);inventory.setItem(slot++,item);}if(pages>1){if(page>0)inventory.setItem(45,pageItem(page-1,"Previous page"));if(page+1<pages)inventory.setItem(53,pageItem(page+1,"Next page"));}if(fishing!=null){var turnIn=new ItemStack(Material.ENDER_CHEST);var turLine truncated
private ItemStack pageItem(int page,String name){var item=new ItemStack(Material.ARROW);var meta=item.getItemMeta();meta.displayName(net.kyori.adventure.text.Component.text(name));meta.getPersistentDataContainer().set(menuPageKey,PersistentDataType.INTEGER,page);item.setItemMeta(meta);return item;}
private static void acceptanceLore(List<net.kyori.adventure.text.Component> lore,AdventureQuestState.ParticipationMode mode,Context context){if(mode!=AdventureQuestState.ParticipationMode.PARTY_REQUIRED)lore.add(net.kyori.adventure.text.Component.text("Left click: Accept individually"));if(mode!=AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY){if(context.partyId!=null&&context.leader)lore.add(net.kyori.adventure.text.Component.text("Right click: Accept for party"));else lore.add(net.kyori.adventure.text.Component.text("Party acceptance requires the active leader"));}}
private record Context(EquipmentSessions.Active active,UUID character,UUID partyId,boolean leader,Set<UUID> members){}
private Context context(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return null;var record=sessions.currentRecord(active).orElse(null);if(record==null||record.dead()||player.isDead())return null;var party=parties.partyOf(player.getUniqueId()).orElse(null);if(party==null)return new Context(active,record.id(),null,true,Set.of(record.id()));return new Context(active,record.id(),party.id(),party.leaderCharacter().equals(record.id()),Set.copyOf(party.characters()));}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void interact(PlayerInteractEntityEvent event){if(!giver(event.getRightClicked()))return;event.setCancelled(true);if(event.getHand()!=EquipmentSlot.HAND)return;UUID id=id(event.getRightClicked(),idKey);if(id!=null&&directory.findGiver(id).isPresent())open(event.getPlayer());}
@EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof MenuHolder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null)return;var data=event.getCurrentItem().getPersistentDataContainer();Integer page=data.get(menuPageKey,PersistentDataType.INTEGER);if(page!=null){open(player,page);return;}if(data.has(menuFishingKey,PersistentDataType.INTEGER)&&fishing!=null){fishing.open(player);return;}UUID site=parse(data.get(menuSiteKey,PersistentDataType.STRING)),catalog=parse(data.get(menuCatalogKey,PersistentDataType.STRING));if(site==null||catalog==null||!holder.catalog.equals(catalog))return;var context=context(player);if(context==null){player.sendMessage("Quest action refused: stale fantasy session.");return;}var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog,site,context.character);if(unclaimed.xp().signum()>0||unclaimed.coins().signum()>0){claim(player,context,catalog,site);return;}if(active!=null){if(!active.site().equals(site)){player.sendMessage("Quest action refused: another quest is active.");return;}if(!event.isRightClick()){player.sendMessage("This quest is active; right-click its Paper to abandon it.");return;}directory.abandon(active.id(),active.unit()).whenComplete((changed,failure)->schedule(()->{refreshTracking(player);player.sendMessage(failure==null&&changed?"Adventure quest abandoned.":"Quest abandonment failed; retained state is unchanged.");open(player);}));return;}if(directory.characterClaimed(catalog,site,context.character)){player.sendMessage(directory.rewardClaimed(catalog,site,context.character)?"Adventure rewards were already claimed.":"Adventure complete with no reward owed.");return;}if(!directory.offerOpen(site)||directory.findSite(site).filter(directory::available).isEmpty()){player.sendMessage("Quest action refused: this offer is unavailable.");return;}var offered=directory.findSite(site).orElseThrow();var type=event.isRightClick()?AdventureQuestState.UnitType.PARTY:AdventureQuestState.UnitType.SOLO;if(!offered.participationMode().permits(type)){player.sendMessage("Quest acceptance refused: this site is "+offered.participationMode().value()+".");return;}if(type==AdventureQuestState.UnitType.PARTY&&(context.partyId==null||!context.leader)){player.sendMessage("Only the active party leader may accept a party quest.");return;}var unit=type==AdventureQuestState.UnitType.SOLO?context.character:context.partyId;var members=type==AdventureQuestState.UnitType.SOLO?Set.of(context.character):context.members;directory.accept(catalog,site,type,unit,members,offered.participationMode()).whenComplete((result,failure)->schedule(()->{if(failure!=null){player.sendMessage("Quest acceptance failed; retained state is unchanged.");return;}if(result.status()!=AdventureQuestDirectory.Acceptance.APPLIED){player.sendMessage("Quest acceptance refused: "+result.status().name().toLowerCase(Locale.ROOT)+".");return;}var quest=result.quest().orElseThrow();var current=context(player);boolean same=current!=null&&current.active.equals(context.active)&&current.character.equals(context.character)&&(type==AdventureQuestState.UnitType.SOLO||current.leader&&Objects.equals(current.partyId,context.partyId)&&current.members.equals(context.members));if(!same){directory.abandon(quest.id(),quest.unit()).whenComplete((ignored,rollbackFailure)->schedule(()->player.sendMessage(rollbackFailure==null?"Quest acceptance refused: the character or party changed.":"Quest acceptance is pending safe cancellation; retry after recovery.")));return;}var location=directory.findSite(site).orElseThrow();refreshTracking(player);player.sendMessage("Accepted "+location.displayName()+"; expires "+quest.expiresAt()+". Follow the Quest Compass.");}));}
private void claim(Player player,Context expected,UUID catalog,UUID site){directory.beginClaim(catalog,site,expected.character).whenComplete((operation,failure)->schedule(()->{if(failure!=null||operation.isEmpty()){player.sendMessage("Reward claim failed; retained rewards are unchanged.");return;}var current=context(player);if(current==null||!current.active.equals(expected.active)||!current.character.equals(operation.orElseThrow().character())){player.sendMessage("Reward claim paused: actively play the exact living character and try again.");return;}var record=sessions.currentRecord(current.active).orElse(null);if(record==null){player.sendMessage("Reward claim paused: the current character changed.");return;}attemptClaim(player,current.active,operation.orElseThrow(),record);}));}
private void attemptClaim(Player player,EquipmentSessions.Active active,AdventureQuestState.RewardClaim operation,games.dmg.fantasystick.persistence.CharacterRecord record){characters.awardCombatReward(record.id(),record.revision(),operation.id(),operation.xp(),operation.coins(),progression,games.dmg.fantasystick.progression.CombatRewardRules.defaults(),null).whenComplete((award,failure)->{if(failure!=null){schedule(()->player.sendMessage("Reward claim failed; retained rewards are unchanged."));return;}if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){attemptClaim(player,active,operation,award.record().orElseThrow());return;}if(award.record().isEmpty()){schedule(()->player.sendMessage("Reward claim failed; retained rewards are unchanged."));return;}var saved=award.record().orElseThrow();directory.finishClaim(operation.id()).whenComplete((finished,finishFailure)->schedule(()->{if(finishFailure!=null||!finished){player.sendMessage("Reward claim is pending recovery; retry at a Quest Giver.");return;}var holder=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE&&holder!=null)sessions.acknowledgeProgress(holder,saved.revision().subtract(BigInteger.ONE),saved);var currentPlayer=holder==null?null:plugin.getServer().getPlayer(holder.player());var current=currentPlayer==null?null:context(currentPlayer);if(current!=null&&current.character.equals(operation.character())){currentPlayer.sendMessage("Claimed "+operation.xp()+" fantasy XP and "+operation.coins()+" character coins.");open(currentPlayer);}}));});}
public boolean trackingAvailable(Player player){var context=context(player);return context!=null&&directory.activeFor(context.character,context.partyId).filter(quest->quest.snapshot().contains(context.character)).isPresent();}
public void tracking(Player player,Boolean enabled){var context=context(player);if(context==null){player.sendMessage("Quest tracking refused: actively play a living fantasy character first.");return;}var quest=directory.activeFor(context.character,context.partyId).filter(value->value.snapshot().contains(context.character)).orElse(null);if(quest==null){player.sendMessage("Quest tracking refused: this character has no eligible active quest.");refreshTracking(player);return;}if(enabled==null){player.sendMessage("Quest tracking is "+(directory.trackingEnabled(quest.id(),context.character)?"on":"off")+" for the current adventure quest.");return;}directory.setTracking(quest.id(),context.character,enabled).whenComplete((status,failure)->schedule(()->{if(failure!=null||status==AdventureQuestDirectory.TrackingStatus.INELIGIBLE){player.sendMessage("Quest tracking change failed; retained state is unchanged.");return;}refreshTracking(player);player.sendMessage("Quest tracking is now "+(enabled?"on.":"off."));}));}
public void inspect(Player player){var context=context(player);if(context==null){player.sendMessage("No active fantasy character.");return;}var quest=directory.activeFor(context.character,context.partyId).orElse(null);if(quest==null){player.sendMessage("No active adventure quest.");return;}var site=directory.findSite(quest.site()).orElse(null);if(site==null){player.sendMessage("Active quest site is unavailable.");return;}player.sendMessage(site.displayName()+"; state="+(attempts.containsKey(site.id())?"active":"waiting")+"; expires "+quest.expiresAt()+"; Quest Compass guidance="+(directory.trackingEnabled(quest.id(),context.character)?"on":"off")+".");}
private void pulse(){if(closed)return;directory.ensureToday().whenComplete((catalog,failure)->{if(failure==null)schedule(()->{if(directory.snapshot().reset()!=null)publishPendingReset();});});if(directory.snapshot().reset()!=null){publishPendingReset();return;}directory.expireParties(parties::exists);refreshTrackingPlayers(true);showArenaBoundaries();for(var attempt:new ArrayList<>(attempts.values())){var catalog=directory.snapshot().catalog();if(catalog==null||!catalog.revision().equals(attempt.catalog)||!clock.instant().isBefore(catalog.expiresAt())){cleanup(attempt);attempts.remove(attempt.site);readyAt.remove(attempt.site);continue;}contain(attempt);syncBossBar(attempt);boolean present=eligibleInside(attempt.site,attempt.arena).findAny().isPresent();if(present)attempt.absentSince=null;else if(attempt.absentSince==null)attempt.absentSince=clock.instant();else if(!clock.instant().isBefore(attempt.absentSince.plusSeconds(attempt.resetSeconds))){cleanup(attempt);attempts.remove(attempt.site);readyAt.put(attempt.site,clock.instant());}}for(var quest:directory.active()){var type=directory.findSite(quest.site()).map(AdventureQuestState.Site::type).orElse(null);if(type==AdventureQuestState.SiteType.DUNGEON||type==AdventureQuestState.SiteType.FISHING)continue;if(attempts.containsKey(quest.site())||starting.contains(quest.site()))continue;var ready=readyAt.get(quest.site());if(ready!=null&&clock.instant().isBefore(ready))continue;if(eligibleInside(quest.site()).findAny().isPresent())start(quest.site(),quest.catalog());}}
private void 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});}
private void contain(Attempt attempt){var site=directory.findSite(attempt.site).orElse(null);if(site==null)return;var world=plugin.getServer().getWorld(site.world());if(world==null)return;for(var entity:safe(world.getEntities()))if(enemy(entity)&&attempt.site.equals(site(entity))&&!inside(entity.getLocation(),site,attempt.arena)){relocating.add(entity);try{entity.teleport(new Location(world,site.x(),site.y(),site.z()));if(entity instanceof LivingEntity living)living.setVelocity(new org.bukkit.util.Vector());}finally{relocating.remove(entity);}}}
private java.util.stream.Stream<Player> eligibleInside(UUID site){var value=directory.findSite(site).orElse(null);return value==null?java.util.stream.Stream.empty():eligibleInside(site,arena(value));}
private java.util.stream.Stream<Player> eligibleInside(UUID site,AdventureQuestState.Arena arena){var value=directory.findSite(site).orElse(null);if(value==null)return java.util.stream.Stream.empty();return plugin.getServer().getOnlinePlayers().stream().map(Player.class::cast).filter(player->eligible(player,site)!=null).filter(player->inside(player.getLocation(),value,arena));}
private AdventureQuestState.Quest eligible(Player player,UUID site){var context=context(player);if(context==null)return null;var quest=directory.activeFor(context.character,context.partyId).orElse(null);return quest!=null&&quest.site().equals(site)&&quest.snapshot().contains(context.character)?quest:null;}
private void start(UUID site,UUID catalog){var value=directory.findSite(site).orElse(null);if(value==null||value.type()==AdventureQuestState.SiteType.DUNGEON||value.type()==AdventureQuestState.SiteType.FISHING)return;if(value.type()==AdventureQuestState.SiteType.CHAMPION){startChampion(value,catalog);return;}starting.add(site);directory.roster(site).whenComplete((roster,failure)->schedule(()->{starting.remove(site);if(failure!=null||closed||attempts.containsKey(site))return;var current=directory.findSite(site).orElse(null);if(current==null)return;var world=plugin.getServer().getWorld(current.world());if(world==null||!world.isChunkLoaded(section(current.x()),section(current.z())))return;var rules=settings.rules();var attempt=new Attempt(site,catalog,current.type(),arena(current),rules.resetSeconds());attempts.put(site,attempt);for(int index=0;index<roster.size();index++)spawnEnemy(world,current,roster.get(index),attempt,index);}));}
private void startChampion(AdventureQuestState.Site site,UUID catalog){if(closed||attempts.containsKey(site.id())||starting.contains(site.id()))return;var world=plugin.getServer().getWorld(site.world());if(world==null||!world.isChunkLoaded(section(site.x()),section(site.z())))return;starting.add(site.id());try{var rules=settings.rules();var attempt=new Attempt(site.id(),catalog,site.type(),arena(site),rules.resetSeconds());attempt.bossBar=plugin.getServer().createBossBar("Daybreak Champion",BarColor.RED,BarStyle.SOLID);attempts.put(site.id(),attempt);world.spawn(new Location(world,site.x(),site.y(),site.z()),Vindicator.class,champion->{configureEnemy(champion,attempt);var maximum=Objects.requireNonNull(champion.getAttribute(Attribute.MAX_HEALTH));maximum.setBaseValue(rules.championHealth());champion.setHealth(rules.championHealth());Objects.requireNonNull(champion.getAttribute(Attribute.ATTACK_DAMAGE)).setBaseValue(rules.championDamage());champion.customName(net.kyori.adventure.text.Component.text("Daybreak Champion"));champion.setCustomNameVisible(true);attempt.champion=champion;});syncBossBar(attempt);}catch(RuntimeException failure){var attempt=attempts.remove(site.id());if(attempt!=null)cleanup(attempt);plugin.getLogger().log(java.util.logging.Level.WARNING,"Champion attempt could not start at site "+site.id(),failure);}finally{starting.remove(site.id());}}
private void spawnEnemy(World world,AdventureQuestState.Site site,org.bukkit.entity.EntityType type,Attempt attempt,int index){var location=new Location(world,site.x()+(index%3)-1,site.y(),site.z()+(index/3));switch(type){case HUSK->world.spawn(location,Husk.class,mob->configureEnemy(mob,attempt));case PILLAGER->world.spawn(location,Pillager.class,mob->configureEnemy(mob,attempt));case VINDICATOR->world.spawn(location,Vindicator.class,mob->configureEnemy(mob,attempt));case WITCH->world.spawn(location,Witch.class,mob->configureEnemy(mob,attempt));default->throw new IllegalArgumentException("Unsupported camp entity");}}
private void configureEnemy(Mob mob,Attempt attempt){mob.getPersistentDataContainer().set(typeKey,PersistentDataType.STRING,ENEMY);mob.getPersistentDataContainer().set(siteKey,PersistentDataType.STRING,attempt.site.toString());mob.getPersistentDataContainer().set(catalogKey,PersistentDataType.STRING,attempt.catalog.toString());mob.getPersistentDataContainer().set(attemptKey,PersistentDataType.STRING,attempt.id.toString());var kind=attempt.type==AdventureQuestState.SiteType.CHAMPION?games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CHAMPION:games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CAMP;mob.getPersistentDataContainer().set(encounterKindKey,PersistentDataType.STRING,kind.name());mob.setCanPickupItems(false);mob.setRemoveWhenFarAway(false);mob.setPersistent(true);mob.setFireTicks(0);if(mob instanceof Husk husk){husk.setShouldBurnInDay(false);husk.setCanBreakDoors(false);}if(mob instanceof Raider raider){raider.setPatrolLeader(false);raider.setCanJoinRaid(false);}attempt.entities.add(mob.getUniqueId());}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void damage(EntityDamageEvent event){if(enemy(event.getEntity())){if(!(event instanceof EntityDamageByEntityEvent by)||source(by)==null||eligible(source(by),site(event.getEntity()))==null)event.setCancelled(true);return;}if(event instanceof EntityDamageByEntityEvent by&&managed(by.getDamager())){UUID site=site(by.getDamager());if(!(event.getEntity() instanceof Player player)||site==null||eligible(player,site)==null)event.setCancelled(true);}}
@EventHandler(priority=EventPriority.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,attempt);} }
private List<UUID> recipients(AdventureQuestState.Quest quest,UUID site){var value=directory.findSite(site).orElse(null);return value==null?List.of():recipients(quest,site,arena(value));}
private List<UUID> recipients(AdventureQuestState.Quest quest,UUID site,AdventureQuestState.Arena arena){var value=directory.findSite(site).orElse(null);if(value==null)return List.of();var result=new ArrayList<UUID>();for(UUID character:quest.snapshot()){if(directory.characterClaimed(quest.catalog(),quest.site(),character))continue;var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);if(active==null)continue;var player=plugin.getServer().getPlayer(active.player());if(player==null||!inside(player.getLocation(),value,arena))continue;if(quest.unitType()==AdventureQuestState.UnitType.PARTY){var party=parties.partyOf(player.getUniqueId()).orElse(null);if(party==null||!party.id().equals(quest.unit())||!party.characters().contains(character))continue;}result.add(character);}return result.stream().distinct().sorted().toList();}
private void award(AdventureQuestState.Quest quest,List<UUID> recipients,Attempt attempt){var unit=new RewardAllocator.Unit<UUID>(quest.unit(),recipients);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);if((attempt.type==AdventureQuestState.SiteType.CHAMPION||attempt.type==AdventureQuestState.SiteType.CAMP)&&fishing!=null){var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(UUID character:recipients)rewards.put(character,new FishingState.Reward(xp.get(character),coins.get(character)));UUID carrier=quest.unitType()==AdventureQuestState.UnitType.SOLO?quest.unit():recipients.stream().map(parties::partyOfCharacter).flatMap(Optional::stream).filter(value->value.id().equals(quest.unit())).map(PartyRoster.Party::leaderCharacter).findFirst().orElse(null);var site=directory.findSite(quest.site()).orElse(null);if(carrier==null||site==null){plugin.getLogger().warning("Champion trophy carrier unavailable for quest "+quest.id());return;}var trophyType=attempt.type==AdventureQuestState.SiteType.CHAMPION?FishingState.TrophyType.CHAMPION:FishingState.TrophyType.CAMP;UUID trophy=UUID.nameUUIDFromBytes((trophyType.name().toLowerCase(java.util.Locale.ROOT)+":"+quest.id()).getBytes(java.nio.charset.StandardCharsets.UTF_8));String trophyName=trophyType==FishingState.TrophyType.CHAMPION?"Daybreak Champion Head":"Spoils of "+site.displayName();var plan=new FishingState.Trophy(trophy,trophyType,carrier,quest.catalog(),quest.site(),quest.id(),attempt.id,trophyName,site.displayName(),rewards);fishing.earnTrophy(plan).whenComplete((earned,failure)->schedule(()->{if(failure!=null||!earned){plugin.getLogger().log(java.util.logging.Level.WARNING,"Champion trophy persistence failed for quest "+quest.id(),failure);return;}for(UUID character:recipients){var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);var player=active==null?null:plugin.getServer().getPlayer(active.player());if(player!=null)player.sendMessage("Adventure complete: its trophy is stored with the captured carrier.");}refreshTrackingPlayers();}));return;}var rewards=new LinkedHashMap<UUID,AdventureQuestDirectory.Reward>();for(UUID character:recipients)rewards.put(character,new AdventureQuestDirectory.Reward(xp.get(character),coins.get(character)));directory.bankAndComplete(quest.id(),quest.id(),rewards).whenComplete((banked,failure)->schedule(()->{if(failure!=null||!banked){plugin.getLogger().log(java.util.logging.Level.WARNING,"Adventure completion reward banking failed for quest "+quest.id(),failure);return;}for(UUID character:recipients){var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);var player=active==null?null:plugin.getServer().getPlayer(active.player());if(player!=null)player.sendMessage("Adventure complete: rewards are ready at any Quest Giver.");}refreshTrackingPlayers();}));}
@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());}}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void combust(EntityCombustEvent event){if(managed(event.getEntity())||giver(event.getEntity()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void transform(EntityTransformEvent event){if(managed(event.getEntity())||giver(event.getEntity()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void portal(EntityPortalEvent event){if(managed(event.getEntity())||giver(event.getEntity()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void pickup(EntityPickupItemEvent event){if(managed(event.getEntity())||giver(event.getEntity()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void block(EntityChangeBlockEvent event){if(managed(event.getEntity()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void breed(EntityBreedEvent event){if(giver(event.getMother())||giver(event.getFather()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void vehicle(org.bukkit.event.vehicle.VehicleEnterEvent event){if(giver(event.getEntered())||managed(event.getEntered()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void leash(PlayerLeashEntityEvent event){if(giver(event.getEntity())||managed(event.getEntity()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void teleport(EntityTeleportEvent event){if((giver(event.getEntity())||managed(event.getEntity()))&&!relocating.contains(event.getEntity()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.MONITOR)public void chunkLoad(org.bukkit.event.world.ChunkLoadEvent event){reconcile(event.getChunk());}
@EventHandler(priority=EventPriority.MONITOR)public void chunkUnload(org.bukkit.event.world.ChunkUnloadEvent event){for(var entity:event.getChunk().getEntities())if(enemy(entity)){UUID site=site(entity);var attempt=site==null?null:attempts.remove(site);if(attempt!=null){cleanup(attempt);readyAt.put(site,clock.instant());}}}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void giverDamage(EntityDamageEvent event){if(giver(event.getEntity()))event.setCancelled(true);}
private Player source(EntityDamageByEntityEvent event){if(event.getDamager() instanceof Player player)return player;if(event.getDamager() instanceof org.bukkit.entity.Projectile projectile&&projectile.getShooter() instanceof Player player)return player;return null;}
private UUID site(Entity entity){return parse(entity.getPersistentDataContainer().get(siteKey,PersistentDataType.STRING));}
private void syncBossBar(Attempt attempt){if(attempt.bossBar==null||attempt.champion==null)return;var viewers=eligibleInside(attempt.site,attempt.arena).collect(java.util.stream.Collectors.toSet());for(var player:plugin.getServer().getOnlinePlayers())if(viewers.contains(player))attempt.bossBar.addPlayer(player);else attempt.bossBar.removePlayer(player);double maximum=Objects.requireNonNull(attempt.champion.getAttribute(Attribute.MAX_HEALTH)).getValue();attempt.bossBar.setProgress(Math.max(0,Math.min(1,attempt.champion.getHealth()/maximum)));}
private void cleanup(Attempt attempt){if(attempt.bossBar!=null)attempt.bossBar.removeAll();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(enemy(entity)&&attempt.site.equals(site(entity)))entity.remove();attempt.entities.clear();}
private void 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()||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());}
private static int section(double coordinate){return((int)Math.floor(coordinate))>>4;}
private static UUID parse(String value){try{return value==null?null:UUID.fromString(value);}catch(IllegalArgumentException invalid){return null;}}
private static UUID id(Entity entity,NamespacedKey key){return parse(entity.getPersistentDataContainer().get(key,PersistentDataType.STRING));}
private static <T>List<T> safe(Collection<T> values){return values==null?List.of():List.copyOf(values);}
@Override public void close(){for(var player:plugin.getServer().getOnlinePlayers()){sessions.active(player.getUniqueId()).filter(sessions::current).ifPresent(sessions::restoreBook);}closed=true;if(pulseTask!=null)pulseTask.cancel();for(var attempt:new ArrayList<>(attempts.values()))cleanup(attempt);attempts.clear();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(giver(entity)||enemy(entity))entity.remove();}
}
@@ -0,0 +1,102 @@
package games.dmg.fantasystick.adventure;
import games.dmg.fantasystick.persistence.AdventureQuestSettings;
import games.dmg.fantasystick.persistence.YamlAdventureQuestRepository;
import java.io.IOException;
import java.math.BigInteger;
import java.time.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.function.Supplier;
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, 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 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);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,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);});}
public CompletableFuture<Optional<AdventureQuestState.Giver>> removeGiver(UUID id){return mutate(state->{var found=state.givers().stream().filter(v->v.id().equals(id)).findFirst();if(found.isEmpty())return result(state,Optional.empty());return result(copy(state,state.sites(),state.givers().stream().filter(v->!v.id().equals(id)).toList(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),found);});}
public CompletableFuture<AdventureQuestState.Catalog> appendOffer(UUID site){return mutate(state->{if(state.reset()!=null||site(state,site).filter(v->v.type()==AdventureQuestState.SiteType.DUNGEON).isEmpty()||state.catalog()==null||!state.catalog().day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC))))throw new IllegalArgumentException("Dungeon offer unavailable");if(state.catalog().sites().contains(site)){if(!state.catalog().closed().contains(site))return result(state,state.catalog());var closed=new HashSet<>(state.catalog().closed());closed.remove(site);var reopened=new AdventureQuestState.Catalog(state.catalog().day(),state.catalog().revision(),state.catalog().createdAt(),state.catalog().sites(),closed);return result(copy(state,state.sites(),state.givers(),reopened,state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),reopened);}if(state.catalog().sites().size()>=256)throw new IllegalArgumentException("Adventure offer limit reached");var offers=new ArrayList<>(state.catalog().sites());offers.add(site);var catalog=new AdventureQuestState.Catalog(state.catalog().day(),state.catalog().revision(),state.catalog().createdAt(),offers,state.catalog().closed());return result(copy(state,state.sites(),state.givers(),catalog,state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),catalog);});}
public CompletableFuture<Boolean> closeOffer(UUID site){return mutate(state->{if(state.catalog()==null||!state.catalog().sites().contains(site)||state.catalog().closed().contains(site))return result(state,false);var closed=new HashSet<>(state.catalog().closed());closed.add(site);var catalog=new AdventureQuestState.Catalog(state.catalog().day(),state.catalog().revision(),state.catalog().createdAt(),state.catalog().sites(),closed);var quests=state.quests().stream().filter(value->!value.site().equals(site)).toList();return result(copy(state,state.sites(),state.givers(),catalog,quests,state.unitClaims(),state.characterClaims(),state.rosters()),true);});}
public CompletableFuture<AdventureQuestState.Catalog> ensureToday(){return mutate(state->{if(state.reset()!=null)return result(state,state.catalog());var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));if(state.catalog()!=null&&state.catalog().day().equals(today))return result(expire(state),state.catalog());var prior=state.catalog();var catalog=roll(state,today,prior==null?Set.of():Set.copyOf(prior.sites()));var reset=prior==null?null:new AdventureQuestState.Reset(UUID.randomUUID(),catalog.revision(),clock.instant());var next=copyReset(state,state.sites(),state.givers(),catalog,List.of(),Set.of(),Set.of(),Map.of(),reset);return result(next,catalog);});}
public CompletableFuture<RerollResult> rerollToday(){var current=snapshot.catalog();return rerollToday(current==null?null:current.revision());}
public CompletableFuture<RerollResult> rerollToday(UUID expectedRevision){return mutate(original->{var state=expire(original);var current=state.catalog();UUID actual=current==null?null:current.revision();if(state.reset()!=null||!Objects.equals(expectedRevision,actual))return result(state,new RerollResult(RerollStatus.STALE,Optional.ofNullable(current)));var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));boolean sameDay=current!=null&&current.day().equals(today);var catalog=roll(state,today,sameDay?Set.copyOf(current.sites()):Set.of());var reset=new AdventureQuestState.Reset(UUID.randomUUID(),catalog.revision(),clock.instant());var next=copyReset(state,state.sites(),state.givers(),catalog,List.of(),Set.of(),Set.of(),Map.of(),reset);return result(next,new RerollResult(RerollStatus.APPLIED,Optional.of(catalog)));});}
public CompletableFuture<Boolean> completeReset(UUID operation){return mutate(state->{if(state.reset()==null||!state.reset().operation().equals(operation))return result(state,false);return result(copyReset(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters(),null),true);});}
public CompletableFuture<AcceptResult> accept(UUID catalog,UUID site,AdventureQuestState.UnitType type,UUID unit,Set<UUID> members){var offered=findSite(site).orElse(null);return offered==null?CompletableFuture.completedFuture(new AcceptResult(Acceptance.UNAVAILABLE,Optional.empty())):accept(catalog,site,type,unit,members,offered.participationMode());}
public CompletableFuture<AcceptResult> accept(UUID catalog,UUID site,AdventureQuestState.UnitType type,UUID unit,Set<UUID> members,AdventureQuestState.ParticipationMode expectedMode){Objects.requireNonNull(expectedMode);var requestedMembers=Set.copyOf(members);return mutate(original->{var state=expire(original);var current=state.catalog();if(state.reset()!=null||current==null||!current.day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC)))||!current.revision().equals(catalog))return result(state,new AcceptResult(Acceptance.STALE,Optional.empty()));var offered=site(state,site).filter(this::available).orElse(null);if(!current.sites().contains(site)||current.closed().contains(site)||offered==null)return result(state,new AcceptResult(Acceptance.UNAVAILABLE,Optional.empty()));if(offered.participationMode()!=expectedMode)return result(state,new AcceptResult(Acceptance.STALE,Optional.empty()));if(!offered.participationMode().permits(type))return result(state,new AcceptResult(Acceptance.POLICY,Optional.empty()));boolean unitClaimed=state.unitClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.type()==type&&claim.unit().equals(unit));boolean characterClaimed=requestedMembers.stream().anyMatch(member->state.characterClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.character().equals(member)));if(unitClaimed||characterClaimed)return result(state,new AcceptResult(Acceptance.CLAIMED,Optional.empty()));boolean conflict=state.quests().stream().anyMatch(q->q.unit().equals(unit)||!Collections.disjoint(q.snapshot(),requestedMembers));if(conflict)return result(state,new AcceptResult(Acceptance.CONFLICT,Optional.empty()));var reward=reward(offered);var quest=new AdventureQuestState.Quest(UUID.randomUUID(),catalog,site,type,unit,requestedMembers,current.expiresAt(),reward.xp(),reward.coins(),offered.participationMode(),offered.fishing()==null?null:offered.fishing().terms());var quests=new ArrayList<>(state.quests());quests.add(quest);var tracking=new HashSet<>(state.tracking());for(UUID character:requestedMembers)tracking.add(new AdventureQuestState.Tracking(quest.id(),character,true));return result(copyTracking(state,state.sites(),state.givers(),current,List.copyOf(quests),state.unitClaims(),state.characterClaims(),state.rosters(),tracking),new AcceptResult(Acceptance.APPLIED,Optional.of(quest)));});}
public CompletableFuture<Integer> expireOfferQuests(UUID site){return mutate(state->{var quests=state.quests().stream().filter(value->!value.site().equals(site)).toList();int removed=state.quests().size()-quests.size();return removed==0?result(state,0):result(copy(state,state.sites(),state.givers(),state.catalog(),quests,state.unitClaims(),state.characterClaims(),state.rosters()),removed);});}
public CompletableFuture<Boolean> abandon(UUID quest,UUID unit){return mutate(state->{var found=state.quests().stream().filter(q->q.id().equals(quest)&&q.unit().equals(unit)).findFirst();if(found.isEmpty())return result(state,false);return result(copy(state,state.sites(),state.givers(),state.catalog(),state.quests().stream().filter(q->!q.id().equals(quest)).toList(),state.unitClaims(),state.characterClaims(),state.rosters()),true);});}
public CompletableFuture<List<EntityType>> roster(UUID site){return mutate(state->{state=expire(state);if(state.reset()!=null||state.catalog()==null||!state.catalog().day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC)))||!state.catalog().sites().contains(site)||find(state.sites(),site).filter(v->v.type()==AdventureQuestState.SiteType.CAMP).isEmpty())throw new IllegalArgumentException("Unavailable camp");var existing=state.rosters().get(site);if(existing!=null)return result(state,existing);var rolled=CampRoster.roll(settings.get().campEnemies(),random);var rosters=new HashMap<>(state.rosters());rosters.put(site,rolled);return result(copy(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),rosters),rolled);});}
public CompletableFuture<Boolean> complete(UUID questId,Set<UUID> recipients){return mutate(state->{var quest=state.quests().stream().filter(q->q.id().equals(questId)).findFirst().orElse(null);if(quest==null)return result(state,false);var units=new HashSet<>(state.unitClaims());units.add(new AdventureQuestState.UnitClaim(quest.catalog(),quest.site(),quest.unitType(),quest.unit()));var characters=new HashSet<>(state.characterClaims());for(UUID recipient:recipients)characters.add(new AdventureQuestState.CharacterClaim(quest.catalog(),quest.site(),recipient));return result(copy(state,state.sites(),state.givers(),state.catalog(),state.quests().stream().filter(q->!q.id().equals(questId)).toList(),units,characters,state.rosters()),true);});}
public List<AdventureQuestState.Quest> active(){return expireView(snapshot).quests();}
public boolean characterClaimed(UUID catalog,UUID site,UUID character){return snapshot.characterClaims().stream().anyMatch(claim->claim.catalog().equals(catalog)&&claim.site().equals(site)&&claim.character().equals(character));}
public boolean claimed(UUID catalog,UUID site,UUID character,UUID party){var state=snapshot;return state.characterClaims().stream().anyMatch(claim->claim.catalog().equals(catalog)&&claim.site().equals(site)&&claim.character().equals(character))||(party!=null&&state.unitClaims().stream().anyMatch(claim->claim.catalog().equals(catalog)&&claim.site().equals(site)&&claim.type()==AdventureQuestState.UnitType.PARTY&&claim.unit().equals(party)));}
public Optional<AdventureQuestState.Quest> activeFor(UUID character,UUID party){return active().stream().filter(q->q.unitType()==AdventureQuestState.UnitType.SOLO?q.unit().equals(character):party!=null&&q.unit().equals(party)&&q.snapshot().contains(character)).findFirst();}
public boolean trackingEnabled(UUID quest,UUID character){var active=findQuest(quest).filter(value->value.snapshot().contains(character));if(active.isEmpty())return false;return snapshot.tracking().stream().filter(value->value.quest().equals(quest)&&value.character().equals(character)).map(AdventureQuestState.Tracking::enabled).findFirst().orElse(true);}
public CompletableFuture<TrackingStatus> setTracking(UUID quest,UUID character,boolean enabled){return mutate(original->{var state=expire(original);var active=state.quests().stream().filter(value->value.id().equals(quest)&&value.snapshot().contains(character)).findFirst();if(active.isEmpty())return result(state,TrackingStatus.INELIGIBLE);var current=state.tracking().stream().filter(value->value.quest().equals(quest)&&value.character().equals(character)).findFirst();if(current.map(AdventureQuestState.Tracking::enabled).orElse(true)==enabled&&current.isPresent())return result(state,TrackingStatus.UNCHANGED);var tracking=new HashSet<>(state.tracking());tracking.removeIf(value->value.quest().equals(quest)&&value.character().equals(character));tracking.add(new AdventureQuestState.Tracking(quest,character,enabled));return result(copyTracking(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters(),tracking),TrackingStatus.APPLIED);});}
public CompletableFuture<Boolean> bank(UUID quest,UUID source,Map<UUID,Reward> rewards){return bank(quest,source,rewards,false);}
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);});}
private AdventureQuestState expire(AdventureQuestState state){var view=expireView(state);return view.equals(state)?state:view;}
private AdventureQuestState expireView(AdventureQuestState state){if(state.catalog()==null||clock.instant().isBefore(state.catalog().expiresAt()))return state;return new AdventureQuestState(state.sites(),state.givers(),state.catalog(),List.of(),state.unitClaims(),state.characterClaims(),state.rosters(),Set.of(),pendingRewards(state.rewards()),state.reset());}
private AdventureQuestState.Catalog roll(AdventureQuestState state,LocalDate day,Set<UUID> previous){var candidates=candidateSites(state);var alternatives=candidates.stream().map(AdventureQuestState.Site::id).filter(id->!previous.contains(id)).collect(java.util.stream.Collectors.toCollection(ArrayList::new));var old=candidates.stream().map(AdventureQuestState.Site::id).filter(previous::contains).collect(java.util.stream.Collectors.toCollection(ArrayList::new));shuffle(alternatives);shuffle(old);int count=Math.min(settings.get().dailySiteCount(),candidates.size());var selected=new ArrayList<UUID>();selected.addAll(alternatives.subList(0,Math.min(count,alternatives.size())));if(selected.size()<count)selected.addAll(old.subList(0,count-selected.size()));return new AdventureQuestState.Catalog(day,UUID.randomUUID(),clock.instant(),selected);}
private void shuffle(List<UUID> values){for(int i=values.size()-1;i>0;i--){int j=random.nextInt(i+1);var v=values.get(i);values.set(i,values.get(j));values.set(j,v);}}
private List<AdventureQuestState.Site> candidateSites(AdventureQuestState state){var values=new LinkedHashMap<UUID,AdventureQuestState.Site>();state.sites().stream().filter(value->value.type()!=AdventureQuestState.SiteType.DUNGEON).forEach(value->values.put(value.id(),value));dungeonSites.get().forEach(value->values.put(value.id(),value));return List.copyOf(values.values());}
private Optional<AdventureQuestState.Site> site(AdventureQuestState state,UUID id){var stored=find(state.sites(),id);return stored.isPresent()?stored:dungeonSites.get().stream().filter(value->value.id().equals(id)).findFirst();}
private static Optional<AdventureQuestState.Site> find(List<AdventureQuestState.Site> sites,UUID id){return sites.stream().filter(v->v.id().equals(id)).findFirst();}
private static AdventureQuestState copy(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){return new AdventureQuestState(sites,givers,catalog,quests,unitClaims,characterClaims,rosters,validTracking(original.tracking(),quests),original.rewards(),original.reset());}
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){return rewards;}
private static Set<AdventureQuestState.Tracking> validTracking(Set<AdventureQuestState.Tracking> tracking,List<AdventureQuestState.Quest> quests){var active=quests.stream().collect(java.util.stream.Collectors.toMap(AdventureQuestState.Quest::id,value->value));return tracking.stream().filter(value->{var quest=active.get(value.quest());return quest!=null&&quest.snapshot().contains(value.character());}).collect(java.util.stream.Collectors.toUnmodifiableSet());}
private static UUID rewardId(AdventureQuestState.Quest quest,UUID source,UUID character){return UUID.nameUUIDFromBytes((quest.catalog()+":"+quest.site()+":"+quest.id()+":"+source+":"+character).getBytes(java.nio.charset.StandardCharsets.UTF_8));}
private record Result<T>(AdventureQuestState state,T value){}
private static <T>Result<T> result(AdventureQuestState state,T value){return new Result<>(state,value);}
private <T>CompletableFuture<T> mutate(java.util.function.Function<AdventureQuestState,Result<T>> operation){var future=new CompletableFuture<T>();synchronized(this){if(closed){future.completeExceptionally(new IOException("Adventure directory closed"));return future;}pending++;}worker.execute(()->{try{var result=operation.apply(snapshot);if(result.state()!=snapshot){repository.save(result.state());snapshot=result.state();}future.complete(result.value());}catch(Throwable failure){future.completeExceptionally(failure);}finally{finished();}});return future;}
private 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,68 @@
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;
import org.bukkit.Server;
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);}
public ItemStack compass(ItemStack ordinaryBook,AdventureQuestState.Quest quest,AdventureQuestState.Site site,Location viewer){
Objects.requireNonNull(ordinaryBook);Objects.requireNonNull(quest);Objects.requireNonNull(site);Objects.requireNonNull(viewer);
var item=ordinaryBook.clone();
item.setType(Material.COMPASS);
var meta=item.getItemMeta();
meta.displayName(Component.text("Quest Compass"));
var lore=new java.util.ArrayList<Component>();
lore.add(Component.text(site.displayName()));
lore.add(Component.text(type(site.type())));
World target=server.getWorld(site.world());
lore.add(Component.text("World: "+worldName(target)));
if(target!=null&&viewer.getWorld()!=null&&viewer.getWorld().getUID().equals(site.world()))lore.add(Component.text("Distance: "+distance(viewer,site)+" blocks"));
lore.add(Component.text("Expires: "+quest.expiresAt()));
meta.lore(List.copyOf(lore));
meta.setMaxStackSize(1);
if(meta instanceof CompassMeta compass){
compass.setLodestoneTracked(false);
if(target!=null)compass.setLodestone(new Location(target,site.x(),site.y(),site.z()));
}
item.setItemMeta(meta);
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();
World target=server.getWorld(site.world());
var viewer=player.getLocation();
if(target==null)return Optional.of(Component.text("Travel to unavailable world"));
if(viewer.getWorld()==null||!viewer.getWorld().getUID().equals(site.world()))return Optional.of(Component.text("Travel to "+worldName(target)));
long vertical=Math.round(site.y()-viewer.getY());
String height=vertical>0?"↑ +"+vertical:vertical<0?"↓ "+vertical:"↔ 0";
return Optional.of(Component.text(site.displayName()+" — "+distance(viewer,site)+" blocks "+bearing(viewer,site)+" · "+height));
}
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";case FISHING->"Fishing site";};}
private static String worldName(World world){return world==null||world.getName()==null||world.getName().isBlank()?"unavailable world":world.getName();}
}
@@ -0,0 +1,103 @@
package games.dmg.fantasystick.adventure;
import java.math.BigInteger;
import java.time.Instant;
import java.time.LocalDate;
import java.util.*;
import org.bukkit.entity.EntityType;
/** Immutable durable authority for registered daily-adventure locations, offers, quests and claims. */
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, 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);
if (shape == ArenaShape.CIRCLE && (xBlocks < 4 || xBlocks > 64 || zBlocks != xBlocks))
throw new IllegalArgumentException("Invalid circular arena");
if (shape == ArenaShape.RECTANGLE && (xBlocks < 8 || xBlocks > 128 || zBlocks < 8 || zBlocks > 128))
throw new IllegalArgumentException("Invalid rectangular arena");
}
public static Arena circle(int radius){return new Arena(ArenaShape.CIRCLE,radius,radius);}
public static Arena rectangle(int xBlocks,int zBlocks){return new Arena(ArenaShape.RECTANGLE,xBlocks,zBlocks);}
public boolean contains(double xOffset,double zOffset){return shape==ArenaShape.CIRCLE?xOffset*xOffset+zOffset*zOffset<=xBlocks*(double)xBlocks:Math.abs(xOffset)<=xBlocks/2d&&Math.abs(zOffset)<=zBlocks/2d;}
public String display(){return shape==ArenaShape.CIRCLE?"circle radius="+xBlocks:"rectangle x="+xBlocks+" z="+zBlocks;}
}
public record Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z,Arena arena,ParticipationMode participationMode,FishingDefinition fishing) {
public 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) {
public Giver { Objects.requireNonNull(id);name=validName(name);Objects.requireNonNull(world);finite(x,y,z);if(!Float.isFinite(yaw)||!Float.isFinite(pitch))throw new IllegalArgumentException("Invalid facing"); }
public String displayName(){return name.isBlank()?"Quest Giver":name;}
}
public record Catalog(LocalDate day,UUID revision,Instant createdAt,List<UUID> sites,Set<UUID> closed) {
public Catalog(LocalDate day,UUID revision,Instant createdAt,List<UUID> sites){this(day,revision,createdAt,sites,Set.of());}
public Catalog { Objects.requireNonNull(day);Objects.requireNonNull(revision);Objects.requireNonNull(createdAt);sites=List.copyOf(sites);closed=Set.copyOf(closed);if(new HashSet<>(sites).size()!=sites.size())throw new IllegalArgumentException("Duplicate offer");if(!new HashSet<>(sites).containsAll(closed))throw new IllegalArgumentException("Unknown closed offer"); }
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,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);}
}
public record CharacterClaim(UUID catalog,UUID site,UUID character) {
public CharacterClaim {Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(character);}
}
public record Tracking(UUID quest,UUID character,boolean enabled) {
public Tracking {Objects.requireNonNull(quest);Objects.requireNonNull(character);}
}
public record RewardAllocation(UUID id,UUID catalog,UUID site,UUID quest,UUID source,UUID character,BigInteger xp,BigInteger coins) {
public RewardAllocation {Objects.requireNonNull(id);Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(quest);Objects.requireNonNull(source);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 reward allocation");}
}
public record RewardClaim(UUID id,UUID catalog,UUID site,UUID character,Set<UUID> allocations,BigInteger xp,BigInteger coins) {
public RewardClaim {Objects.requireNonNull(id);Objects.requireNonNull(catalog);Objects.requireNonNull(site);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 reward claim");}
}
public record RewardReceipt(UUID catalog,UUID site,UUID character) {public RewardReceipt {Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(character);}}
public record RewardLedger(List<RewardAllocation> allocations,List<RewardClaim> operations,Set<RewardReceipt> receipts) {
public RewardLedger {allocations=List.copyOf(allocations);operations=List.copyOf(operations);receipts=Set.copyOf(receipts);unique(allocations.stream().map(RewardAllocation::id).toList());unique(operations.stream().map(RewardClaim::id).toList());var allocated=allocations.stream().collect(java.util.stream.Collectors.toMap(RewardAllocation::id,value->value));var operationKeys=new HashSet<String>();for(var operation:operations){if(!operationKeys.add(operation.catalog()+":"+operation.site()+":"+operation.character()))throw new IllegalArgumentException("Duplicate offer reward operation");BigInteger xp=BigInteger.ZERO,coins=BigInteger.ZERO;for(UUID id:operation.allocations()){var value=allocated.get(id);if(value==null||!value.catalog().equals(operation.catalog())||!value.site().equals(operation.site())||!value.character().equals(operation.character()))throw new IllegalArgumentException("Invalid reward operation allocation");xp=xp.add(value.xp());coins=coins.add(value.coins());}if(!xp.equals(operation.xp())||!coins.equals(operation.coins()))throw new IllegalArgumentException("Invalid reward operation total");}}
public static RewardLedger empty(){return new RewardLedger(List.of(),List.of(),Set.of());}
}
public record Reset(UUID operation,UUID generation,Instant startedAt) {
public Reset {Objects.requireNonNull(operation);Objects.requireNonNull(generation);Objects.requireNonNull(startedAt);}
}
public AdventureQuestState(List<Site> sites,List<Giver> givers,Catalog catalog,List<Quest> quests,Set<UnitClaim> unitClaims,Set<CharacterClaim> characterClaims,Map<UUID,List<EntityType>> rosters){this(sites,givers,catalog,quests,unitClaims,characterClaims,rosters,Set.of(),RewardLedger.empty(),null);}
public AdventureQuestState(List<Site> sites,List<Giver> givers,Catalog catalog,List<Quest> quests,Set<UnitClaim> unitClaims,Set<CharacterClaim> characterClaims,Map<UUID,List<EntityType>> rosters,Reset reset){this(sites,givers,catalog,quests,unitClaims,characterClaims,rosters,Set.of(),RewardLedger.empty(),reset);}
public 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,Reset reset){this(sites,givers,catalog,quests,unitClaims,characterClaims,rosters,tracking,RewardLedger.empty(),reset);}
public AdventureQuestState {
sites=List.copyOf(sites);givers=List.copyOf(givers);quests=List.copyOf(quests);unitClaims=Set.copyOf(unitClaims);characterClaims=Set.copyOf(characterClaims);tracking=Set.copyOf(tracking);Objects.requireNonNull(rewards);
var copied=new LinkedHashMap<UUID,List<EntityType>>();rosters.forEach((key,value)->copied.put(key,List.copyOf(value)));rosters=Map.copyOf(copied);
unique(sites.stream().map(Site::id).toList());unique(givers.stream().map(Giver::id).toList());unique(quests.stream().map(Quest::id).toList());
var trackingKeys=new HashSet<String>();for(var value:tracking){var quest=quests.stream().filter(candidate->candidate.id().equals(value.quest())).findFirst().orElseThrow(()->new IllegalArgumentException("Unknown tracked quest"));if(!quest.snapshot().contains(value.character())||!trackingKeys.add(value.quest()+":"+value.character()))throw new IllegalArgumentException("Invalid quest tracking");}
}
public static AdventureQuestState empty(){return new AdventureQuestState(List.of(),List.of(),null,List.of(),Set.of(),Set.of(),Map.of(),Set.of(),RewardLedger.empty(),null);}
private static String validName(String name){Objects.requireNonNull(name);name=name.strip();if(name.codePointCount(0,name.length())>64||name.codePoints().anyMatch(c->{int t=Character.getType(c);return t==Character.CONTROL||t==Character.FORMAT||t==Character.SURROGATE;}))throw new IllegalArgumentException("Invalid name");return name;}
private static void finite(double... values){for(double value:values)if(!Double.isFinite(value))throw new IllegalArgumentException("Invalid position");}
private static void unique(List<UUID> values){if(new HashSet<>(values).size()!=values.size())throw new IllegalArgumentException("Duplicate identity");}
}
@@ -0,0 +1,9 @@
package games.dmg.fantasystick.adventure;
import java.util.concurrent.CompletionStage;
/** Durable extension point for state that must join the authoritative adventure-day reset. */
@FunctionalInterface
public interface AdventureResetParticipant {
CompletionStage<Void> reset(AdventureQuestState.Reset reset);
}
@@ -0,0 +1,46 @@
package games.dmg.fantasystick.adventure;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.IntSupplier;
import java.util.function.IntUnaryOperator;
import org.bukkit.entity.EntityType;
/** Fixed weighted monster-camp roster generation with reviewed guarantees and caps. */
public final class CampRoster {
private CampRoster() { }
public static List<EntityType> roll(int count, java.util.random.RandomGenerator random) {
Objects.requireNonNull(random);
return roll(count, () -> random.nextInt(100), random::nextInt);
}
static List<EntityType> roll(int count, IntSupplier percent, IntUnaryOperator shuffleIndex) {
Objects.requireNonNull(percent); Objects.requireNonNull(shuffleIndex);
if (count < 4 || count > 64) throw new IllegalArgumentException("Camp enemy count must be 4 through 64");
int guaranteed = (count + 1) / 2;
int pillagerCap = count / 2;
int vindicatorCap = Math.max(1, count / 4);
int pillagers = 0, vindicators = 0, witches = 0;
var result = new ArrayList<EntityType>(count);
for (int index = 0; index < guaranteed; index++) result.add(EntityType.HUSK);
while (result.size() < count) {
int roll = percent.getAsInt();
if (roll < 0 || roll >= 100) throw new IllegalArgumentException("Camp roll must be 0 through 99");
EntityType selected;
if (roll < 40) selected = EntityType.HUSK;
else if (roll < 75 && pillagers < pillagerCap) { selected = EntityType.PILLAGER; pillagers++; }
else if (roll < 95 && roll >= 75 && vindicators < vindicatorCap) { selected = EntityType.VINDICATOR; vindicators++; }
else if (roll >= 95 && witches < 1) { selected = EntityType.WITCH; witches++; }
else selected = EntityType.HUSK;
result.add(selected);
}
for (int index = result.size() - 1; index > 0; index--) {
int other = shuffleIndex.applyAsInt(index + 1);
if (other < 0 || other > index) throw new IllegalArgumentException("Invalid shuffle index");
var value = result.get(index); result.set(index, result.get(other)); result.set(other, value);
}
return List.copyOf(result);
}
}
@@ -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,10 +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");
@@ -42,6 +49,10 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
private static final java.util.regex.Pattern STAT_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):(STR|DEX|CON|INT|WIS|CHR)\"");
private static final java.util.regex.Pattern ABILITY_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([a-z][a-z0-9_]{0,63})\"");
private record View(String nonce, ControlInput input, int previous, int next) { }
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) { }
@@ -87,11 +98,128 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
this.abilityEngine = abilityEngine;
this.passives = Objects.requireNonNull(passives);
this.passiveEngine = passiveEngine;
this.server = Objects.requireNonNull(server);
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) {
Objects.requireNonNull(administrator); Objects.requireNonNull(target);
if (closed) return;
if (!administrator.isOnline() || !administrator.hasPermission("fantasystick.admin")) {
if (administrator.isOnline()) administrator.sendMessage("Character view refused: administrator permission is required.");
return;
}
UUID targetId = target.getUniqueId();
var active = sessions.active(targetId).filter(sessions::current).orElse(null);
if (active == null) {
administrator.sendMessage("Character view refused: the target has no current active character.");
return;
}
loadReadOnly(administrator, target, active, 0);
}
private void loadReadOnly(org.bukkit.entity.Player administrator, org.bukkit.entity.Player target,
games.dmg.fantasystick.session.EquipmentSessions.Active active, int offset) {
if (closed) return;
UUID administratorId = administrator.getUniqueId();
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;
try {
nextTick.accept(() -> {
if (closed || !adminPending.remove(administratorId, ticket)) return;
var currentTarget = server.getPlayer(targetId);
var current = currentTarget == null ? null : sessions.active(targetId)
.filter(sessions::current).orElse(null);
if (!administrator.isOnline() || !administrator.hasPermission("fantasystick.admin")) {
if (administrator.isOnline()) administrator.sendMessage("Character view refused: administrator permission changed.");
return;
}
if (currentTarget != target || !target.isOnline() || !active.equals(current)) {
administrator.sendMessage("Character view refused: the target character session changed.");
return;
}
if (failure != null) {
report(new Failure(administratorId, active.character(), failure));
administrator.sendMessage("Character view could not be loaded; no state was changed.");
return;
}
if (record.isEmpty() || record.orElseThrow().dead()) {
administrator.sendMessage("Character view refused: the target character is unavailable.");
return;
}
try { administrator.openBook(renderReadOnly(record.orElseThrow(), administrator,
currentTarget, active, offset)); }
catch (RuntimeException error) {
adminViews.remove(administratorId);
report(new Failure(administratorId, active.character(), error));
administrator.sendMessage("Character view could not be opened; no state was changed.");
}
});
} catch (RuntimeException rejected) {
adminPending.remove(administratorId, ticket);
report(new Failure(administratorId, active.character(), rejected));
}
});
}
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;
@@ -128,21 +256,44 @@ 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, 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, 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,
boolean savedState) {
boolean interactive = input != null;
var item = new ItemStack(Material.WRITTEN_BOOK);
var meta = (BookMeta) item.getItemMeta();
meta.setTitle("Character");
meta.setAuthor("Fantasy Stick");
var statView = new StatView(UUID.randomUUID().toString(), input, record.revision());
var allOverview = overviewPages(record, input, statView);
var statView = interactive ? new StatView(UUID.randomUUID().toString(), input, record.revision()) : null;
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 (record.unspentPoints().signum() > 0 && start == 0) statViews.put(input.session().player(), statView);
if (interactive && record.unspentPoints().signum() > 0 && start == 0)
statViews.put(input.session().player(), statView);
var definitions = abilities.definitions().stream()
.sorted(java.util.Comparator.comparing(games.dmg.fantasystick.ability.AbilityDefinition::name)
.sorted(java.util.Comparator.comparing(games.dmg.fantasystick.ability.AbilityDefinition::category)
.thenComparing(games.dmg.fantasystick.ability.AbilityDefinition::name)
.thenComparing(games.dmg.fantasystick.ability.AbilityDefinition::id))
.toList();
var passiveDefinitions = passives.definitions().stream()
@@ -150,70 +301,128 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
.thenComparing(games.dmg.fantasystick.passive.PassiveDefinition::id))
.toList();
if ((!definitions.isEmpty() || !passiveDefinitions.isEmpty()) && end == allOverview.size()) {
var abilityView = new AbilityView(UUID.randomUUID().toString(), input, record.revision(),
var abilityView = interactive ? new AbilityView(UUID.randomUUID().toString(), input, record.revision(),
definitions.stream().map(games.dmg.fantasystick.ability.AbilityDefinition::id)
.collect(java.util.stream.Collectors.toUnmodifiableSet()));
if (!definitions.isEmpty()) abilityViews.put(input.session().player(), abilityView);
var passiveView = new PassiveView(UUID.randomUUID().toString(), input, record.revision(),
.collect(java.util.stream.Collectors.toUnmodifiableSet())) : null;
if (interactive && !definitions.isEmpty()) abilityViews.put(input.session().player(), abilityView);
var passiveView = interactive ? new PassiveView(UUID.randomUUID().toString(), input, record.revision(),
passiveDefinitions.stream().map(games.dmg.fantasystick.passive.PassiveDefinition::id)
.collect(java.util.stream.Collectors.toUnmodifiableSet()));
if (!passiveDefinitions.isEmpty()) passiveViews.put(input.session().player(), passiveView);
var details = definitions.stream().map(definition -> abilityPages(record, definition, abilityView)).toList();
.collect(java.util.stream.Collectors.toUnmodifiableSet())) : null;
if (interactive && !passiveDefinitions.isEmpty()) passiveViews.put(input.session().player(), passiveView);
var passiveNames = passiveDefinitions.stream().collect(java.util.stream.Collectors.toMap(
games.dmg.fantasystick.passive.PassiveDefinition::id,
games.dmg.fantasystick.passive.PassiveDefinition::name));
var passiveDetails = passiveDefinitions.stream()
.map(definition -> passiveDetailPages(record, definition, passiveNames, passiveView)).toList();
var placeholderPassiveDetails = passiveDefinitions.stream()
.map(definition -> passiveDetailPages(record, definition, passiveNames, passiveView, 1)).toList();
var placeholderTargets = definitions.stream().collect(java.util.stream.Collectors.toMap(
games.dmg.fantasystick.ability.AbilityDefinition::id, ignored -> 1));
var passivePlaceholderTargets = passiveDefinitions.stream().collect(java.util.stream.Collectors.toMap(
games.dmg.fantasystick.passive.PassiveDefinition::id, ignored -> 1));
var placeholderDetails = definitions.stream()
.map(definition -> abilityPages(record, definition, abilityView, 1)).toList();
int activeCount = activePages(record, definitions, placeholderTargets).size();
int passiveCount = passiveSlotPages(record, passiveDefinitions, passivePlaceholderTargets).size();
int indexCount = indexPages(definitions, placeholderTargets).size();
int passiveIndexCount = passiveIndexPages(passiveDefinitions, passivePlaceholderTargets).size();
var placeholderIndex = indexPages(definitions, placeholderTargets);
int indexCount = placeholderIndex.pages().size();
var placeholderPassiveIndex = passiveIndexPages(passiveDefinitions, passivePlaceholderTargets);
int passiveIndexCount = placeholderPassiveIndex.pages().size();
int indexStart = pages.size() + activeCount + passiveCount;
var indexTargets = new java.util.HashMap<String, Integer>();
placeholderIndex.relativePageByAbility().forEach((id, page) -> indexTargets.put(id, indexStart + page + 1));
int passiveIndexStart = indexStart + indexCount;
var passiveIndexTargets = new java.util.HashMap<String, Integer>();
placeholderPassiveIndex.relativePageByPassive().forEach((id, page) ->
passiveIndexTargets.put(id, passiveIndexStart + page + 1));
var targets = new java.util.HashMap<String, Integer>();
int target = pages.size() + activeCount + passiveCount + indexCount + passiveIndexCount + 1;
for (int index = 0; index < definitions.size(); index++) {
targets.put(definitions.get(index).id(), target);
target += details.get(index).size();
target += placeholderDetails.get(index).size();
}
var passiveTargets = new java.util.HashMap<String, Integer>();
for (int index = 0; index < passiveDefinitions.size(); index++) {
passiveTargets.put(passiveDefinitions.get(index).id(), target);
target += passiveDetails.get(index).size();
target += placeholderPassiveDetails.get(index).size();
}
var index = indexPages(definitions, targets);
var details = definitions.stream()
.map(definition -> abilityPages(record, definition, abilityView, indexTargets.get(definition.id()))).toList();
var passiveIndex = passiveIndexPages(passiveDefinitions, passiveTargets);
var passiveDetails = passiveDefinitions.stream().map(definition -> passiveDetailPages(record, definition,
passiveNames, passiveView, passiveIndexTargets.get(definition.id()))).toList();
pages.addAll(activePages(record, definitions, targets));
pages.addAll(passiveSlotPages(record, passiveDefinitions, passiveTargets));
pages.addAll(indexPages(definitions, targets));
pages.addAll(passiveIndexPages(passiveDefinitions, passiveTargets));
pages.addAll(index.pages());
pages.addAll(passiveIndex.pages());
details.forEach(pages::addAll);
passiveDetails.forEach(pages::addAll);
}
if (allOverview.size() > VOLUME_SIZE) {
var view = new View(UUID.randomUUID().toString(), input, start > 0 ? start - VOLUME_SIZE : -1,
end < allOverview.size() ? end : -1);
views.put(input.session().player(), view);
Component navigation = Component.text("Character pages\n\n" + (start + 1) + "–" + end + "/" + allOverview.size());
if (view.previous() >= 0) navigation = navigation.append(Component.newline()).append(pageButton("Previous pages", view, 0));
if (view.next() >= 0) navigation = navigation.append(Component.newline()).append(pageButton("Next pages", view, 1));
if (interactive) {
var view = new View(UUID.randomUUID().toString(), input, start > 0 ? start - VOLUME_SIZE : -1,
end < allOverview.size() ? end : -1);
views.put(input.session().player(), view);
if (view.previous() >= 0) navigation = navigation.append(Component.newline())
.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,
end < allOverview.size() ? end : -1);
adminViews.put(administrator.getUniqueId(), view);
if (view.previous() >= 0) navigation = navigation.append(Component.newline())
.append(adminPageButton("Previous pages", view, 0));
if (view.next() >= 0) navigation = navigation.append(Component.newline())
.append(adminPageButton("Next pages", view, 1));
}
pages.add(navigation);
}
if (end == allOverview.size()) pages.addAll(partyControls.pages(input));
if(end==allOverview.size())pages.addAll(inventoryPages(record.id()));
if (end == allOverview.size()) pages.addAll(interactive
? 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> overviewPages(CharacterRecord record, ControlInput input, StatView view) {
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, boolean savedState) {
var builder = new PageBuilder();
builder.text(record.name());
builder.blank();
for (var stat : Stat.values()) {
Component action = record.unspentPoints().signum() > 0
Component action = view != null && record.unspentPoints().signum() > 0
? statButton(stat, view) : null;
builder.inline(stat + ": " + record.stats().get(stat), action);
}
@@ -224,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(input.player().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();
}
@@ -274,34 +491,61 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
return builder.pages();
}
private static java.util.List<Component> indexPages(
java.util.List<games.dmg.fantasystick.ability.AbilityDefinition> definitions, Map<String, Integer> targets) {
var pages = new java.util.ArrayList<Component>();
for (int start = 0; start < definitions.size(); start += 11) {
var builder = new PageBuilder(); builder.text("Ability index"); builder.blank();
for (var definition : definitions.subList(start, Math.min(start + 11, definitions.size()))) {
builder.link("", definition.name(), targets.get(definition.id()));
}
pages.addAll(builder.pages());
}
return java.util.List.copyOf(pages);
private record AbilityIndex(java.util.List<Component> pages, Map<String, Integer> relativePageByAbility) {
private AbilityIndex { pages = java.util.List.copyOf(pages); relativePageByAbility = Map.copyOf(relativePageByAbility); }
}
private static java.util.List<Component> passiveIndexPages(
java.util.List<games.dmg.fantasystick.passive.PassiveDefinition> definitions, Map<String, Integer> targets) {
private static AbilityIndex indexPages(
java.util.List<games.dmg.fantasystick.ability.AbilityDefinition> definitions, Map<String, Integer> targets) {
var pages = new java.util.ArrayList<Component>();
for (int start = 0; start < definitions.size(); start += 11) {
var relativePages = new java.util.HashMap<String, Integer>();
for (var category : games.dmg.fantasystick.ability.AbilityDefinition.Category.values()) {
var grouped = definitions.stream().filter(definition -> definition.category() == category).toList();
int cursor = 0;
while (cursor < grouped.size()) {
var builder = new PageBuilder(); builder.text("Ability index"); builder.text(category.name());
int usedLines = 0;
while (cursor < grouped.size()) {
var definition = grouped.get(cursor);
int linkLines = BookPagination.lines(definition.name()).size();
if (usedLines > 0 && usedLines + linkLines > 11) break;
relativePages.put(definition.id(), pages.size());
builder.link("", definition.name(), targets.get(definition.id()));
usedLines += linkLines; cursor++;
}
pages.addAll(builder.pages());
}
}
return new AbilityIndex(pages, relativePages);
}
private record PassiveIndex(java.util.List<Component> pages, Map<String, Integer> relativePageByPassive) {
private PassiveIndex { pages = java.util.List.copyOf(pages); relativePageByPassive = Map.copyOf(relativePageByPassive); }
}
private static PassiveIndex passiveIndexPages(
java.util.List<games.dmg.fantasystick.passive.PassiveDefinition> definitions, Map<String, Integer> targets) {
var pages = new java.util.ArrayList<Component>(); var relativePages = new java.util.HashMap<String, Integer>();
int cursor = 0;
while (cursor < definitions.size()) {
var builder = new PageBuilder(); builder.text("Passive index"); builder.blank();
for (var definition : definitions.subList(start, Math.min(start + 11, definitions.size()))) {
int usedLines = 0;
while (cursor < definitions.size()) {
var definition = definitions.get(cursor);
int linkLines = BookPagination.lines(definition.name()).size();
if (usedLines > 0 && usedLines + linkLines > 11) break;
relativePages.put(definition.id(), pages.size());
builder.link("", definition.name(), targets.get(definition.id()));
usedLines += linkLines; cursor++;
}
pages.addAll(builder.pages());
}
return java.util.List.copyOf(pages);
return new PassiveIndex(pages, relativePages);
}
private static java.util.List<Component> passiveDetailPages(CharacterRecord record,
games.dmg.fantasystick.passive.PassiveDefinition definition, Map<String, String> names, PassiveView view) {
games.dmg.fantasystick.passive.PassiveDefinition definition, Map<String, String> names, PassiveView view,
int indexPage) {
String requirements = definition.requirements().entrySet().stream().sorted(Map.Entry.comparingByKey())
.map(entry -> entry.getKey() + " " + entry.getValue()).collect(java.util.stream.Collectors.joining(", "));
if (!definition.prerequisites().isEmpty()) {
@@ -318,15 +562,18 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
String state = learned ? "Learned" : !prerequisites || !stats ? "Unavailable: requirements"
: !points ? "Unavailable: points" : !slot ? "Unavailable: passive slots" : "Available";
var builder = new PageBuilder(); builder.text(definition.name()); builder.text(requirements);
if (learned) builder.component(passiveButton("Unlearn", PASSIVE_UNLEARN, view, definition.id()));
if (view == null) builder.text(state);
else if (learned) builder.component(passiveButton("Unlearn", PASSIVE_UNLEARN, view, definition.id()));
else if (prerequisites && stats && points && slot) builder.component(passiveButton("Learn", PASSIVE_UNLOCK, view, definition.id()));
else builder.text(state);
builder.blank(); definition.description().forEach(builder::text); builder.blank(); builder.text(state);
builder.component(Component.text("[Back to index]", net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.changePage(indexPage)));
return builder.pages();
}
private static java.util.List<Component> abilityPages(CharacterRecord record,
games.dmg.fantasystick.ability.AbilityDefinition definition, AbilityView view) {
games.dmg.fantasystick.ability.AbilityDefinition definition, AbilityView view, int indexPage) {
String requirements = definition.requirements().isEmpty() ? "none" : definition.requirements().entrySet().stream()
.sorted(Map.Entry.comparingByKey()).map(entry -> entry.getKey() + " " + entry.getValue())
.collect(java.util.stream.Collectors.joining(", "));
@@ -339,8 +586,10 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
: !points ? "Unavailable: points" : !slot ? "Unavailable: slots" : "Available";
var builder = new PageBuilder();
builder.text(definition.name());
builder.text(definition.category().name());
builder.text(requirements);
if (learned) builder.component(abilityButton("Unlearn", ABILITY_UNLEARN, view, definition.id()));
if (view == null) builder.text(state);
else if (learned) builder.component(abilityButton("Unlearn", ABILITY_UNLEARN, view, definition.id()));
else if (stats && points && slot) builder.component(abilityButton("Learn", ABILITY_UNLOCK, view, definition.id()));
else builder.text(state);
builder.blank();
@@ -351,6 +600,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
.sorted(Map.Entry.comparingByKey()).map(entry -> entry.getKey() + "=" + entry.getValue())
.collect(java.util.stream.Collectors.joining(", ")));
builder.text(state);
builder.component(Component.text("[Back to index]", net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.changePage(indexPage)));
return builder.pages();
}
@@ -426,6 +677,50 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + view.nonce() + ":" + direction + "\"")));
}
private static Component adminPageButton(String label, AdminView view, int direction) {
return Component.text("[" + label + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(ADMIN_PAGES,
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())
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return;
var administrator = connection.getPlayer();
var view = adminViews.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 || !adminViews.remove(administrator.getUniqueId(), view)) return;
loadReadOnly(administrator, view.target(), view.active(), offset);
}
@org.bukkit.event.EventHandler
public void pageAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
if (closed || !PAGES.equals(event.getIdentifier())
@@ -444,7 +739,11 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
}
private void report(ControlInput input, Throwable failure) {
try { failures.accept(new Failure(input.session().player(), input.session().character(), failure)); }
report(new Failure(input.session().player(), input.session().character(), failure));
}
private void report(Failure failure) {
try { failures.accept(failure); }
catch (RuntimeException ignored) { /* A diagnostic failure must not replay a book request. */ }
}
@@ -603,6 +902,9 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
public void quit(org.bukkit.event.player.PlayerQuitEvent event) {
var owner = event.getPlayer().getUniqueId();
pending.remove(owner);
adminPending.remove(owner);
adminViews.remove(owner);
savedViews.remove(owner);
views.remove(owner);
statViews.remove(owner);
abilityViews.remove(owner);
@@ -611,6 +913,6 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
}
@Override public void close() {
closed = true; pending.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();
}
}
@@ -54,6 +54,17 @@ public final class PartyBookControls implements AutoCloseable {
public List<Component> pages(ControlInput input) { return render(input, Menu.ROOT, 0); }
/** Informational party pages with no registered action or retained viewer state. */
public List<Component> readOnlyPages(UUID account) {
if (closed) return List.of();
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);
@@ -61,18 +72,7 @@ public final class PartyBookControls implements AutoCloseable {
var party = parties.partyOf(owner);
if (menu == Menu.PLAYERS && (party.isEmpty() || !party.orElseThrow().leader().equals(owner))) menu = Menu.ROOT;
if (menu == Menu.ROOT) {
Component summary = Component.text("Party\n\n");
if (party.isEmpty()) {
build.pages.add(summary.append(Component.text("No party.\n\nVisit a Party\nRegistrar.\nCreate, join, or\nmanage one.")));
} else {
var group = party.orElseThrow();
summary = summary.append(Component.text(group.characters().size() + " members\n" + group.members().size() + " active\n\nManage this party\nonly at a Party\nRegistrar."));
build.pages.add(summary);
for (var member : group.characters()) {
Component label=parties.activePlayer(member).map(names).orElse(Component.text("Character\n"+member.toString().substring(0,8)));
build.pages.add(Component.text(member.equals(group.leaderCharacter()) ? "Leader\n\n" : "Member\n\n").append(label));
}
}
build.pages.addAll(rootPages(owner));
} else if (menu == Menu.PLAYERS) {
var group = party.orElseThrow();
var candidates = server.getOnlinePlayers().stream().filter(player -> player.isOnline())
@@ -108,6 +108,29 @@ public final class PartyBookControls implements AutoCloseable {
return List.copyOf(build.pages);
}
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>();
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.")));
} else {
var group = party.orElseThrow();
summary = summary.append(Component.text(group.characters().size() + " members\n" + group.members().size()
+ " active\n\nManage this party\nonly at a Party\nRegistrar."));
pages.add(summary);
for (var member : group.characters()) {
Component label = parties.activePlayer(member).map(names)
.orElse(Component.text("Character\n" + member.toString().substring(0, 8)));
pages.add(Component.text(member.equals(group.leaderCharacter()) ? "Leader\n\n" : "Member\n\n").append(label));
}
}
return List.copyOf(pages);
}
private static int start(int requested, int size) {
return Math.min(Math.max(0, requested), Math.max(0, (size - 1) / PAGE_SIZE * PAGE_SIZE));
}
@@ -35,9 +35,10 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
final ControlInput input;
final LivingEntity target;
final Instant attackedAt;
final boolean knockback;
boolean accepted;
Pending(ControlInput input, LivingEntity target, Instant attackedAt) {
this.input = input; this.target = target; this.attackedAt = attackedAt;
Pending(ControlInput input, LivingEntity target, Instant attackedAt, boolean knockback) {
this.input = input; this.target = target; this.attackedAt = attackedAt; this.knockback = knockback;
}
}
@@ -72,7 +73,8 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
if (ready != null && now.isBefore(ready)) return;
BigInteger damage = damage(record.stats().get(Stat.STR));
double nativeDamage = damage.bitLength() > 1023 ? Double.MAX_VALUE : damage.doubleValue();
var attempt = new Pending(input, target, now);
var attempt = new Pending(input, target, now,
record.passives().contains(games.dmg.fantasystick.passive.StickKnockback.ID));
pending = attempt;
try {
var measured = trainingDamage.apply(input.player(), target, () -> target.damage(nativeDamage, input.player()));
@@ -82,21 +84,27 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
});
} finally {
if (pending == attempt) pending = null;
if (attempt.accepted) readyAt.put(input.session().character(), attempt.attackedAt.plus(CADENCE));
if (attempt.accepted) {
readyAt.put(input.session().character(), attempt.attackedAt.plus(CADENCE));
if (attempt.knockback && !trainingDamage.target(target) && input.stillCurrent()
&& sessions.current(input.session())) {
games.dmg.fantasystick.passive.StickKnockback.apply(input.player(), target);
}
}
}
}
static BigInteger damage(BigInteger strength) {
Objects.requireNonNull(strength);
if (strength.signum() < 0) throw new IllegalArgumentException("Strength must not be negative");
return strength.subtract(BigInteger.TEN).divide(BigInteger.TWO).max(BigInteger.ONE);
return strength.subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.ONE).max(BigInteger.ONE);
}
/** MONITOR observes the synchronous native result; cancelled/zero damage never spends cadence. */
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageByEntityEvent event) {
var attempt = pending;
if (closed || attempt == null || event.isCancelled() || event.getFinalDamage() <= 0
if (closed || attempt == null || attempt.accepted || event.isCancelled() || event.getFinalDamage() <= 0
|| !Double.isFinite(event.getFinalDamage()) || event.getDamager() != attempt.input.player() || event.getEntity() != attempt.target
|| !attempt.input.stillCurrent() || !sessions.current(attempt.input.session())) return;
attempt.accepted = true;
@@ -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)); }
@@ -0,0 +1,66 @@
package games.dmg.fantasystick.dungeon;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.function.*;
import games.dmg.fantasystick.adventure.AdventureAdminTeleport;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.persistence.CharacterRecord;
import org.bukkit.NamespacedKey;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.*;
import org.bukkit.plugin.Plugin;
import org.bukkit.potion.PotionEffectType;
/** Registered command adapter for durable dungeon authoring and catalog administration. */
public final class DungeonCommands {
private static final String PERMISSION="fantasystick.admin";
private final Plugin plugin;private final DungeonDirectory directory;private final DungeonController controller;private final games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures;private final Consumer<Runnable> nextTick;private final games.dmg.fantasystick.adventure.AdventureLocationPresentation locations;private final Function<UUID,CompletableFuture<Optional<CharacterRecord>>> characters;private final Predicate<UUID> activeCharacter;private final Predicate<UUID> partyCharacter;private final CharacterSheets sheets=new CharacterSheets();private final AdventureAdminTeleport teleports;
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"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()+"; 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,String[] args){if(args.length<3||args.length>5){usage(sender);return;}UUID dungeon=id(args[2]);var current=dungeon==null?null:directory.find(dungeon).orElse(null);if(current==null){sender.sendMessage("Dungeon not found.");return;}if(args.length==3){sender.sendMessage(current.rewardConfigured()?"Dungeon "+dungeon+" completion reward is "+current.completionReward().xp()+" XP and "+current.completionReward().coins()+" coins.":"Dungeon "+dungeon+" completion reward is not configured.");return;}if(current.enabled()||adventures.offerOpen(dungeon)||adventures.active().stream().anyMatch(quest->quest.site().equals(dungeon))){sender.sendMessage("Dungeon reward update requires disabled, closed dungeon state without accepted quests.");return;}if(controller.beingEdited(dungeon)&&(!(sender instanceof Player player)||!controller.editing(player.getUniqueId()).filter(dungeon::equals).isPresent())){sender.sendMessage("Dungeon reward update refused: another administrator owns the editing lease.");return;}BigInteger xp=args.length==5?whole(args[3]):null,coins=args.length==5?whole(args[4]):null;if(xp==null||coins==null){usage(sender);return;}directory.setCompletionReward(dungeon,xp,coins).whenComplete((value,failure)->saved(sender,failure,"Set dungeon completion reward to "+xp+" XP and "+coins+" coins."));}
private void boss(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length==5&&args[3].equals("assign-sheet")){assignCharacterBoss(sender,player,dungeon,room,args[4]);return;}if(args.length>=5&&args[3].equals("mob")){var type=entity(args[4]);if(!ordinary(type)){sender.sendMessage("Unsupported living boss type.");return;}var location=player.getLocation();directory.setBoss(dungeon,room.id(),type,join(args,5),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->saved(sender,failure,"Configured native boss "+type.name().toLowerCase(Locale.ROOT)+"."));return;}if(args.length==4&&args[3].equals("clear")){directory.clearBoss(dungeon,room.id()).whenComplete((value,failure)->saved(sender,failure,"Cleared the room boss."));return;}usage(sender);}
private void assignCharacterBoss(CommandSender sender,Player player,UUID dungeon,DungeonState.Room room,String rawMultiplier){BigDecimal multiplier;try{if(!rawMultiplier.matches("(?:0|[1-9][0-9]*)(?:\\.[0-9]{1,3})?"))throw new NumberFormatException();multiplier=new BigDecimal(rawMultiplier);}catch(NumberFormatException invalid){usage(sender);return;}var held=player.getInventory().getItemInMainHand();var sheetIdentity=sheets.identity(held).orElse(null);var character=sheetIdentity==null?null:sheetIdentity.character();if(character==null||held.getAmount()!=1){sender.sendMessage("Hold exactly one authentic living character sheet in your main hand.");return;}if(physicalCopies(character)!=1){sender.sendMessage("Character boss assignment requires one unambiguous physical sheet.");return;}if(activeCharacter.test(character)){sender.sendMessage("That character has an active equipment session.");return;}if(partyCharacter.test(character)){sender.sendMessage("That character must leave its party before boss assignment.");return;}characters.apply(character).whenComplete((loaded,failure)->{if(failure!=null||loaded==null||loaded.isEmpty()||loaded.orElseThrow().dead()||loaded.orElseThrow().health().signum()<=0){respond(sender,()->sender.sendMessage("Character boss assignment refused: the saved living character could not be verified."));return;}var expected=loaded.orElseThrow();if(!sheetIdentity.generation().equals(expected.sheetGeneration())||(sheetIdentity.legacy()&&expected.sheetGeneration().signum()!=0)){respond(sender,()->sender.sendMessage("Character boss assignment refused: that sheet generation is no longer authorized."));return;}directory.assignCharacterBoss(dungeon,room.id(),expected,multiplier).whenComplete((custody,saveFailure)->{if(saveFailure!=null){respond(sender,()->sender.sendMessage("Character boss assignment failed before sheet custody changed."));return;}characters.apply(character).whenComplete((fresh,readFailure)->respond(sender,()->{if(readFailure!=null||fresh==null||fresh.isEmpty()||!fresh.orElseThrow().equals(expected)||activeCharacter.test(character)||partyCharacter.test(character)){directory.cancelAssignment(dungeon,custody.operation()).whenComplete((ignored,rollbackFailure)->respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the saved character changed.":"Character boss custody was saved but assignment rollback could not be confirmed; recover it before editing.")));return;}var current=player.getInventory().getItemInMainHand();if(!player.isOnline()||physicalCopies(character)!=1||current.getAmount()!=1||!sheets.authorizes(current,expected)){directory.cancelAssignment(dungeon,custody.operation()).whenComplete((ignored,rollbackFailure)->respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the held sheet changed.":"Character boss custody was saved but sheet consumption could not be confirmed; recover it before editing.")));return;}player.getInventory().setItemInMainHand(null);sender.sendMessage("Assigned character "+custody.character().name()+" as the dungeon boss at "+custody.multiplier().toPlainString()+"x stats; its sheet is now in durable custody.");}));});});}
private void 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 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","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;}
private DungeonState.Room standing(CommandSender sender,Player player){var result=controller.standingRoom(player).orElse(null);if(result==null)sender.sendMessage("Stand inside exactly one room of the dungeon being edited.");return result;}
private void saved(CommandSender sender,Throwable failure,String success){respond(sender,()->sender.sendMessage(failure==null?success:"Dungeon edit failed; desired state is unchanged."));}
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 static EntityType entity(String value){return Arrays.stream(EntityType.values()).filter(type->type.name().equalsIgnoreCase(value)||type.getKey().toString().equals(value)).findFirst().orElse(null);}
private static boolean ordinary(EntityType type){if(type==null||!type.isSpawnable()||type==EntityType.PLAYER||type==EntityType.ENDER_DRAGON||type==EntityType.WITHER)return false;var entity=type.getEntityClass();return entity!=null&&Mob.class.isAssignableFrom(entity);}
private static List<String> entityNames(){return Arrays.stream(EntityType.values()).filter(DungeonCommands::ordinary).flatMap(v->java.util.stream.Stream.of(v.name().toLowerCase(Locale.ROOT),v.getKey().toString())).distinct().sorted().toList();}
@SuppressWarnings("deprecation") private static List<String> effectNames(){return Arrays.stream(PotionEffectType.values()).map(value->value.getKey().toString()).sorted().toList();}
private static UUID id(String value){try{var id=UUID.fromString(value);return id.toString().equals(value)?id:null;}catch(IllegalArgumentException invalid){return null;}}
private static Integer integer(String value){try{return Integer.valueOf(value);}catch(NumberFormatException invalid){return null;}}
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 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> ...");}
}
@@ -0,0 +1,161 @@
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;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.ProgressionRules;
import games.dmg.fantasystick.progression.RewardAllocator;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.*;
import java.util.function.Consumer;
import org.bukkit.*;
import org.bukkit.entity.*;
import org.bukkit.event.*;
import org.bukkit.event.entity.*;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.persistence.PersistentDataType;
import org.bukkit.plugin.Plugin;
import org.bukkit.potion.*;
/** Transient exclusive editing leases, definition/catalog publication and editor-only room outlines. */
public final class DungeonController implements Listener,AutoCloseable,games.dmg.fantasystick.adventure.AdventureResetParticipant {
public enum EditStatus { STARTED, CONFIRM_REQUIRED, BUSY, NOT_FOUND }
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 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 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;}
public synchronized Optional<UUID> editing(UUID player){var edit=playerEdits.get(player);return edit==null?Optional.empty():Optional.of(edit.dungeon());}
public synchronized boolean beingEdited(UUID dungeon){return dungeonEditors.containsKey(dungeon);}
public boolean occupied(UUID dungeon){var definition=dungeons.find(dungeon).orElse(null);if(definition==null)return false;return plugin.getServer().getOnlinePlayers().stream().anyMatch(player->definition.rooms().stream().anyMatch(room->inside(player.getLocation(),definition,room)));}
public Optional<DungeonState.Room> standingRoom(Player player){var dungeon=editing(player.getUniqueId()).orElse(null);if(dungeon==null)return Optional.empty();var location=player.getLocation();return dungeons.roomAt(dungeon,location.getBlockX(),location.getBlockY(),location.getBlockZ());}
public CompletionStage<DungeonState.Dungeon> enable(UUID id){synchronized(this){if(dungeonEditors.containsKey(id))return CompletableFuture.failedFuture(new IllegalStateException("Dungeon is being edited"));}return dungeons.enable(id,List.of());}
public CompletionStage<Boolean> disable(UUID id){return settleAwards().thenCompose(ignored->dungeons.disable(id)).thenCompose(value->adventures.closeOffer(id).thenCompose(ignored->adventures.removeDungeonSite(id)).thenCompose(ignored->cleanupOnMain(id)).thenApply(ignored->value.isPresent()));}
public CompletionStage<AdventureQuestState.Catalog> open(UUID id){var dungeon=dungeons.find(id).filter(DungeonState.Dungeon::enabled).orElseThrow(()->new IllegalArgumentException("Dungeon is not enabled"));var catalog=adventures.snapshot().catalog();if(catalog!=null&&dungeons.runView(id,catalog.revision()).map(DungeonState.Run::defeated).orElse(false))return CompletableFuture.failedFuture(new IllegalArgumentException("Dungeon is defeated"));return adventures.ensureToday().thenCompose(current->adventures.appendOffer(dungeon.id()));}
public CompletionStage<Boolean> closeOffer(UUID id){var catalog=adventures.snapshot().catalog();boolean defeated=catalog!=null&&dungeons.runView(id,catalog.revision()).map(DungeonState.Run::defeated).orElse(false);return settleAwards().thenCompose(ignored->adventures.closeOffer(id)).thenCompose(changed->{if(catalog==null)return CompletableFuture.completedFuture(changed);CompletionStage<?> progress=defeated?CompletableFuture.completedFuture(false):dungeons.clearRun(id,catalog.revision());return progress.thenCompose(ignored->cleanupOnMain(id)).thenApply(ignored->changed);});}
private CompletableFuture<Void> settleAwards(){return CompletableFuture.allOf(inFlight.stream().map(future->future.handle((ignored,failure)->null)).toArray(CompletableFuture[]::new));}
private CompletionStage<Void> cleanupOnMain(UUID dungeon){var result=new CompletableFuture<Void>();if(closed){result.completeExceptionally(new IllegalStateException("Dungeon controller closed"));return result;}try{nextTick.accept(()->{if(closed){result.completeExceptionally(new IllegalStateException("Dungeon controller closed"));return;}cleanupDungeon(dungeon);synchronized(this){var editor=dungeonEditors.remove(dungeon);if(editor!=null)playerEdits.remove(editor);}result.complete(null);});}catch(RuntimeException failure){result.completeExceptionally(failure);}return result;}
public void definitionChanged(UUID dungeon){synchronized(this){var editor=dungeonEditors.get(dungeon);if(editor==null)return;var edit=playerEdits.get(editor);if(edit==null)return;for(var room:dungeons.find(dungeon).map(DungeonState.Dungeon::rooms).orElse(List.of()))edit.colors().computeIfAbsent(room.id(),ignored->randomColor());}}
private void tick(){if(closed)return;if(++ticks%10==0)showBorders();runtime();}
private void runtime(){var catalog=adventures.snapshot().catalog();if(catalog==null||adventures.snapshot().reset()!=null){cleanupRuntime();return;}var activeDungeonIds=catalog.sites().stream().filter(adventures::offerOpen).filter(id->adventures.findSite(id).map(AdventureQuestState.Site::type).orElse(null)==AdventureQuestState.SiteType.DUNGEON).collect(java.util.stream.Collectors.toSet());for(var key:new ArrayList<>(attempts.keySet()))if(!activeDungeonIds.contains(key.dungeon()))cleanupAttempt(key);var activeEffects=new HashSet<String>();for(UUID dungeonId:activeDungeonIds){if(defeatingDungeons.contains(dungeonId))continue;var dungeon=dungeons.find(dungeonId).orElse(null);if(dungeon==null)continue;var run=dungeons.runView(dungeonId,catalog.revision()).orElse(null);if(run==null){String key=dungeonId+":"+catalog.revision();if(startingRuns.add(key))dungeons.run(dungeonId,catalog.revision()).whenComplete((ignored,failure)->schedule(()->startingRuns.remove(key)));continue;}if(run.defeated()){cleanupDungeon(dungeonId);continue;}for(var room:dungeon.rooms()){var players=eligibleInside(dungeon,room).toList();var key=new AttemptKey(dungeonId,room.id());if(players.isEmpty()){cleanupAttempt(key);continue;}applyPlayerEffects(dungeon.id(),room,players,activeEffects);if(run.clearedRooms().contains(room.id())){cleanupAttempt(key);continue;}if(completing.contains(key))continue;var attempt=attempts.computeIfAbsent(key,ignored->new Attempt(dungeonId,room.id(),catalog.revision()));contain(dungeon,room,attempt);if(attempt.entities.isEmpty()&&attempt.boss==null)spawnRoom(dungeon,room,run,attempt);if(room.boss()!=null&&room.boss().character()!=null)driveCharacterBoss(dungeon,room,attempt,players);if(room.mobs().isEmpty()&&room.boss()==null){attempt.represented.addAll(players.stream().map(this::eligibleQuest).filter(Objects::nonNull).map(AdventureQuestState.Quest::id).toList());clearRoom(dungeon,room,attempt,false);}}}cleanupOwnedEffects(activeEffects);}
private Context context(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return null;var record=sessions.currentRecord(active).orElse(null);if(record==null||record.dead())return null;var party=parties.partyOf(player.getUniqueId()).orElse(null);return party==null?new Context(record.id(),null,Set.of(record.id())):new Context(record.id(),party.id(),Set.copyOf(party.characters()));}
private AdventureQuestState.Quest eligibleQuest(Player player){var context=context(player);if(context==null)return null;var quest=adventures.activeFor(context.character(),context.party()).orElse(null);return quest!=null&&quest.snapshot().contains(context.character())?quest:null;}
private AdventureQuestState.Quest eligible(Player player,UUID dungeon){var quest=eligibleQuest(player);return quest!=null&&quest.site().equals(dungeon)?quest:null;}
private AdventureQuestState.Quest eligible(Player player,UUID dungeon,UUID room){var quest=eligible(player,dungeon);var definition=dungeons.find(dungeon).orElse(null);var roomDefinition=dungeons.room(dungeon,room).orElse(null);return quest!=null&&definition!=null&&roomDefinition!=null&&inside(player.getLocation(),definition,roomDefinition)?quest:null;}
private java.util.stream.Stream<Player> eligibleInside(DungeonState.Dungeon dungeon,DungeonState.Room room){return plugin.getServer().getOnlinePlayers().stream().map(Player.class::cast).filter(player->eligible(player,dungeon.id())!=null).filter(player->inside(player.getLocation(),dungeon,room));}
private static boolean inside(Location location,DungeonState.Dungeon dungeon,DungeonState.Room room){return location.getWorld()!=null&&location.getWorld().getUID().equals(dungeon.world())&&room.bounds().contains(location.getBlockX(),location.getBlockY(),location.getBlockZ());}
@SuppressWarnings({"unchecked","rawtypes"}) private void spawnRoom(DungeonState.Dungeon dungeon,DungeonState.Room room,DungeonState.Run run,Attempt attempt){var world=plugin.getServer().getWorld(dungeon.world());if(world==null)return;for(var slot:room.mobs())if(!run.defeatedSlots().contains(slot.id())&&loaded(world,slot.position()))world.spawn(location(world,slot.position()),(Class)slot.type().getEntityClass(),entity->configure((Mob)entity,dungeon,room,attempt,slot.id(),false,slot.name(),room.effects()));if(room.boss()!=null&&loaded(world,room.boss().position()))world.spawn(location(world,room.boss().position()),(Class)room.boss().type().getEntityClass(),entity->configure((Mob)entity,dungeon,room,attempt,null,true,room.boss().name(),room.effects()));}
private void configure(Mob mob,DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,UUID slot,boolean boss,String name,List<DungeonState.Effect> effects){var data=mob.getPersistentDataContainer();data.set(typeKey,PersistentDataType.STRING,ENEMY);data.set(dungeonKey,PersistentDataType.STRING,dungeon.id().toString());data.set(roomKey,PersistentDataType.STRING,room.id().toString());data.set(generationKey,PersistentDataType.STRING,attempt.generation.toString());data.set(bossKey,PersistentDataType.BOOLEAN,boss);if(slot!=null)data.set(slotKey,PersistentDataType.STRING,slot.toString());mob.setCanPickupItems(false);mob.setRemoveWhenFarAway(false);mob.setPersistent(true);mob.setFireTicks(0);if(mob instanceof Raider raider){raider.setPatrolLeader(false);raider.setCanJoinRaid(false);}if(mob instanceof Zombie zombie)zombie.setShouldBurnInDay(false);if(!name.isBlank()){mob.customName(net.kyori.adventure.text.Component.text(name));mob.setCustomNameVisible(true);}if(boss){attempt.boss=mob.getUniqueId();if(room.boss()!=null&&room.boss().character()!=null)configureCharacterBoss(mob,room.boss().character());}else attempt.entities.put(mob.getUniqueId(),slot);applyMobEffects(mob,effects);}
private void configureCharacterBoss(Mob mob,DungeonState.CharacterBoss custody){var stats=custody.effectiveStats();var maximum=new java.math.BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN)).min(games.dmg.fantasystick.session.NativeHealthLimit.read()).max(java.math.BigDecimal.ONE).doubleValue();var health=mob.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH);if(health!=null){health.setBaseValue(maximum);mob.setHealth(maximum);}var damage=mob.getAttribute(org.bukkit.attribute.Attribute.ATTACK_DAMAGE);if(damage!=null)damage.setBaseValue(Math.min(characterDamage(stats.get(Stat.STR)),nativeAttributeMaximum(org.bukkit.attribute.Attribute.ATTACK_DAMAGE)));var equipment=mob.getEquipment();if(equipment!=null){var gear=custody.character().gear();equipment.setChestplate(armor(org.bukkit.inventory.EquipmentSlot.CHEST,gear.chestplate()));equipment.setLeggings(armor(org.bukkit.inventory.EquipmentSlot.LEGS,gear.leggings()));equipment.setBoots(armor(org.bukkit.inventory.EquipmentSlot.FEET,gear.boots()));equipment.setChestplateDropChance(0);equipment.setLeggingsDropChance(0);equipment.setBootsDropChance(0);}for(var id:custody.character().passives())if(passives.find(id).isPresent()){if(id.equals("speed_i"))mob.addPotionEffect(new PotionEffect(PotionEffectType.SPEED,PotionEffect.INFINITE_DURATION,0,false,false));else if(id.equals("low_light_vision"))mob.addPotionEffect(new PotionEffect(PotionEffectType.NIGHT_VISION,PotionEffect.INFINITE_DURATION,0,false,false));}}
private static double nativeAttributeMaximum(org.bukkit.attribute.Attribute attribute){try{var handle=attribute.getClass().getMethod("getHandle").invoke(attribute);double maximum=((Number)handle.getClass().getMethod("getMaxValue").invoke(handle)).doubleValue();if(!Double.isFinite(maximum)||maximum<=0)throw new IllegalStateException("Invalid native attribute range");return maximum;}catch(ReflectiveOperationException|ClassCastException failure){throw new IllegalStateException("Cannot read native attribute range",failure);}}
private static BigInteger modifier(BigInteger stat){var delta=stat.subtract(BigInteger.TEN);var parts=delta.divideAndRemainder(BigInteger.TWO);return delta.signum()<0&&parts[1].signum()!=0?parts[0].subtract(BigInteger.ONE):parts[0];}
private static double characterDamage(BigInteger stat){var value=modifier(stat).add(BigInteger.ONE).max(BigInteger.ONE);return value.bitLength()>1023?Double.MAX_VALUE:value.doubleValue();}
private static double abilityAmount(AbilityDefinition definition,DungeonState.CharacterBoss custody,boolean healing){var stats=custody.effectiveStats();Stat stat=healing?Stat.WIS:switch(definition.id()){case"great_sword","shield_bash"->Stat.STR;case"longbow"->Stat.DEX;case"hex"->Stat.CHR;default->Stat.INT;};var base=definition.parameters().get(healing?"base-healing":"base-damage");var exact=(base==null?BigInteger.ZERO:base.toBigInteger()).add(modifier(stats.get(stat))).max(BigInteger.ZERO);return exact.bitLength()>1023?Double.MAX_VALUE:exact.doubleValue();}
private static double range(AbilityDefinition definition){for(var key:List.of("range","initial-range","target-range","placement-range")){var value=definition.parameters().get(key);if(value!=null)return value.doubleValue();}return definition.id().equals("great_sword")?4d:16d;}
private static org.bukkit.inventory.ItemStack armor(org.bukkit.inventory.EquipmentSlot slot,games.dmg.fantasystick.persistence.GearTier tier){if(tier==games.dmg.fantasystick.persistence.GearTier.NONE)return null;String material=(tier==games.dmg.fantasystick.persistence.GearTier.GOLD?"GOLDEN":tier.name())+"_"+switch(slot){case CHEST->"CHESTPLATE";case LEGS->"LEGGINGS";case FEET->"BOOTS";default->throw new IllegalArgumentException("Not armor");};return new org.bukkit.inventory.ItemStack(Material.valueOf(material));}
private void driveCharacterBoss(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,List<Player> eligible){var world=plugin.getServer().getWorld(dungeon.world());if(world==null)return;Mob boss=null;for(var entity:safe(world.getEntities()))if(entity instanceof Mob mob&&Objects.equals(attempt.boss,mob.getUniqueId())){boss=mob;break;}if(boss==null)return;var actor=boss;long now=System.nanoTime();if(attempt.effects.getOrDefault("absorption",Long.MAX_VALUE)<=now){boss.setAbsorptionAmount(0);attempt.effects.remove("absorption");}var eligibleIds=eligible.stream().map(Player::getUniqueId).collect(java.util.stream.Collectors.toSet());attempt.threat.keySet().removeIf(id->!eligibleIds.contains(id));Player target=eligible.stream().filter(player->player.getUniqueId().equals(attempt.target)&&!player.isDead()&&actor.hasLineOfSight(player)).findFirst().orElse(null);if(target==null&&!attempt.threat.isEmpty()){target=eligible.stream().filter(player->!player.isDead()&&actor.hasLineOfSight(player)&&attempt.threat.containsKey(player.getUniqueId())).max(Comparator.<Player>comparingDouble(player->attempt.threat.getOrDefault(player.getUniqueId(),0d)).thenComparing(player->player.getUniqueId().toString(),Comparator.reverseOrder())).orElse(null);attempt.target=target==null?null:target.getUniqueId();}if(target==null){regenerateCharacterBoss(boss,room.boss().character(),attempt);return;}if(boss.getTarget()!=target)boss.setTarget(target);castCharacterAbility(boss,target,eligible,room.boss().character(),attempt);}
private void regenerateCharacterBoss(Mob boss,DungeonState.CharacterBoss custody,Attempt attempt){var settings=recoverySettings.get();long now=System.nanoTime();long ready=attempt.cooldowns.getOrDefault("recovery",attempt.lastCombat+settings.delay().toNanos());if(now<ready)return;var maximum=boss.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH);var amount=settings.amount().add(recoveryBonus.apply(custody.character()));if(maximum!=null&&amount.signum()>0&&boss.getHealth()<maximum.getValue())healBoss(boss,Math.min(Double.MAX_VALUE,amount.doubleValue()));attempt.cooldowns.put("recovery",now+settings.interval().toNanos());}
private void castCharacterAbility(Mob boss,Player target,List<Player> eligible,DungeonState.CharacterBoss custody,Attempt attempt){
long now=System.nanoTime();
for(String id:custody.character().loadout()){
if(id.isBlank()||!custody.character().learned().contains(id))continue;
var definition=abilities.find(id).orElse(null);
if(definition==null||now<attempt.cooldowns.getOrDefault(id,0L)
||definition.targetPolicy()==AbilityDefinition.TargetPolicy.EXCEPTIONAL_NONCOMBAT
||definition.targetPolicy()==AbilityDefinition.TargetPolicy.EXCEPTIONAL_NONCOMBAT_SELECTION
||id.equals("revive")||id.equals("guardian_bond"))continue;
double range=range(definition);
if(definition.category()==AbilityDefinition.Category.ATK&&(definition.targetPolicy()!=AbilityDefinition.TargetPolicy.AREA||id.equals("meteor"))
&&(!boss.hasLineOfSight(target)||boss.getLocation().distanceSquared(target.getLocation())>range*range))continue;
boolean cast;
if(id.equals("purify")){
var harmful=boss.getActivePotionEffects().stream().filter(effect->effect.getType().getEffectCategory().name().equals("HARMFUL")).toList();
for(var effect:harmful)boss.removePotionEffect(effect.getType());
cast=!harmful.isEmpty();
}else if(id.equals("hex")){
target.addPotionEffect(new PotionEffect(PotionEffectType.WEAKNESS,Math.max(20,(int)Math.min(1200,definition.cooldown().toMillis()/50)),0,false,true),true);cast=true;
}else cast=switch(definition.category()){
case HEAL->{yield healBoss(boss,abilityAmount(definition,custody,true));}
case BUF->{if(id.equals("quickstep")){var direction=target.getLocation().toVector().subtract(boss.getLocation().toVector());double distance=definition.parameters().getOrDefault("distance",java.math.BigDecimal.ONE).doubleValue();if(direction.lengthSquared()==0||!Double.isFinite(distance)||distance<=0)yield false;boss.setVelocity(direction.normalize().multiply(distance));yield true;}if(id.equals("inspire")){double amount=definition.parameters().getOrDefault("base-temporary-health",java.math.BigDecimal.ZERO).add(new java.math.BigDecimal(modifier(custody.effectiveStats().get(Stat.CHR)))).max(java.math.BigDecimal.ZERO).doubleValue();if(amount<=boss.getAbsorptionAmount())yield false;boss.setAbsorptionAmount(amount);attempt.effects.put("absorption",now+java.time.Duration.ofMillis(durationTicks(definition)*50L).toNanos());yield true;}var type=id.equals("stone_skin")?PotionEffectType.RESISTANCE:id.equals("rallying_cry")?PotionEffectType.SPEED:PotionEffectType.STRENGTH;boss.addPotionEffect(new PotionEffect(type,durationTicks(definition),0,false,false),true);yield true;}
case ATK->{var victims=abilityVictims(definition,boss,target,eligible,range);double amount=abilityAmount(definition,custody,false);if(amount<=0)yield false;for(int index=0;index<victims.size();index++){var victim=victims.get(index);double strike=id.equals("chain_lightning")?java.math.BigDecimal.valueOf(amount).multiply(definition.parameters().getOrDefault("jump-multiplier",java.math.BigDecimal.ONE).pow(index)).doubleValue():amount;victim.damage(strike,boss);if(id.equals("frost_shard")||id.equals("frost_nova"))victim.addPotionEffect(new PotionEffect(PotionEffectType.SLOWNESS,40,0,false,true),true);}yield !victims.isEmpty();}
};
if(cast)attempt.cooldowns.put(id,now+definition.cooldown().toNanos());
}
}
private static int durationTicks(AbilityDefinition definition){var seconds=definition.parameters().get("duration-seconds");long ticks=seconds==null?Math.max(20,definition.cooldown().toMillis()/50):seconds.movePointRight(1).longValue();return (int)Math.max(1,Math.min(Integer.MAX_VALUE,ticks));}
private boolean healBoss(Mob boss,double requested){var maximum=boss.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH);if(maximum==null||!Double.isFinite(requested)||requested<=0)return false;double accepted=Math.min(requested,maximum.getValue()-boss.getHealth());if(accepted<=0)return false;var event=new EntityRegainHealthEvent(boss,accepted,EntityRegainHealthEvent.RegainReason.CUSTOM);plugin.getServer().getPluginManager().callEvent(event);if(event.isCancelled()||!Double.isFinite(event.getAmount())||event.getAmount()<=0)return false;double before=boss.getHealth();boss.setHealth(Math.min(maximum.getValue(),before+event.getAmount()));return boss.getHealth()>before;}
private static List<Player> abilityVictims(AbilityDefinition definition,Mob boss,Player target,List<Player> eligible,double range){if(definition.id().equals("chain_lightning")){var result=new ArrayList<Player>();result.add(target);int maximum=1+definition.parameters().getOrDefault("additional-targets",java.math.BigDecimal.ZERO).intValue();double jump=definition.parameters().getOrDefault("jump-range",java.math.BigDecimal.valueOf(range)).doubleValue();Player current=target;while(result.size()<maximum){var origin=current;var next=eligible.stream().filter(player->!result.contains(player)&&origin.hasLineOfSight(player)&&origin.getLocation().distanceSquared(player.getLocation())<=jump*jump).min(Comparator.comparingDouble((Player player)->origin.getLocation().distanceSquared(player.getLocation())).thenComparing(player->player.getUniqueId().toString())).orElse(null);if(next==null)break;result.add(next);current=next;}return List.copyOf(result);}double radius=definition.parameters().getOrDefault("radius",java.math.BigDecimal.valueOf(range)).doubleValue();var center=definition.id().equals("meteor")?target:boss;return definition.targetPolicy()==AbilityDefinition.TargetPolicy.AREA?eligible.stream().filter(player->center.hasLineOfSight(player)&&center.getLocation().distanceSquared(player.getLocation())<=radius*radius).toList():List.of(target);}
private void applyMobEffects(LivingEntity mob,List<DungeonState.Effect> effects){for(var effect:effects)if(effect.target()!=DungeonState.EffectTarget.PLAYERS){var type=effectType(effect.effect());if(type!=null)mob.addPotionEffect(new PotionEffect(type,PotionEffect.INFINITE_DURATION,effect.level()-1,false,effect.particles()));}}
private void applyPlayerEffects(UUID dungeon,DungeonState.Room room,List<Player> players,Set<String> active){for(var effect:room.effects())if(effect.target()!=DungeonState.EffectTarget.MOBS){var type=effectType(effect.effect());if(type==null)continue;for(var player:players){String key=player.getUniqueId()+":"+effect.id();active.add(key);var current=effect(player,type);int amplifier=effect.level()-1;if(current!=null&&current.getAmplifier()>amplifier)continue;ownedEffects.computeIfAbsent(key,ignored->new OwnedEffect(dungeon,player.getUniqueId(),type,amplifier,current));player.addPotionEffect(new PotionEffect(type,30,amplifier,false,effect.particles()),true);}}}
private void cleanupOwnedEffects(Set<String> active){for(var entry:new ArrayList<>(ownedEffects.entrySet()))if(!active.contains(entry.getKey()))restoreOwned(entry.getKey(),entry.getValue());}
private void cleanupOwnedDungeon(UUID dungeon){for(var entry:new ArrayList<>(ownedEffects.entrySet()))if(entry.getValue().dungeon().equals(dungeon))restoreOwned(entry.getKey(),entry.getValue());}
private void restoreOwned(String key,OwnedEffect owned){ownedEffects.remove(key);var player=plugin.getServer().getPlayer(owned.player());if(player==null)return;var current=effect(player,owned.type());if(current!=null&&current.getAmplifier()==owned.amplifier()){player.removePotionEffect(owned.type());if(owned.prior()!=null&&owned.prior().getDuration()>0)player.addPotionEffect(owned.prior(),true);}}
private static PotionEffect effect(LivingEntity entity,PotionEffectType type){return entity.getActivePotionEffects().stream().filter(value->value.getType().equals(type)).findFirst().orElse(null);}
private static PotionEffectType effectType(String key){var value=NamespacedKey.fromString(key);return value==null?null:PotionEffectType.getByKey(value);}
private void contain(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt){var world=plugin.getServer().getWorld(dungeon.world());if(world==null)return;for(var entity:safe(world.getEntities()))if(managed(entity)&&dungeon.id().equals(id(entity,dungeonKey))&&room.id().equals(id(entity,roomKey))&&!inside(entity.getLocation(),dungeon,room)){relocating.add(entity);try{entity.teleport(new Location(world,(room.bounds().minX()+room.bounds().maxX()+1)/2d,room.bounds().minY()+1,(room.bounds().minZ()+room.bounds().maxZ()+1)/2d));if(entity instanceof LivingEntity living)living.setVelocity(new org.bukkit.util.Vector());}finally{relocating.remove(entity);}}}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void damage(EntityDamageEvent event){if(managedEnemy(event.getEntity())){if(!(event instanceof EntityDamageByEntityEvent by)||source(by)==null||eligible(source(by),id(event.getEntity(),dungeonKey),id(event.getEntity(),roomKey))==null)event.setCancelled(true);return;}if(event instanceof EntityDamageByEntityEvent by&&managed(by.getDamager())){UUID dungeon=id(by.getDamager(),dungeonKey),room=id(by.getDamager(),roomKey);if(!(event.getEntity() instanceof Player player)||dungeon==null||room==null||eligible(player,dungeon,room)==null)event.setCancelled(true);}}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void accepted(EntityDamageByEntityEvent event){if(!managedEnemy(event.getEntity())||event.getFinalDamage()<=0||!Double.isFinite(event.getFinalDamage()))return;var player=source(event);UUID dungeon=id(event.getEntity(),dungeonKey),room=id(event.getEntity(),roomKey);var quest=player==null||dungeon==null||room==null?null:eligible(player,dungeon,room);var attempt=room==null?null:attempts.get(new AttemptKey(dungeon,room));if(quest!=null&&attempt!=null){attempt.represented.add(quest.id());attempt.threat.merge(player.getUniqueId(),event.getFinalDamage(),Double::sum);attempt.lastCombat=System.nanoTime();attempt.cooldowns.remove("recovery");}}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void bossAccepted(EntityDamageByEntityEvent event){if(!(event.getDamager() instanceof Mob boss)||!managedEnemy(boss)||!(event.getEntity() instanceof Player player)||event.getFinalDamage()<=0)return;var room=dungeons.room(id(boss,dungeonKey),id(boss,roomKey)).orElse(null);var custody=room==null||room.boss()==null?null:room.boss().character();if(custody==null)return;if(custody.character().passives().contains("stick_knockback")&&passives.find("stick_knockback").isPresent()){double radians=Math.toRadians(boss.getLocation().getYaw()),x=Math.sin(radians),z=-Math.cos(radians);if(Double.isFinite(x)&&Double.isFinite(z))player.knockback(1,x,z);}}
@EventHandler(priority=EventPriority.MONITOR)public void bossKill(org.bukkit.event.entity.PlayerDeathEvent event){if(!(event.getEntity().getLastDamageCause() instanceof EntityDamageByEntityEvent damage)||!(damage.getDamager() instanceof Mob boss)||!managedEnemy(boss))return;var room=dungeons.room(id(boss,dungeonKey),id(boss,roomKey)).orElse(null);var custody=room==null||room.boss()==null?null:room.boss().character();if(custody==null||!custody.character().passives().contains("health_steal")||passives.find("health_steal").isEmpty()||eligible(event.getPlayer(),id(boss,dungeonKey),id(boss,roomKey))==null)return;healBoss(boss,Math.min(Double.MAX_VALUE,healthStealHealing.get().doubleValue()));}
public void accept(AbilityReport report){if(report.type()==AbilityReport.Type.DAMAGE)return;var active=sessions.activeCharacter(report.sourceCharacter()).filter(sessions::current).orElse(null);if(active==null)return;var player=plugin.getServer().getPlayer(active.player());if(player==null)return;double value=report.amount().doubleValue();if(!Double.isFinite(value)||value<=0)return;for(var entry:attempts.entrySet())if(eligible(player,entry.getKey().dungeon(),entry.getKey().room())!=null)entry.getValue().threat.merge(player.getUniqueId(),value*.5d,Double::sum);}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void death(EntityDeathEvent event){if(!managedEnemy(event.getEntity()))return;UUID dungeonId=id(event.getEntity(),dungeonKey),roomId=id(event.getEntity(),roomKey),generation=id(event.getEntity(),generationKey);if(dungeonId==null||roomId==null||generation==null)return;var dungeon=dungeons.find(dungeonId).orElse(null);var room=dungeons.room(dungeonId,roomId).orElse(null);var attempt=attempts.get(new AttemptKey(dungeonId,roomId));if(dungeon==null||room==null||attempt==null)return;boolean boss=Boolean.TRUE.equals(event.getEntity().getPersistentDataContainer().get(bossKey,PersistentDataType.BOOLEAN));if(boss){attempt.boss=null;defeatingDungeons.add(dungeonId);cleanupDungeon(dungeonId);clearRoom(dungeon,room,attempt,true);return;}UUID slot=id(event.getEntity(),slotKey);attempt.entities.remove(event.getEntity().getUniqueId());if(slot==null)return;encounterOrigin(event.getEntity()).ifPresent(origin->pendingKills.putIfAbsent(origin.source(),new PendingKill(dungeonId,roomId,generation,slot)));}
private void clearRoom(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,boolean boss){var key=new AttemptKey(dungeon.id(),room.id());if(!completing.add(key))return;var quests=attempt.represented.stream().map(adventures::findQuest).flatMap(Optional::stream).toList();var awards=quests.stream().map(quest->awardQuest(dungeon,room,attempt.generation,quest,boss)).toArray(CompletableFuture[]::new);var completion=CompletableFuture.allOf(awards).thenCompose(ignored->dungeons.markCleared(dungeon.id(),attempt.generation,room.id())).thenCompose(run->boss?dungeons.defeat(dungeon.id(),attempt.generation):CompletableFuture.completedFuture(run)).thenCompose(run->boss?adventures.expireOfferQuests(dungeon.id()).thenApply(ignored->run):CompletableFuture.completedFuture(run)).whenComplete((run,failure)->schedule(()->{completing.remove(key);if(boss)defeatingDungeons.remove(dungeon.id());if(failure!=null){plugin.getLogger().log(java.util.logging.Level.WARNING,"Dungeon room completion failed",failure);return;}cleanupAttempt(key);if(boss)cleanupDungeon(dungeon.id());}));inFlight.add(completion);completion.whenComplete((ignored,failure)->inFlight.remove(completion));}
private CompletableFuture<Void> awardQuest(DungeonState.Dungeon dungeon,DungeonState.Room room,UUID generation,AdventureQuestState.Quest quest,boolean completeQuest){var run=dungeons.runView(dungeon.id(),generation).orElse(null);if(run==null||run.unitClaims().contains(new DungeonState.UnitClaim(room.id(),quest.id())))return CompletableFuture.completedFuture(null);var recipients=recipients(dungeon,room,quest,run);if(recipients.isEmpty())return CompletableFuture.completedFuture(null);if(!completeQuest)return dungeons.clearRoom(dungeon.id(),generation,room.id(),quest.id(),Set.copyOf(recipients)).thenApply(ignored->null);if(fishing==null)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon trophy persistence unavailable"));var unit=new RewardAllocator.Unit<UUID>(quest.unit(),recipients);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(UUID character:recipients)rewards.put(character,new FishingState.Reward(xp.get(character),coins.get(character)));UUID carrier=quest.unitType()==AdventureQuestState.UnitType.SOLO?quest.unit():recipients.stream().map(parties::partyOfCharacter).flatMap(Optional::stream).filter(value->value.id().equals(quest.unit())).map(PartyRoster.Party::leaderCharacter).findFirst().orElse(null);if(carrier==null)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon trophy carrier unavailable"));UUID trophy=UUID.nameUUIDFromBytes(("dungeon:"+quest.id()).getBytes(StandardCharsets.UTF_8));var plan=new FishingState.Trophy(trophy,FishingState.TrophyType.DUNGEON,carrier,quest.catalog(),dungeon.id(),quest.id(),generation,"Trophy of "+dungeon.name(),dungeon.name(),rewards);return fishing.earnTrophy(plan).thenCompose(earned->{if(!earned)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon trophy persistence refused"));return dungeons.clearRoom(dungeon.id(),generation,room.id(),quest.id(),Set.copyOf(recipients));}).thenApply(ignored->(Void)null).toCompletableFuture();}
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);}}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void splash(PotionSplashEvent event){if(!managed(event.getPotion()))return;UUID dungeon=id(event.getPotion(),dungeonKey),room=id(event.getPotion(),roomKey);for(var affected:List.copyOf(event.getAffectedEntities()))if(!(affected instanceof Player player)||dungeon==null||room==null||eligible(player,dungeon,room)==null)event.setIntensity(affected,0);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void combust(EntityCombustEvent event){if(managed(event.getEntity()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void transform(EntityTransformEvent event){if(managed(event.getEntity()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void portal(EntityPortalEvent event){if(managed(event.getEntity()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void pickup(EntityPickupItemEvent event){if(managed(event.getEntity()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void block(EntityChangeBlockEvent event){if(managed(event.getEntity()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void explode(EntityExplodeEvent event){if(managed(event.getEntity())){event.blockList().clear();event.setYield(0);}}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void teleport(EntityTeleportEvent event){if(managed(event.getEntity())&&!relocating.contains(event.getEntity()))event.setCancelled(true);}
private boolean managedEnemy(Entity entity){return entity!=null&&ENEMY.equals(entity.getPersistentDataContainer().get(typeKey,PersistentDataType.STRING));}
private Player source(EntityDamageByEntityEvent event){if(event.getDamager() instanceof Player player)return player;if(event.getDamager() instanceof Projectile projectile&&projectile.getShooter() instanceof Player player)return player;return null;}
private void cleanupAttempt(AttemptKey key){var attempt=attempts.remove(key);if(attempt==null)return;for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(managed(entity)&&key.dungeon().equals(id(entity,dungeonKey))&&key.room().equals(id(entity,roomKey)))entity.remove();attempt.entities.clear();attempt.boss=null;}
private void cleanupDungeon(UUID dungeon){cleanupOwnedDungeon(dungeon);for(var key:new ArrayList<>(attempts.keySet()))if(key.dungeon().equals(dungeon))cleanupAttempt(key);for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(managed(entity)&&dungeon.equals(id(entity,dungeonKey)))entity.remove();}
private void cleanupRuntime(){for(var key:new ArrayList<>(attempts.keySet()))cleanupAttempt(key);cleanupOwnedEffects(Set.of());completing.clear();defeatingDungeons.clear();startingRuns.clear();}
private void schedule(Runnable task){if(closed)return;try{nextTick.accept(()->{if(!closed)task.run();});}catch(RuntimeException ignored){}}
private static boolean loaded(World world,DungeonState.Position value){return world.isChunkLoaded(((int)Math.floor(value.x()))>>4,((int)Math.floor(value.z()))>>4);}
private static Location location(World world,DungeonState.Position value){return new Location(world,value.x(),value.y(),value.z(),value.yaw(),value.pitch());}
private static UUID id(Entity entity,NamespacedKey key){try{var value=entity.getPersistentDataContainer().get(key,PersistentDataType.STRING);return value==null?null:UUID.fromString(value);}catch(IllegalArgumentException invalid){return null;}}
private static void copy(org.bukkit.persistence.PersistentDataContainer target,NamespacedKey key,Entity source){var value=source.getPersistentDataContainer().get(key,PersistentDataType.STRING);if(value!=null)target.set(key,PersistentDataType.STRING,value);}
private static <T>List<T> safe(Collection<T> values){return values==null?List.of():List.copyOf(values);}
private void showBorders(){if(closed)return;for(var entry:List.copyOf(playerEdits.entrySet())){var player=plugin.getServer().getPlayer(entry.getKey());if(player==null||!player.isOnline()){done(entry.getKey());continue;}var dungeon=dungeons.find(entry.getValue().dungeon()).orElse(null);if(dungeon==null){done(entry.getKey());continue;}int count=0;for(var room:dungeon.rooms()){var bounds=room.bounds();var location=player.getLocation();if(location.getWorld()==null||!location.getWorld().getUID().equals(dungeon.world()))continue;var dust=new Particle.DustOptions(entry.getValue().colors().computeIfAbsent(room.id(),ignored->randomColor()),1);for(var point:edges(bounds)){var sample=new Location(location.getWorld(),point[0],point[1],point[2]);if(location.distanceSquared(sample)>64d*64d)continue;if(count++>=1024)return;particles.viewer(player,Particle.DUST,sample,1,0,0,0,0,dust);}}}}
static List<double[]> edges(DungeonState.Bounds b){var result=new ArrayList<double[]>();double x1=b.minX(),x2=b.maxX()+1d,y1=b.minY(),y2=b.maxY()+1d,z1=b.minZ(),z2=b.maxZ()+1d;line(result,x1,x2,y1,z1,0);line(result,x1,x2,y1,z2,0);line(result,x1,x2,y2,z1,0);line(result,x1,x2,y2,z2,0);line(result,z1,z2,y1,x1,1);line(result,z1,z2,y1,x2,1);line(result,z1,z2,y2,x1,1);line(result,z1,z2,y2,x2,1);line(result,y1,y2,x1,z1,2);line(result,y1,y2,x1,z2,2);line(result,y1,y2,x2,z1,2);line(result,y1,y2,x2,z2,2);return List.copyOf(result);}
private static void line(List<double[]> out,double from,double to,double fixedA,double fixedB,int axis){for(double value=from;value<to;value+=2)out.add(axis==0?new double[]{value,fixedA,fixedB}:axis==1?new double[]{fixedB,fixedA,value}:new double[]{fixedA,value,fixedB});out.add(axis==0?new double[]{to,fixedA,fixedB}:axis==1?new double[]{fixedB,fixedA,to}:new double[]{fixedA,to,fixedB});}
private static Color randomColor(){var random=java.util.concurrent.ThreadLocalRandom.current();return Color.fromRGB(64+random.nextInt(192),64+random.nextInt(192),64+random.nextInt(192));}
@EventHandler(priority=EventPriority.MONITOR)public void chunkLoad(org.bukkit.event.world.ChunkLoadEvent event){for(var entity:event.getChunk().getEntities())if(managed(entity))entity.remove();}
@EventHandler(priority=EventPriority.MONITOR)public void chunkUnload(org.bukkit.event.world.ChunkUnloadEvent event){for(var entity:event.getChunk().getEntities())if(managed(entity)){UUID dungeon=id(entity,dungeonKey),room=id(entity,roomKey);if(dungeon!=null&&room!=null)cleanupAttempt(new AttemptKey(dungeon,room));}}
@Override public CompletionStage<Void> reset(AdventureQuestState.Reset reset){cleanupRuntime();var pending=inFlight.stream().map(future->future.handle((ignored,failure)->null)).toArray(CompletableFuture[]::new);return CompletableFuture.allOf(pending).thenCompose(ignored->dungeons.reset(reset));}
@EventHandler(priority=EventPriority.MONITOR)public void quit(PlayerQuitEvent event){done(event.getPlayer().getUniqueId());}
@Override public synchronized void close(){if(closed)return;if(pulse!=null)pulse.cancel();cleanupRuntime();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(managed(entity))entity.remove();closed=true;playerEdits.clear();dungeonEditors.clear();startingRuns.clear();completing.clear();defeatingDungeons.clear();}
}
@@ -0,0 +1,75 @@
package games.dmg.fantasystick.dungeon;
import games.dmg.fantasystick.adventure.AdventureQuestState;
import games.dmg.fantasystick.adventure.AdventureResetParticipant;
import games.dmg.fantasystick.persistence.YamlDungeonRepository;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.BigInteger;
import games.dmg.fantasystick.persistence.CharacterRecord;
import java.util.*;
import java.util.concurrent.*;
import org.bukkit.entity.EntityType;
/** Serialized save-before-publication authority for dungeon definitions and daily progress. */
public final class DungeonDirectory implements AutoCloseable,AdventureResetParticipant {
private final YamlDungeonRepository repository;private final java.util.function.IntSupplier roomLinkDistance;private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-dungeon-persistence"));
private volatile DungeonState snapshot;private volatile UUID currentGeneration;private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
public DungeonDirectory(YamlDungeonRepository repository)throws IOException{this(repository,()->10);}
public DungeonDirectory(YamlDungeonRepository repository,java.util.function.IntSupplier roomLinkDistance)throws IOException{this.repository=Objects.requireNonNull(repository);this.roomLinkDistance=Objects.requireNonNull(roomLinkDistance);snapshot=repository.load();}
public DungeonState snapshot(){return snapshot;}
public Optional<DungeonState.Dungeon> find(UUID id){return snapshot.dungeons().stream().filter(v->v.id().equals(id)).findFirst();}
public Optional<DungeonState.CharacterBoss> custody(UUID character){return snapshot.dungeons().stream().flatMap(value->value.rooms().stream()).map(DungeonState.Room::boss).filter(Objects::nonNull).map(DungeonState.Boss::character).filter(Objects::nonNull).filter(value->value.character().id().equals(character)).findFirst();}
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){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)));}
public CompletableFuture<DungeonState.Room> addRadiusRoom(UUID id,String name,int x,int y,int z,int radius,int height){if(radius<1||radius>128||height<1||height>128)return CompletableFuture.failedFuture(new IllegalArgumentException("Room dimensions out of range"));return addRoom(id,name,new DungeonState.Bounds(x-radius,y,z-radius,x+radius,y+height-1,z+radius));}
private CompletableFuture<DungeonState.Room> addRoom(UUID id,String name,DungeonState.Bounds bounds){var room=new DungeonState.Room(UUID.randomUUID(),name,bounds,List.of(),List.of(),DungeonState.Reward.empty(),null);return mutate(state->{var dungeon=editable(state,id);if(dungeon.rooms().size()>=128)throw new IllegalArgumentException("Room limit reached");if(dungeon.rooms().stream().anyMatch(value->value.bounds().overlaps(bounds)))throw new IllegalArgumentException("Rooms overlap");int maximum=linkDistance();var rooms=new ArrayList<>(dungeon.rooms());rooms.add(room);if(!connected(rooms,maximum))throw new IllegalArgumentException("Room exceeds configured link distance of "+maximum+" blocks");return result(replace(state,definition(dungeon,rooms,dungeon.entrance(),false)),room);});}
public CompletableFuture<Optional<DungeonState.Room>> removeRoom(UUID id,UUID roomId){return mutate(state->{var dungeon=editable(state,id);var found=dungeon.rooms().stream().filter(v->v.id().equals(roomId)).findFirst();if(found.isEmpty())return result(state,Optional.empty());if(found.orElseThrow().boss()!=null&&found.orElseThrow().boss().character()!=null)throw new IllegalArgumentException("Recover character boss custody first");var rooms=dungeon.rooms().stream().filter(v->!v.id().equals(roomId)).toList();if(!connected(rooms,linkDistance()))throw new IllegalArgumentException("Removal disconnects rooms");return result(replace(state,definition(dungeon,rooms,dungeon.entrance(),false)),found);});}
public CompletableFuture<DungeonState.Spawn> addMob(UUID id,UUID roomId,EntityType type,String name,double x,double y,double z,float yaw,float pitch){var value=new DungeonState.Spawn(UUID.randomUUID(),type,name,new DungeonState.Position(x,y,z,yaw,pitch));return updateRoom(id,roomId,room->{var mobs=new ArrayList<>(room.mobs());if(mobs.size()>=256)throw new IllegalArgumentException("Mob limit reached");mobs.add(value);return new DungeonState.Room(room.id(),room.name(),room.bounds(),mobs,room.effects(),room.reward(),room.boss());},value);}
public CompletableFuture<Optional<DungeonState.Spawn>> removeMob(UUID id,UUID roomId,UUID spawn){var found=room(id,roomId).flatMap(value->value.mobs().stream().filter(v->v.id().equals(spawn)).findFirst());if(found.isEmpty())return CompletableFuture.completedFuture(Optional.empty());return updateRoom(id,roomId,room->new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs().stream().filter(v->!v.id().equals(spawn)).toList(),room.effects(),room.reward(),room.boss()),found);}
public CompletableFuture<DungeonState.Effect> addEffect(UUID id,UUID roomId,DungeonState.EffectTarget target,String effect,int level,boolean particles){var value=new DungeonState.Effect(UUID.randomUUID(),target,effect,level,particles);return updateRoom(id,roomId,room->{var effects=new ArrayList<>(room.effects());if(effects.size()>=32)throw new IllegalArgumentException("Effect limit reached");effects.add(value);return new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),effects,room.reward(),room.boss());},value);}
public CompletableFuture<Optional<DungeonState.Effect>> removeEffect(UUID id,UUID roomId,UUID effect){var found=room(id,roomId).flatMap(value->value.effects().stream().filter(v->v.id().equals(effect)).findFirst());if(found.isEmpty())return CompletableFuture.completedFuture(Optional.empty());return updateRoom(id,roomId,room->new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects().stream().filter(v->!v.id().equals(effect)).toList(),room.reward(),room.boss()),found);}
public CompletableFuture<DungeonState.Reward> setCompletionReward(UUID id,BigInteger xp,BigInteger coins){var value=new DungeonState.Reward(xp,coins);return mutate(state->{var dungeon=editable(state,id);var changed=new DungeonState.Dungeon(dungeon.id(),dungeon.name(),dungeon.world(),dungeon.entrance(),false,UUID.randomUUID(),dungeon.rooms(),dungeon.participationMode(),value,true);return result(replace(state,changed),value);});}
public 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);});}
public CompletableFuture<Boolean> cancelAssignment(UUID id,UUID operation){return mutate(state->{var dungeon=editable(state,id);var target=dungeon.rooms().stream().filter(room->room.boss()!=null&&room.boss().character()!=null&&room.boss().character().operation().equals(operation)).findFirst().orElse(null);if(target==null)return result(state,false);var custody=target.boss().character();var prior=new DungeonState.Boss(custody.priorType(),custody.priorName(),target.boss().position());var changed=new DungeonState.Room(target.id(),target.name(),target.bounds(),target.mobs(),target.effects(),target.reward(),prior);var rooms=dungeon.rooms().stream().map(room->room.id().equals(target.id())?changed:room).toList();return result(replace(state,definition(dungeon,rooms,dungeon.entrance(),false)),true);});}
public CompletableFuture<DungeonState.CharacterBoss> prepareRecovery(UUID id,UUID administrator){return mutate(state->{var dungeon=editable(state,id);var target=dungeon.rooms().stream().filter(room->room.boss()!=null&&room.boss().character()!=null).findFirst().orElseThrow(()->new IllegalArgumentException("Character boss not found"));var custody=target.boss().character();if(custody.recovery()!=null){if(!custody.recovery().administrator().equals(administrator))throw new IllegalArgumentException("Recovery belongs to another administrator");return result(state,custody);}var prepared=new DungeonState.CharacterBoss(custody.operation(),custody.character(),custody.multiplier(),custody.priorType(),custody.priorName(),new DungeonState.Recovery(UUID.randomUUID(),administrator));var boss=new DungeonState.Boss(target.boss().type(),target.boss().name(),target.boss().position(),prepared);var changed=new DungeonState.Room(target.id(),target.name(),target.bounds(),target.mobs(),target.effects(),target.reward(),boss);var rooms=dungeon.rooms().stream().map(room->room.id().equals(target.id())?changed:room).toList();return result(replace(state,definition(dungeon,rooms,dungeon.entrance(),false)),prepared);});}
public CompletableFuture<Boolean> completeRecovery(UUID id,UUID operation){return mutate(state->{var dungeon=editable(state,id);var target=dungeon.rooms().stream().filter(room->room.boss()!=null&&room.boss().character()!=null&&room.boss().character().recovery()!=null&&room.boss().character().recovery().operation().equals(operation)).findFirst().orElse(null);if(target==null)return result(state,false);var changed=new DungeonState.Room(target.id(),target.name(),target.bounds(),target.mobs(),target.effects(),target.reward(),null);var rooms=dungeon.rooms().stream().map(room->room.id().equals(target.id())?changed:room).toList();return result(replace(state,definition(dungeon,rooms,dungeon.entrance(),false)),true);});}
public CompletableFuture<DungeonState.Dungeon> enable(UUID id,List<DungeonState.Dungeon> externalEnabled){return mutate(state->{var dungeon=find(state,id).orElseThrow(()->new IllegalArgumentException("Dungeon not found"));validate(dungeon);var conflicts=new ArrayList<DungeonState.Dungeon>();conflicts.addAll(state.dungeons().stream().filter(v->v.enabled()&&!v.id().equals(id)).toList());conflicts.addAll(externalEnabled);for(var other:conflicts)if(other.world().equals(dungeon.world())&&dungeon.rooms().stream().anyMatch(a->other.rooms().stream().anyMatch(b->a.bounds().overlaps(b.bounds()))))throw new IllegalArgumentException("Dungeon overlaps an enabled dungeon");if(dungeon.enabled())return result(state,dungeon);var enabled=definition(dungeon,dungeon.rooms(),dungeon.entrance(),true);return result(replace(state,enabled),enabled);});}
public CompletableFuture<Optional<DungeonState.Dungeon>> disable(UUID id){return mutate(state->{var dungeon=find(state,id).orElse(null);if(dungeon==null)return result(state,Optional.empty());var disabled=definition(dungeon,dungeon.rooms(),dungeon.entrance(),false);return result(replace(new DungeonState(state.dungeons(),state.runs().stream().filter(v->!v.dungeon().equals(id)).toList()),disabled),Optional.of(disabled));});}
public CompletableFuture<Boolean> clearRun(UUID dungeon,UUID generation){return mutate(state->{var runs=state.runs().stream().filter(v->!(v.dungeon().equals(dungeon)&&v.generation().equals(generation))).toList();if(runs.size()==state.runs().size())return result(state,false);return result(new DungeonState(state.dungeons(),runs),true);});}
public CompletableFuture<DungeonState.Run> run(UUID dungeon,UUID generation){return mutate(state->{requireGeneration(generation);var found=state.runs().stream().filter(v->v.dungeon().equals(dungeon)&&v.generation().equals(generation)).findFirst();if(found.isPresent())return result(state,found.orElseThrow());var value=new DungeonState.Run(dungeon,generation,Set.of(),Set.of(),Set.of(),Set.of(),false);var runs=new ArrayList<>(state.runs());runs.add(value);return result(new DungeonState(state.dungeons(),runs),value);});}
public Optional<DungeonState.Run> runView(UUID dungeon,UUID generation){return snapshot.runs().stream().filter(v->v.dungeon().equals(dungeon)&&v.generation().equals(generation)).findFirst();}
public CompletableFuture<DungeonState.Run> defeatSlot(UUID dungeon,UUID generation,UUID slot){return updateRun(dungeon,generation,run->{var defeated=new HashSet<>(run.defeatedSlots());defeated.add(slot);return new DungeonState.Run(run.dungeon(),run.generation(),defeated,run.clearedRooms(),run.unitClaims(),run.characterClaims(),run.defeated());});}
public CompletableFuture<DungeonState.Run> markCleared(UUID dungeon,UUID generation,UUID room){return updateRun(dungeon,generation,run->{var cleared=new HashSet<>(run.clearedRooms());cleared.add(room);return new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),cleared,run.unitClaims(),run.characterClaims(),run.defeated());});}
public CompletableFuture<DungeonState.Run> clearRoom(UUID dungeon,UUID generation,UUID room,UUID quest,Set<UUID> characters){return updateRun(dungeon,generation,run->{var cleared=new HashSet<>(run.clearedRooms());cleared.add(room);var units=new HashSet<>(run.unitClaims());units.add(new DungeonState.UnitClaim(room,quest));var claims=new HashSet<>(run.characterClaims());characters.forEach(value->claims.add(new DungeonState.CharacterClaim(room,value)));return new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),cleared,units,claims,run.defeated());});}
public CompletableFuture<DungeonState.Run> defeat(UUID dungeon,UUID generation){return updateRun(dungeon,generation,run->new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),run.clearedRooms(),run.unitClaims(),run.characterClaims(),true));}
@Override public CompletionStage<Void> reset(AdventureQuestState.Reset reset){currentGeneration=reset.generation();return mutate(state->result(new DungeonState(state.dungeons(),List.of()),null));}
private <T>CompletableFuture<T> updateRoom(UUID id,UUID roomId,java.util.function.UnaryOperator<DungeonState.Room> operation,T result){return mutate(state->{var dungeon=editable(state,id);var found=dungeon.rooms().stream().filter(v->v.id().equals(roomId)).findFirst().orElseThrow(()->new IllegalArgumentException("Room not found"));var updated=operation.apply(found);var rooms=dungeon.rooms().stream().map(v->v.id().equals(roomId)?updated:v).toList();return result(replace(state,definition(dungeon,rooms,dungeon.entrance(),false)),result);});}
private CompletableFuture<DungeonState.Run> updateRun(UUID dungeon,UUID generation,java.util.function.UnaryOperator<DungeonState.Run> operation){return mutate(state->{requireGeneration(generation);var existing=state.runs().stream().filter(v->v.dungeon().equals(dungeon)&&v.generation().equals(generation)).findFirst().orElse(new DungeonState.Run(dungeon,generation,Set.of(),Set.of(),Set.of(),Set.of(),false));var updated=operation.apply(existing);var runs=state.runs().stream().filter(v->!(v.dungeon().equals(dungeon)&&v.generation().equals(generation))).collect(java.util.stream.Collectors.toCollection(ArrayList::new));runs.add(updated);return result(new DungeonState(state.dungeons(),runs),updated);});}
private CompletableFuture<DungeonState.Dungeon> change(UUID id,java.util.function.UnaryOperator<DungeonState.Dungeon> operation){return mutate(state->{var updated=operation.apply(editable(state,id));return result(replace(state,updated),updated);});}
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,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(!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())&&current.bounds().withinDistance(room.bounds(),maximum)){reached.add(room.id());queue.add(room);}}return reached.size()==rooms.size();}
private void requireGeneration(UUID generation){var current=currentGeneration;if(current!=null&&!current.equals(generation))throw new IllegalStateException("Stale dungeon generation");}
private record Result<T>(DungeonState state,T value){}
private static <T>Result<T> result(DungeonState state,T value){return new Result<>(state,value);}
private <T>CompletableFuture<T> mutate(java.util.function.Function<DungeonState,Result<T>> operation){var future=new CompletableFuture<T>();synchronized(this){if(closed){future.completeExceptionally(new IOException("Dungeon directory closed"));return future;}pending++;}worker.execute(()->{try{var changed=operation.apply(snapshot);if(changed.state()!=snapshot){repository.save(changed.state());snapshot=changed.state();}future.complete(changed.value());}catch(Throwable failure){future.completeExceptionally(failure);}finally{finished();}});return future;}
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,66 @@
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.*;
import java.util.*;
import org.bukkit.entity.EntityType;
/** Immutable durable dungeon definitions and adventure-generation progress. */
public record DungeonState(List<Dungeon> dungeons,List<Run> runs) {
public enum EffectTarget { PLAYERS, MOBS, BOTH }
public record Position(double x,double y,double z,float yaw,float pitch) {
public Position {if(!Double.isFinite(x)||!Double.isFinite(y)||!Double.isFinite(z)||!Float.isFinite(yaw)||!Float.isFinite(pitch))throw new IllegalArgumentException("Invalid position");}
}
public record Bounds(int minX,int minY,int minZ,int maxX,int maxY,int maxZ) {
public Bounds {if(minX>maxX||minY>maxY||minZ>maxZ)throw new IllegalArgumentException("Invalid bounds");}
public boolean contains(int x,int y,int z){return x>=minX&&x<=maxX&&y>=minY&&y<=maxY&&z>=minZ&&z<=maxZ;}
public boolean overlaps(Bounds other){return minX<=other.maxX&&maxX>=other.minX&&minY<=other.maxY&&maxY>=other.minY&&minZ<=other.maxZ&&maxZ>=other.minZ;}
public boolean touchesFace(Bounds other){boolean vertical=minY<=other.maxY&&maxY>=other.minY;boolean x=(maxX+1==other.minX||other.maxX+1==minX)&&minZ<=other.maxZ&&maxZ>=other.minZ;boolean z=(maxZ+1==other.minZ||other.maxZ+1==minZ)&&minX<=other.maxX&&maxX>=other.minX;return vertical&&(x||z);}
public double distanceSquared(Bounds other){double x=axisDistance(minX,maxX,other.minX,other.maxX),y=axisDistance(minY,maxY,other.minY,other.maxY),z=axisDistance(minZ,maxZ,other.minZ,other.maxZ);return x*x+y*y+z*z;}
public boolean withinDistance(Bounds other,int maximum){return maximum>=1&&distanceSquared(other)<=(double)maximum*maximum;}
private static double axisDistance(int minimum,int maximum,int otherMinimum,int otherMaximum){if(maximum<otherMinimum)return (long)otherMinimum-maximum;if(otherMaximum<minimum)return (long)minimum-otherMaximum;return 0;}
}
public record Spawn(UUID id,EntityType type,String name,Position position) {
public Spawn {Objects.requireNonNull(id);Objects.requireNonNull(type);name=validName(name);Objects.requireNonNull(position);}
}
public record Effect(UUID id,EffectTarget target,String effect,int level,boolean particles) {
public Effect {Objects.requireNonNull(id);Objects.requireNonNull(target);Objects.requireNonNull(effect);if(effect.isBlank()||effect.length()>128||level<1||level>256)throw new IllegalArgumentException("Invalid effect");}
}
public record Reward(BigInteger xp,BigInteger coins) {
public Reward {Objects.requireNonNull(xp);Objects.requireNonNull(coins);var maximum=BigInteger.valueOf(Integer.MAX_VALUE);if(xp.signum()<0||coins.signum()<0||xp.compareTo(maximum)>0||coins.compareTo(maximum)>0)throw new IllegalArgumentException("Invalid reward");}
public static Reward empty(){return new Reward(BigInteger.ZERO,BigInteger.ZERO);}
}
public record Recovery(UUID operation,UUID administrator) {public Recovery{Objects.requireNonNull(operation);Objects.requireNonNull(administrator);}}
public record CharacterBoss(UUID operation,CharacterRecord character,BigDecimal multiplier,EntityType priorType,String priorName,Recovery recovery) {
public CharacterBoss {Objects.requireNonNull(operation);Objects.requireNonNull(character);Objects.requireNonNull(multiplier);Objects.requireNonNull(priorType);priorName=validName(priorName);if(character.dead()||character.health().signum()<=0||multiplier.scale()>3||multiplier.compareTo(new BigDecimal("0.1"))<0||multiplier.compareTo(new BigDecimal("100"))>0)throw new IllegalArgumentException("Invalid character boss");multiplier=multiplier.stripTrailingZeros();}
public CharacterBoss(UUID operation,CharacterRecord character,BigDecimal multiplier,Recovery recovery){this(operation,character,multiplier,EntityType.HUSK,"",recovery);}
public Map<Stat,BigInteger> effectiveStats(){var result=new EnumMap<Stat,BigInteger>(Stat.class);for(var stat:Stat.values())result.put(stat,new BigDecimal(character.stats().get(stat)).multiply(multiplier).setScale(0,RoundingMode.FLOOR).toBigIntegerExact());return Map.copyOf(result);}
}
public record Boss(EntityType type,String name,Position position,CharacterBoss character) {
public Boss {Objects.requireNonNull(type);name=validName(name);Objects.requireNonNull(position);if(character!=null&&(type!=EntityType.VINDICATOR||!name.equals(character.character().name())))throw new IllegalArgumentException("Invalid character boss shell");}
public Boss(EntityType type,String name,Position position){this(type,name,position,null);}
}
public record Room(UUID id,String name,Bounds bounds,List<Spawn> mobs,List<Effect> effects,Reward reward,Boss boss) {
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,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);}}
public record Run(UUID dungeon,UUID generation,Set<UUID> defeatedSlots,Set<UUID> clearedRooms,Set<UnitClaim> unitClaims,Set<CharacterClaim> characterClaims,boolean defeated) {
public Run {Objects.requireNonNull(dungeon);Objects.requireNonNull(generation);defeatedSlots=Set.copyOf(defeatedSlots);clearedRooms=Set.copyOf(clearedRooms);unitClaims=Set.copyOf(unitClaims);characterClaims=Set.copyOf(characterClaims);}
}
public DungeonState {dungeons=List.copyOf(dungeons);runs=List.copyOf(runs);unique(dungeons.stream().map(Dungeon::id).toList());var keys=runs.stream().map(v->v.dungeon()+":"+v.generation()).toList();if(new HashSet<>(keys).size()!=keys.size())throw new IllegalArgumentException("Duplicate dungeon run");}
public static DungeonState empty(){return new DungeonState(List.of(),List.of());}
private static String validName(String name){Objects.requireNonNull(name);name=name.strip();if(name.codePointCount(0,name.length())>64||name.codePoints().anyMatch(c->{int t=Character.getType(c);return t==Character.CONTROL||t==Character.FORMAT||t==Character.SURROGATE;}))throw new IllegalArgumentException("Invalid name");return name;}
private static void unique(List<UUID> values){if(new HashSet<>(values).size()!=values.size())throw new IllegalArgumentException("Duplicate identity");}
}
@@ -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,8 @@ 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."));
@@ -27,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;
@@ -50,6 +54,10 @@ public final class CharacterSheets {
return item;
}
public void markRecovery(ItemStack item,UUID operation){if(identify(item).isEmpty())throw new IllegalArgumentException("Not an authentic character sheet");var meta=item.getItemMeta();meta.getPersistentDataContainer().set(RECOVERY,PersistentDataType.STRING,operation.toString());item.setItemMeta(meta);}
public boolean hasRecovery(ItemStack item,UUID operation){if(identify(item).isEmpty())return false;return operation.toString().equals(item.getItemMeta().getPersistentDataContainer().get(RECOVERY,PersistentDataType.STRING));}
public void clearRecovery(ItemStack item){if(item==null||!item.hasItemMeta())return;var meta=item.getItemMeta();meta.getPersistentDataContainer().remove(RECOVERY);item.setItemMeta(meta);}
/** Adds only the cosmetic model association to an already authentic sheet. */
public boolean applyModel(ItemStack item) {
if (identify(item).isEmpty()) return false;
@@ -60,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();
@@ -93,6 +93,9 @@ public final class SessionControls {
var meta = item.getItemMeta();
meta.displayName(Component.text(icon.name()));
meta.setMaxStackSize(1);
if (slot == 1 && character.passives().contains(games.dmg.fantasystick.passive.StickKnockback.ID)) {
meta.addEnchant(org.bukkit.enchantments.Enchantment.KNOCKBACK, 1, true);
}
if (cooling) meta.setItemModel(COOLDOWN_MODEL);
var data = meta.getPersistentDataContainer();
data.set(VERSION, PersistentDataType.INTEGER, 1);
@@ -142,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 && !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;
@@ -40,6 +40,7 @@ public final class CharacterPartyDirectory implements AutoCloseable {
task -> new Thread(task, "fantasy-party-persistence"));
private volatile List<CharacterParty> snapshot;
private volatile java.util.function.Consumer<CharacterParty> ended=ignored->{};
private volatile java.util.function.Predicate<UUID> unavailable=ignored->false;
private volatile boolean closed;
private volatile boolean ready=true;
private volatile CompletableFuture<Void> readiness=CompletableFuture.completedFuture(null);
@@ -57,6 +58,7 @@ public final class CharacterPartyDirectory implements AutoCloseable {
public synchronized void suspend(){if(!ready)return;ready=false;readiness=new CompletableFuture<>();}
public synchronized void resume(){ready=true;readiness.complete(null);}public boolean ready(){return ready;}public java.util.concurrent.CompletionStage<Void> readiness(){return readiness.minimalCompletionStage();}
public void attachEnded(java.util.function.Consumer<CharacterParty> notification){ended=Objects.requireNonNull(notification);}
public void attachUnavailableCharacter(java.util.function.Predicate<UUID> predicate){unavailable=Objects.requireNonNull(predicate);}
public Optional<CharacterParty> partyOf(UUID character) {
return snapshot.stream().filter(party -> party.members().contains(character)).findFirst();
}
@@ -81,6 +83,7 @@ public final class CharacterPartyDirectory implements AutoCloseable {
return submit(() -> createNow(partyId,leader,input));
}
private Result createNow(UUID partyId,UUID leader,String input)throws IOException{
if(unavailable.test(leader))return Result.of(Status.INVALID);
var completed=partyById(partyId);if(completed.isPresent())return Result.party(Status.DUPLICATE,completed.orElseThrow());
var name=PartyNames.parse(input);if(name.isEmpty())return Result.of(Status.INVALID);
if(partyOf(leader).isPresent())return Result.of(Status.ALREADY_MEMBER);
@@ -89,6 +92,7 @@ public final class CharacterPartyDirectory implements AutoCloseable {
}
public CompletableFuture<PaidResult> paidCreate(PartyOperationStore.Plan plan,CharacterStorage characters){return submit(()->{
if(plan.kind()!=PartyOperationStore.Kind.CREATE)throw new IllegalArgumentException("Expected create operation");
if(unavailable.test(plan.actor()))return new PaidResult(Result.of(Status.INVALID),null);
var existing=partyById(plan.id());if(existing.isPresent()){var spent=debit(plan,characters,plan.expectedRevision());if(refunded(plan,spent)&&plan.id().equals(existing.orElseThrow().lastPaidOperation())){remove(existing.orElseThrow());return new PaidResult(Result.of(Status.INVALID),new CharacterStorage.CoinSpend(CharacterStorage.CoinSpendStatus.STALE,spent.record()));}return new PaidResult(Result.party(Status.DUPLICATE,existing.orElseThrow()),spent);}
var name=PartyNames.parse(plan.name());if(name.isEmpty())return new PaidResult(Result.of(Status.INVALID),null);
if(partyOf(plan.actor()).isPresent())return new PaidResult(Result.of(Status.ALREADY_MEMBER),null);
@@ -100,7 +104,7 @@ public final class CharacterPartyDirectory implements AutoCloseable {
public CompletableFuture<Result> invite(UUID leader, UUID partyId, UUID target) {
return submit(() -> {
var party = managed(leader, partyId);
if (party.isEmpty() || partyOf(target).isPresent() || leader.equals(target)) return Result.of(Status.INVALID);
if (unavailable.test(target) || party.isEmpty() || partyOf(target).isPresent() || leader.equals(target)) return Result.of(Status.INVALID);
var existing = party.orElseThrow().invitations().stream()
.filter(invitation -> invitation.character().equals(target)).findFirst();
if (existing.isPresent()) return Result.invite(Status.DUPLICATE, party.orElseThrow(), existing.orElseThrow());
@@ -114,6 +118,7 @@ public final class CharacterPartyDirectory implements AutoCloseable {
public CompletableFuture<Result> accept(UUID character, UUID invitationId) {
return submit(() -> {
if(unavailable.test(character))return Result.of(Status.INVALID);
CharacterParty party = null; CharacterParty.Invitation invitation = null;
for (var candidate : snapshot) for (var offer : candidate.invitations()) {
if (offer.id().equals(invitationId)) { party = candidate; invitation = offer; }
@@ -6,7 +6,6 @@ import games.dmg.fantasystick.progression.CombatContributions;
import games.dmg.fantasystick.progression.CombatRewardRules;
import games.dmg.fantasystick.progression.ProgressionRules;
import games.dmg.fantasystick.session.EquipmentSessions;
import games.dmg.fantasystick.session.SessionVisuals;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Clock;
@@ -17,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;
@@ -57,12 +57,13 @@ public final class CombatExperience implements Listener, AutoCloseable {
private final CharacterStorage characters;
private final ProgressionRules progression;
private final CombatRewardRules rewards;
private volatile CombatRewardRules rewards;
private final EquipmentSessions sessions;
private final Clock clock;
private final Consumer<Runnable> nextTick;
private final Function<UUID, Player> players;
private final Consumer<Failure> failures;
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private final CombatSources sources;
private final CombatContributions contributions;
private final PartyRoster parties;
@@ -73,6 +74,9 @@ public final class CombatExperience implements Listener, AutoCloseable {
private final Map<UUID, PendingAward> pending = new ConcurrentHashMap<>();
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 {
@@ -81,12 +85,13 @@ public final class CombatExperience implements Listener, AutoCloseable {
final BigInteger experience;
final BigInteger coins;
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.KillPosition restricted, CombatRewardRules rewardRules) {
this.id = id; this.character = character; this.experience = experience;
this.coins = coins; this.restricted = restricted;
this.coins = coins; this.restricted = restricted; this.rewardRules = rewardRules;
}
}
@@ -96,7 +101,8 @@ public final class CombatExperience implements Listener, AutoCloseable {
this(characters, progression, rewards, sessions, new PartyRoster(clock, id -> {
var player = players.apply(id); return player != null && player.isOnline();
}), games.dmg.fantasystick.progression.CoinRules::defaults, java.util.random.RandomGenerator.getDefault(),
clock, nextTick, players, failures);
clock, nextTick, players, failures,
games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());
}
public CombatExperience(CharacterStorage characters, ProgressionRules progression, CombatRewardRules rewards,
@@ -104,15 +110,35 @@ public final class CombatExperience implements Listener, AutoCloseable {
java.util.function.Supplier<games.dmg.fantasystick.progression.CoinRules> coinRules,
java.util.random.RandomGenerator random, Clock clock, Consumer<Runnable> nextTick,
Function<UUID, Player> players, Consumer<Failure> failures) {
this(characters, progression, rewards, sessions, parties, coinRules, random, clock, nextTick, players, failures,
games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());
}
public CombatExperience(CharacterStorage characters, ProgressionRules progression, CombatRewardRules rewards,
EquipmentSessions sessions, PartyRoster parties,
java.util.function.Supplier<games.dmg.fantasystick.progression.CoinRules> coinRules,
java.util.random.RandomGenerator random, Clock clock, Consumer<Runnable> nextTick,
Function<UUID, Player> players, Consumer<Failure> failures,
games.dmg.fantasystick.presentation.ParticleDelivery particles) {
this.characters = Objects.requireNonNull(characters); this.progression = Objects.requireNonNull(progression);
this.rewards = Objects.requireNonNull(rewards); this.sessions = Objects.requireNonNull(sessions);
this.parties = Objects.requireNonNull(parties); this.coinRules = Objects.requireNonNull(coinRules);
this.random = Objects.requireNonNull(random); this.clock = Objects.requireNonNull(clock);
this.nextTick = Objects.requireNonNull(nextTick); this.players = Objects.requireNonNull(players);
this.failures = Objects.requireNonNull(failures);
this.failures = Objects.requireNonNull(failures); this.particles = Objects.requireNonNull(particles);
sources = new CombatSources(sessions); contributions = new CombatContributions(rewards);
}
public void attachExcluded(java.util.function.Predicate<org.bukkit.entity.Entity> predicate) {
if (closed) throw new IllegalStateException("Combat experience is closed");
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);
@@ -134,7 +160,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void damage(EntityDamageEvent event) {
if (closed || games.dmg.fantasystick.ability.GuardianBond.redirectedDamage()
if (closed || excluded.test(event.getEntity()) || games.dmg.fantasystick.ability.GuardianBond.redirectedDamage()
|| event.getFinalDamage() <= 0) return;
var origin = sources.source(event);
if (origin == null || origin.session() == null || !sources.current(origin)
@@ -172,7 +198,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void mobDeath(EntityDeathEvent event) {
if (closed || event instanceof PlayerDeathEvent) return;
if (closed || event instanceof PlayerDeathEvent || excluded.test(event.getEntity())) return;
award(event.getEntity(), classify(event.getEntity()), 0, null, BigInteger.ZERO);
}
@@ -186,25 +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.
var xp = games.dmg.fantasystick.progression.RewardAllocator.allocate(rewards.pool(kind, level), units, random);
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);
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) {
@@ -252,26 +284,27 @@ 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);
});
return;
}
characters.awardCombatReward(operation.character, operation.expectedRevision, operation.id,
operation.experience, operation.coins, progression, rewards, operation.restricted).whenComplete((award, failure) -> {
operation.experience, operation.coins, progression, operation.rewardRules, operation.restricted).whenComplete((award, failure) -> {
synchronized (operation) { operation.running = false; }
if (failure != null) { fail(operation, failure); return; }
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);
@@ -304,7 +337,8 @@ public final class CombatExperience implements Listener, AutoCloseable {
for (int gained = 1; gained <= award.levels(); gained++) {
int level = saved.level() - award.levels() + gained;
player.sendMessage("Level " + level + "! Gained 1 character point.");
SessionVisuals.burst(player, Color.BLUE, 12);
particles.world(org.bukkit.Particle.DUST, player.getLocation().add(0, 1, 0), 12,
0.3, 0.4, 0.3, 0, new org.bukkit.Particle.DustOptions(Color.BLUE, 1));
player.playSound(player.getLocation(), Sound.ENTITY_PLAYER_LEVELUP, 1, 1);
}
} else if (award.status() == CharacterStorage.CombatAwardStatus.SPACING_BLOCKED) {
@@ -314,14 +348,23 @@ public final class CombatExperience implements Listener, AutoCloseable {
} else if (award.status() == CharacterStorage.CombatAwardStatus.UNCHANGED) {
player.sendMessage("No fantasy XP: this target's configured reward is zero.");
}
if (operation.coins.signum() > 0 && award.status() != CharacterStorage.CombatAwardStatus.DUPLICATE) {
if (operation.coins.signum() > 0 && award.status() != CharacterStorage.CombatAwardStatus.DUPLICATE
&& award.status() != CharacterStorage.CombatAwardStatus.SPACING_BLOCKED) {
player.sendMessage("Earned " + operation.coins + " character coins.");
}
}
public void configure(CombatRewardRules updated) {
if (closed) throw new IllegalStateException("Combat rewards are closed");
rewards = Objects.requireNonNull(updated);
}
public CombatRewardRules rules() { return rewards; }
public static CombatRewardRules.Target classify(LivingEntity entity) {
EntityType type = entity.getType();
if (type == EntityType.WARDEN || type == EntityType.ENDER_DRAGON) return CombatRewardRules.Target.BOSS;
if (type == EntityType.WITCH) return CombatRewardRules.Target.HARD;
return PASSIVE.contains(type) ? CombatRewardRules.Target.PASSIVE : CombatRewardRules.Target.ORDINARY;
}
@@ -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);}
@@ -30,6 +31,7 @@ public final class PartyRoster implements AutoCloseable {
private UUID character(UUID player){return sessions.active(player).filter(sessions::current).map(EquipmentSessions.Active::character).orElse(null);}
private List<UUID> activePlayers(CharacterParty party){return party.members().stream().map(sessions::activeCharacter).flatMap(Optional::stream).filter(sessions::current).map(EquipmentSessions.Active::player).sorted().toList();}
public Optional<UUID> activePlayer(UUID character){if(durable==null)return Optional.of(character);return sessions.activeCharacter(character).filter(sessions::current).map(EquipmentSessions.Active::player);}
public boolean exists(UUID party){if(durable!=null)return durable.ready()&&durable.all().stream().anyMatch(value->value.id().equals(party));return membership.values().stream().anyMatch(value->value.id().equals(party));}
private Optional<Party> project(CharacterParty party){var members=activePlayers(party);UUID leader=sessions.activeCharacter(party.leader()).filter(sessions::current).map(EquipmentSessions.Active::player).orElse(party.leader());return Optional.of(new Party(party.id(),leader,members,party.leader(),party.members().stream().sorted().toList()));}
private Party managedBy(UUID leader,UUID party){var group=membership.get(leader);return group!=null&&group.id().equals(party)&&group.leader().equals(leader)&&connected(leader)?group:null;}
private void removeMember(Party group,UUID player){var remaining=group.members().stream().filter(member->!member.equals(player)).toList();if(remaining.stream().noneMatch(this::connected)){remove(group);return;}membership.remove(player);departed.remove(player);var updated=new Party(group.id(),group.leader(),remaining);for(var member:remaining)membership.put(member,updated);}
@@ -0,0 +1,28 @@
package games.dmg.fantasystick.passive;
import games.dmg.fantasystick.progression.Stat;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** Fixed reviewed metadata and native level-I behavior for the basic-stick Knockback passive. */
public final class StickKnockback {
public static final String ID = "stick_knockback";
public PassiveDefinition definition() {
return new PassiveDefinition(ID, "Knockback", Map.of(Stat.STR, BigInteger.valueOf(12)), Set.of(),
BigInteger.ONE, List.of("Your bound basic attack stick carries native Knockback I.",
"Only positive accepted basic-stick damage can knock the target back."));
}
public static void apply(org.bukkit.entity.Player source, org.bukkit.entity.LivingEntity target) {
double radians = Math.toRadians(source.getLocation().getYaw());
double directionX = Math.sin(radians);
double directionZ = -Math.cos(radians);
if (!Double.isFinite(directionX) || !Double.isFinite(directionZ)) return;
boolean sprinting = source.isSprinting();
target.knockback(sprinting ? 2 : 1, directionX, directionZ);
if (sprinting) source.setSprinting(false);
}
}
@@ -0,0 +1,101 @@
package games.dmg.fantasystick.persistence;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Durable save-before-publication settings for later daily catalogs, camp and champion attempts. */
public final class AdventureQuestSettings implements AutoCloseable {
public static final String DAILY_COUNT="adventure-daily-site-count", RADIUS="adventure-site-radius",
RESET_SECONDS="adventure-site-reset-seconds", DUNGEON_ROOM_LINK_DISTANCE="dungeon-room-link-distance",
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",
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(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 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,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) {
public Update { values=Map.copyOf(values); }
}
private final Path directory;
private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-adventure-configuration"));
private volatile Map<String,String> effective;
private Map<String,String> persisted;
private long savedSequence,publishedSequence;
private boolean closed; private int pending;
private final CompletableFuture<Void> drained=new CompletableFuture<>();
public AdventureQuestSettings(Path directory,Map<String,String> initial) {
this.directory=Objects.requireNonNull(directory).toAbsolutePath();
var values=new LinkedHashMap<String,String>();
for(String key:KEYS)values.put(key,Objects.requireNonNull(initial.getOrDefault(key,DEFAULTS.get(key))));
parse(values);effective=Map.copyOf(values);persisted=effective;
}
public static List<String> keys(){return KEYS;}
public static String defaultValue(String key){return DEFAULTS.get(key);}
public String value(String key){return effective.get(key);}
public Rules rules(){return parse(effective);}
public static Rules parse(Map<String,String> values){
try{return new Rules(bounded(values.get(DAILY_COUNT),0,20),bounded(values.get(RADIUS),4,64),
bounded(values.get(RESET_SECONDS),0,600),bounded(values.get(CAMP_ENEMIES),4,64),
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),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();}
private static BigInteger whole(String raw){var value=new BigInteger(raw);if(value.signum()<0||value.compareTo(BigInteger.valueOf(Integer.MAX_VALUE))>0)throw new IllegalArgumentException();return value;}
public CompletionStage<Update> set(String key,String raw){
if(!KEYS.contains(key))throw new IllegalArgumentException("Unknown adventure setting");
var preview=new LinkedHashMap<>(effective);preview.put(key,new BigInteger(raw).toString());var accepted=preview.get(key);parse(preview);
synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Configuration closed"));pending++;}
return CompletableFuture.supplyAsync(()->{try{var next=new LinkedHashMap<>(persisted);next.put(key,accepted);parse(next);save(key,accepted);persisted=Map.copyOf(next);return new Update(key,accepted,persisted,++savedSequence);}catch(IOException failure){throw new CompletionException(failure);}finally{finished();}},worker).minimalCompletionStage();
}
public synchronized boolean publish(Update update){if(update.sequence()<=publishedSequence)return false;parse(update.values());effective=update.values();publishedSequence=update.sequence();return true;}
private void save(String key,String value)throws IOException{
Files.createDirectories(directory);var file=directory.resolve("config.yml");var lockFile=directory.resolve(".config.lock");
try(var channel=FileChannel.open(lockFile,StandardOpenOption.CREATE,StandardOpenOption.WRITE,LinkOption.NOFOLLOW_LINKS);var lock=channel.tryLock()){
if(lock==null)throw new IOException("Configuration busy");var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(20);options.setCodePointLimit(1_048_576);var yaml=new Yaml(new SafeConstructor(options));Map<String,Object> document=new LinkedHashMap<>();
if(Files.exists(file,LinkOption.NOFOLLOW_LINKS)){if(!Files.isRegularFile(file,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe configuration");byte[] bytes;try(var input=Files.newInputStream(file,LinkOption.NOFOLLOW_LINKS)){bytes=input.readNBytes(1_048_577);}if(bytes.length>1_048_576)throw new IOException("Configuration too large");Object loaded=yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());if(loaded instanceof Map<?,?> map)for(var entry:map.entrySet()){if(!(entry.getKey() instanceof String savedKey))throw new IOException("Invalid key");document.put(savedKey,entry.getValue());}}
document.put(key,Integer.parseInt(value));byte[] bytes=yaml.dump(document).getBytes(StandardCharsets.UTF_8);Path temporary=Files.createTempFile(directory,".config-",".tmp");try{Files.write(temporary,bytes,StandardOpenOption.TRUNCATE_EXISTING);try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE)){output.force(true);}Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);DurableFiles.forceDirectory(directory);}finally{Files.deleteIfExists(temporary);}
}catch(OverlappingFileLockException busy){throw new IOException("Configuration busy",busy);}
}
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);}
}
@@ -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,8 +42,12 @@ 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);}
public CompletableFuture<Void> create(CharacterRecord character) {
return submit(() -> {
repository.create(character);
@@ -203,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);
});
@@ -271,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);
});
@@ -307,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));
});
@@ -316,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) { }
@@ -376,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(coinReward), 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);
});
@@ -416,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));
});
@@ -444,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));
});
@@ -479,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));
});
@@ -499,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));
});
@@ -522,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));
});
@@ -546,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));
});
@@ -591,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));
});
@@ -617,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);
});
@@ -654,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);
});
@@ -691,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);
});
@@ -16,7 +16,7 @@ import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Plain values only: no Bukkit item deserialization or arbitrary Java YAML tags. */
final class CharacterYaml {
public final class CharacterYaml {
private CharacterYaml() { }
private static Yaml yaml() {
@@ -27,12 +27,13 @@ final class CharacterYaml {
return new Yaml(new SafeConstructor(options));
}
static String encode(CharacterRecord record) {
public static String encode(CharacterRecord record) {
Map<String, Object> root = new LinkedHashMap<>();
root.put("schema-version", 1);
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);
@@ -67,7 +68,7 @@ final class CharacterYaml {
return yaml().dump(root);
}
static CharacterRecord decode(String input) {
public static CharacterRecord decode(String input) {
Map<?, ?> root = object(yaml().load(input));
if (!number(root.get("schema-version")).equals(BigInteger.ONE)) {
throw new IllegalArgumentException("Unsupported character schema");
@@ -108,7 +109,8 @@ 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"))));}
@@ -0,0 +1,132 @@
package games.dmg.fantasystick.persistence;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.*;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Durable save-before-publication Witch hard-mob XP pool. */
public final class HardMobXpSettings implements AutoCloseable {
public static final String KEY = "hard-mob-xp";
public static final String DEFAULT = "20";
private static final BigInteger MAXIMUM = BigInteger.valueOf(Integer.MAX_VALUE);
private static final int MAX_BYTES = 1_048_576;
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String value, BigInteger pool, long sequence) { }
private final Path directory;
private final Replacement replacement;
private final ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-hard-mob-xp-configuration"));
private volatile String effective;
private String persisted;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public HardMobXpSettings(Path directory, String initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
HardMobXpSettings(Path directory, String initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
this.replacement = Objects.requireNonNull(replacement);
effective = canonical(initial); persisted = effective;
}
public static BigInteger parse(String raw) {
try {
var value = new BigInteger(raw);
if (value.signum() < 0 || value.compareTo(MAXIMUM) > 0) throw new IllegalArgumentException();
return value;
} catch (RuntimeException invalid) {
throw new IllegalArgumentException("Hard-mob XP must be a whole number from 0 through 2147483647", invalid);
}
}
private static String canonical(String raw) { return parse(raw).toString(); }
public String value() { return effective; }
public BigInteger pool() { return parse(effective); }
public CompletionStage<Update> set(String raw) {
String accepted = canonical(raw);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
save(accepted); persisted = accepted;
return new Update(accepted, parse(accepted), ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new CompletionException(failure instanceof IllegalArgumentException ? failure
: new IOException("Hard-mob XP save failed; effective value unchanged", failure));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed", rejected));
}
}
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return false;
effective = canonical(update.value()); publishedSequence = update.sequence(); return true;
}
private void save(String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS))
throw new IOException("Unsafe lock");
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false); options.setMaxAliasesForCollections(20);
options.setCodePointLimit(MAX_BYTES); var yaml = new Yaml(new SafeConstructor(options));
Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes; try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) {
bytes = input.readNBytes(MAX_BYTES + 1);
}
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String key)) throw new IOException("Invalid configuration key");
document.put(key, entry.getValue());
}
}
}
document.put(KEY, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy", busy); }
}
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); }
}
@@ -22,22 +22,35 @@ import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Durable account-owned routine combat/effect feedback preference. */
/** Durable account-owned presentation preferences, serialized through one save-before-publish worker. */
public final class PlayerFeedbackPreferences implements AutoCloseable {
private static final String FILE = "player-preferences.yml";
private static final String PLAYERS = "players";
private static final String KEY = "combat-feedback";
private static final int MAX_BYTES = 1_048_576;
public record Update(UUID account, boolean enabled, long sequence) {
public Update { Objects.requireNonNull(account); }
public enum Preference {
COMBAT_FEEDBACK("combat-feedback", true),
PLAYER_AURAS("player-aura-particles", false),
ALL_PARTICLES("all-particles", true);
private final String key;
private final boolean defaultValue;
Preference(String key, boolean defaultValue) { this.key = key; this.defaultValue = defaultValue; }
}
private record AccountPreference(UUID account, Preference preference) {
private AccountPreference { Objects.requireNonNull(account); Objects.requireNonNull(preference); }
}
public record Update(UUID account, Preference preference, boolean enabled, long sequence) {
public Update { Objects.requireNonNull(account); Objects.requireNonNull(preference); }
}
private final Path directory;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-player-feedback-preferences"));
private final Map<UUID, Boolean> effective = new java.util.HashMap<>();
private final Map<UUID, Long> published = new java.util.HashMap<>();
private final Map<AccountPreference, Boolean> effective = new java.util.HashMap<>();
private final Map<AccountPreference, Long> published = new java.util.HashMap<>();
private long persistedSequence;
private boolean closed;
private int pending;
@@ -48,13 +61,34 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
effective.putAll(loadEnabled());
}
public synchronized boolean enabled(UUID account) {
/** Existing routine combat/effect feedback preference. */
public synchronized boolean enabled(UUID account) { return value(account, Preference.COMBAT_FEEDBACK); }
public synchronized boolean playerAurasEnabled(UUID account) { return value(account, Preference.PLAYER_AURAS); }
public synchronized boolean allParticlesEnabled(UUID account) { return value(account, Preference.ALL_PARTICLES); }
public synchronized boolean auraParticlesEnabled(UUID account) {
return value(account, Preference.ALL_PARTICLES) && value(account, Preference.PLAYER_AURAS);
}
private boolean value(UUID account, Preference preference) {
Objects.requireNonNull(account);
return effective.getOrDefault(account, true);
return effective.getOrDefault(new AccountPreference(account, preference), preference.defaultValue);
}
public CompletionStage<Update> set(UUID account, boolean enabled) {
Objects.requireNonNull(account);
return set(account, Preference.COMBAT_FEEDBACK, enabled);
}
public CompletionStage<Update> setPlayerAuras(UUID account, boolean enabled) {
return set(account, Preference.PLAYER_AURAS, enabled);
}
/** Shared persisted precedence for the separately delivered all-particles command. */
public CompletionStage<Update> setAllParticles(UUID account, boolean enabled) {
return set(account, Preference.ALL_PARTICLES, enabled);
}
private CompletionStage<Update> set(UUID account, Preference preference, boolean enabled) {
Objects.requireNonNull(account); Objects.requireNonNull(preference);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Player preference store is closed"));
pending++;
@@ -62,11 +96,11 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
try {
return CompletableFuture.supplyAsync(() -> {
try {
save(account, enabled);
return new Update(account, enabled, ++persistedSequence);
save(account, preference, enabled);
return new Update(account, preference, enabled, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(new IOException(
"Combat feedback preference save refused or failed; effective setting unchanged", failure));
"Player preference save refused or failed; effective setting unchanged", failure));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
@@ -78,34 +112,37 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (closed) return false;
long previous = published.getOrDefault(update.account(), 0L);
var key = new AccountPreference(update.account(), update.preference());
long previous = published.getOrDefault(key, 0L);
if (update.sequence() <= previous) return false;
effective.put(update.account(), update.enabled());
published.put(update.account(), update.sequence());
effective.put(key, update.enabled());
published.put(key, update.sequence());
return true;
}
private Map<UUID, Boolean> loadEnabled() throws IOException {
private Map<AccountPreference, Boolean> loadEnabled() throws IOException {
Map<String, Object> root = readRoot();
Object rawPlayers = root.get(PLAYERS);
if (rawPlayers == null) return Map.of();
if (!(rawPlayers instanceof Map<?, ?> players)) throw new IOException("Invalid player preferences");
var result = new LinkedHashMap<UUID, Boolean>();
var result = new LinkedHashMap<AccountPreference, Boolean>();
for (var entry : players.entrySet()) {
if (!(entry.getKey() instanceof String rawId)) throw new IOException("Invalid player preference identity");
UUID account;
try { account = UUID.fromString(rawId); }
catch (IllegalArgumentException invalid) { throw new IOException("Invalid player preference identity", invalid); }
if (!(entry.getValue() instanceof Map<?, ?> values)) throw new IOException("Invalid player preference entry");
Object raw = values.get(KEY);
if (raw == null) continue;
if (!(raw instanceof Boolean value)) throw new IOException("Invalid combat feedback preference");
result.put(account, value);
for (var preference : Preference.values()) {
Object raw = values.get(preference.key);
if (raw == null) continue;
if (!(raw instanceof Boolean value)) throw new IOException("Invalid " + preference.key + " preference");
result.put(new AccountPreference(account, preference), value);
}
}
return result;
}
private void save(UUID account, boolean enabled) throws IOException {
private void save(UUID account, Preference preference, boolean enabled) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe data directory");
Path lockFile = directory.resolve(".player-preferences.lock");
@@ -118,7 +155,7 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
Map<String, Object> root = readRoot();
Map<String, Object> players = stringMap(root.get(PLAYERS), "player preferences");
Map<String, Object> values = stringMap(players.get(account.toString()), "player preference entry");
values.put(KEY, enabled);
values.put(preference.key, enabled);
players.put(account.toString(), values);
root.put(PLAYERS, players);
byte[] bytes = yaml().dump(root).getBytes(StandardCharsets.UTF_8);
@@ -0,0 +1,74 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.adventure.AdventureQuestState;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.time.Instant;
import java.time.LocalDate;
import java.util.*;
import org.bukkit.entity.EntityType;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Atomic single-file durable authority for daily adventure quests. */
public final class YamlAdventureQuestRepository {
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);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"))),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());}
var tracking=new HashSet<AdventureQuestState.Tracking>();for(Object item:root.containsKey("tracking")?list(root.get("tracking")):List.of()){var r=map(item);tracking.add(new AdventureQuestState.Tracking(uuid(r,"quest"),uuid(r,"character"),Boolean.parseBoolean(Objects.toString(r.get("enabled")))));}
var rewards=AdventureQuestState.RewardLedger.empty();
if(root.get("rewards")!=null){
var ledger=map(root.get("rewards"));
var allocations=new ArrayList<AdventureQuestState.RewardAllocation>();
for(Object item:list(ledger.get("allocations"))){var r=map(item);allocations.add(new AdventureQuestState.RewardAllocation(uuid(r,"id"),uuid(r,"catalog"),uuid(r,"site"),uuid(r,"quest"),uuid(r,"source"),uuid(r,"character"),new BigInteger(text(r.get("xp"))),new BigInteger(text(r.get("coins")))));}
var operations=new ArrayList<AdventureQuestState.RewardClaim>();
for(Object item:list(ledger.get("operations"))){var r=map(item);operations.add(new AdventureQuestState.RewardClaim(uuid(r,"id"),uuid(r,"catalog"),uuid(r,"site"),uuid(r,"character"),list(r.get("allocations")).stream().map(value->UUID.fromString(text(value))).collect(java.util.stream.Collectors.toSet()),new BigInteger(text(r.get("xp"))),new BigInteger(text(r.get("coins")))));}
var receipts=new HashSet<AdventureQuestState.RewardReceipt>();
for(Object item:list(ledger.get("receipts"))){var r=map(item);receipts.add(new AdventureQuestState.RewardReceipt(uuid(r,"catalog"),uuid(r,"site"),uuid(r,"character")));}
rewards=new AdventureQuestState.RewardLedger(allocations,operations,receipts);
}
AdventureQuestState.Reset reset=null;if(root.get("reset")!=null){var r=map(root.get("reset"));reset=new AdventureQuestState.Reset(uuid(r,"operation"),uuid(r,"generation"),Instant.parse(text(r.get("started-at"))));}
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",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(),"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);}legacyDungeonRewardTerms=false;
}
private static Map<String,Object> siteRow(AdventureQuestState.Site site){var result=row("id",site.id(),"type",site.type(),"name",site.name(),"world",site.world(),"x",site.x(),"y",site.y(),"z",site.z(),"participation-mode",site.participationMode().value(),"fishing",site.fishing()==null?null:fishing(site.fishing().terms()));if(site.arena()!=null){result.put("arena-shape",encode(site.arena().shape()));result.put("arena-x",site.arena().xBlocks());result.put("arena-z",site.arena().zBlocks());}return result;}
private static Map<String,Object> 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 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();}
private static String text(Object value){if(value instanceof String result)return result;throw new IllegalArgumentException();}
private static UUID uuid(Map<?,?> row,String key){return UUID.fromString(text(row.get(key)));}
private static double decimal(Map<?,?> row,String key){return Double.parseDouble(text(row.get(key)));}
private static int number(Object value){return Integer.parseInt(value.toString());}
}
@@ -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;
@@ -22,12 +24,20 @@ public final class YamlCharacterRepository {
private static final int MAX_FILE_BYTES = 1_048_576;
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; }
@FunctionalInterface
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,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
@@ -68,10 +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);
@@ -103,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();
@@ -0,0 +1,45 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.dungeon.DungeonState;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import org.bukkit.entity.EntityType;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Atomic single-file durable authority for dungeon definitions and generation progress. */
public final class YamlDungeonRepository {
private final Path file;
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)));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",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());}
private static Map<String,Object> position(DungeonState.Position value){return row("x",value.x(),"y",value.y(),"z",value.z(),"yaw",value.yaw(),"pitch",value.pitch());}
private static DungeonState.Position position(Map<?,?> value){return new DungeonState.Position(decimal(value.get("x")),decimal(value.get("y")),decimal(value.get("z")),(float)decimal(value.get("yaw")),(float)decimal(value.get("pitch")));}
private static Map<String,Object> bounds(DungeonState.Bounds value){return row("min-x",value.minX(),"min-y",value.minY(),"min-z",value.minZ(),"max-x",value.maxX(),"max-y",value.maxY(),"max-z",value.maxZ());}
private static DungeonState.Bounds bounds(Map<?,?> value){return new DungeonState.Bounds(integer(value.get("min-x")),integer(value.get("min-y")),integer(value.get("min-z")),integer(value.get("max-x")),integer(value.get("max-y")),integer(value.get("max-z")));}
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||value instanceof Double||value instanceof Float)return value.toString();if(value instanceof Collection<?> values)return values.stream().map(YamlDungeonRepository::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 int integer(Object value){return Integer.parseInt(value.toString());}
private static double decimal(Object value){return Double.parseDouble(text(value));}
private static boolean bool(Object value){if(value instanceof Boolean result)return result;throw new IllegalArgumentException();}
private static UUID uuid(Map<?,?> row,String key){return UUID.fromString(text(row.get(key)));}
private static Set<UUID> uuids(Object values){var result=new HashSet<UUID>();for(var value:list(values))result.add(UUID.fromString(text(value)));return result;}
}
@@ -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;}
}
@@ -10,6 +10,8 @@ import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Locale;
import java.util.Objects;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
@@ -74,7 +76,8 @@ public final class CombatFeedback implements AbilityFeedback {
}
private static void send(Player player, String message) {
try { player.sendMessage(message); } catch (RuntimeException ignored) { }
try { player.sendMessage(Component.text(message, NamedTextColor.GRAY)); }
catch (RuntimeException ignored) { }
}
private static boolean recipientEffect(AbilityReport.Type type) {
@@ -0,0 +1,117 @@
package games.dmg.fantasystick.presentation;
import games.dmg.fantasystick.persistence.PlayerFeedbackPreferences;
import java.util.List;
import java.util.Objects;
import java.util.function.Consumer;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.Server;
import org.bukkit.entity.Player;
/** Bounded viewer-aware authority for every explicit Fantasy Stick-owned particle packet. */
public final class ParticleDelivery {
private static final double NATIVE_PARTICLE_RANGE_SQUARED = 32d * 32d;
private final Server server;
private final PlayerFeedbackPreferences preferences;
private final Consumer<Throwable> failures;
private final boolean legacyBroadcast;
public ParticleDelivery(Server server, PlayerFeedbackPreferences preferences, Consumer<Throwable> failures) {
this.server = Objects.requireNonNull(server);
this.preferences = Objects.requireNonNull(preferences);
this.failures = Objects.requireNonNull(failures);
this.legacyBroadcast = false;
}
private ParticleDelivery() {
server = null; preferences = null; failures = ignored -> { }; legacyBroadcast = true;
}
/** Compatibility for isolated effect tests; production constructs the viewer-aware authority. */
public static ParticleDelivery legacyBroadcast() { return new ParticleDelivery(); }
public void world(Particle particle, Location location, int count,
double offsetX, double offsetY, double offsetZ, double extra) {
emitWorld(particle, location, count, offsetX, offsetY, offsetZ, extra, null, false);
}
public void world(Particle particle, Location location, int count,
double offsetX, double offsetY, double offsetZ, double extra, Object data) {
emitWorld(particle, location, count, offsetX, offsetY, offsetZ, extra, data, true);
}
public void viewer(Player viewer, Particle particle, Location location, int count,
double offsetX, double offsetY, double offsetZ, double extra) {
emitViewer(viewer, particle, location, count, offsetX, offsetY, offsetZ, extra, null, false);
}
public void viewer(Player viewer, Particle particle, Location location, int count,
double offsetX, double offsetY, double offsetZ, double extra, Object data) {
emitViewer(viewer, particle, location, count, offsetX, offsetY, offsetZ, extra, data, true);
}
public void aura(Player source) {
Objects.requireNonNull(source);
Location location = source.getLocation().add(0, 1, 0);
var dust = new Particle.DustOptions(Color.BLUE, 1);
if (legacyBroadcast) {
source.getWorld().spawnParticle(Particle.DUST, location, 3, 0.3, 0.4, 0.3, 0, dust);
return;
}
var tracked = source.getTrackedBy();
for (var viewer : List.copyOf(server.getOnlinePlayers())) {
try {
if (!viewer.isOnline() || viewer.getWorld() != source.getWorld()
|| (!viewer.getUniqueId().equals(source.getUniqueId()) && !tracked.contains(viewer))
|| !viewer.canSee(source) || !preferences.auraParticlesEnabled(viewer.getUniqueId())) continue;
viewer.spawnParticle(Particle.DUST, location, 3, 0.3, 0.4, 0.3, 0, dust);
} catch (RuntimeException failure) { report(failure); }
}
}
private void emitWorld(Particle particle, Location location, int count,
double offsetX, double offsetY, double offsetZ, double extra,
Object data, boolean hasData) {
Objects.requireNonNull(particle); Objects.requireNonNull(location);
if (location.getWorld() == null) return;
if (legacyBroadcast) {
if (hasData) location.getWorld().spawnParticle(particle, location, count,
offsetX, offsetY, offsetZ, extra, data);
else location.getWorld().spawnParticle(particle, location, count,
offsetX, offsetY, offsetZ, extra);
return;
}
for (var viewer : List.copyOf(server.getOnlinePlayers())) {
try {
if (!viewer.isOnline() || viewer.getWorld() != location.getWorld()
|| viewer.getLocation().distanceSquared(location) > NATIVE_PARTICLE_RANGE_SQUARED
|| !preferences.allParticlesEnabled(viewer.getUniqueId())) continue;
send(viewer, particle, location, count, offsetX, offsetY, offsetZ, extra, data, hasData);
} catch (RuntimeException failure) { report(failure); }
}
}
private void emitViewer(Player viewer, Particle particle, Location location, int count,
double offsetX, double offsetY, double offsetZ, double extra,
Object data, boolean hasData) {
Objects.requireNonNull(viewer); Objects.requireNonNull(particle); Objects.requireNonNull(location);
if (!legacyBroadcast && (!viewer.isOnline() || location.getWorld() == null
|| viewer.getWorld() != location.getWorld()
|| !preferences.allParticlesEnabled(viewer.getUniqueId()))) return;
try { send(viewer, particle, location, count, offsetX, offsetY, offsetZ, extra, data, hasData); }
catch (RuntimeException failure) { report(failure); }
}
private static void send(Player viewer, Particle particle, Location location, int count,
double offsetX, double offsetY, double offsetZ, double extra,
Object data, boolean hasData) {
if (hasData) viewer.spawnParticle(particle, location, count, offsetX, offsetY, offsetZ, extra, data);
else viewer.spawnParticle(particle, location, count, offsetX, offsetY, offsetZ, extra);
}
private void report(Throwable failure) {
try { failures.accept(failure); } catch (RuntimeException ignored) { }
}
}
@@ -8,9 +8,9 @@ import java.util.Objects;
import java.util.UUID;
/** Validated combat reward arithmetic and location policy; native classification lives at the adapter. */
public record CombatRewardRules(BigInteger passivePool, BigInteger ordinaryPool, BigInteger bossPool,
BigDecimal restrictedSpacing) {
public enum Target { PASSIVE, ORDINARY, BOSS, FANTASY_CHARACTER }
public record CombatRewardRules(BigInteger passivePool, BigInteger ordinaryPool, BigInteger hardPool,
BigInteger bossPool, BigDecimal restrictedSpacing) {
public enum Target { PASSIVE, ORDINARY, HARD, BOSS, FANTASY_CHARACTER }
public static final Duration CONTRIBUTION_LIFETIME = Duration.ofMinutes(5);
public record KillPosition(UUID world, BigDecimal x, BigDecimal y, BigDecimal z) {
@@ -26,16 +26,26 @@ public record CombatRewardRules(BigInteger passivePool, BigInteger ordinaryPool,
}
public CombatRewardRules {
Objects.requireNonNull(passivePool); Objects.requireNonNull(ordinaryPool);
Objects.requireNonNull(passivePool); Objects.requireNonNull(ordinaryPool); Objects.requireNonNull(hardPool);
Objects.requireNonNull(bossPool); Objects.requireNonNull(restrictedSpacing);
if (passivePool.signum() < 0 || ordinaryPool.signum() < 0 || bossPool.signum() < 0
if (passivePool.signum() < 0 || ordinaryPool.signum() < 0 || hardPool.signum() < 0 || bossPool.signum() < 0
|| restrictedSpacing.signum() < 0 || !Double.isFinite(restrictedSpacing.doubleValue())) {
throw new IllegalArgumentException("Combat reward values must be finite and nonnegative");
}
}
public CombatRewardRules(BigInteger passivePool, BigInteger ordinaryPool, BigInteger bossPool,
BigDecimal restrictedSpacing) {
this(passivePool, ordinaryPool, BigInteger.valueOf(20), bossPool, restrictedSpacing);
}
public static CombatRewardRules defaults() {
return new CombatRewardRules(BigInteger.ONE, BigInteger.TEN, BigInteger.valueOf(100), BigDecimal.valueOf(3));
return new CombatRewardRules(BigInteger.ONE, BigInteger.TEN, BigInteger.valueOf(20),
BigInteger.valueOf(100), BigDecimal.valueOf(3));
}
public CombatRewardRules withHardPool(BigInteger pool) {
return new CombatRewardRules(passivePool, ordinaryPool, pool, bossPool, restrictedSpacing);
}
public BigInteger pool(Target target, int defeatedLevel) {
@@ -47,6 +57,7 @@ public record CombatRewardRules(BigInteger passivePool, BigInteger ordinaryPool,
return switch (target) {
case PASSIVE -> passivePool;
case ORDINARY -> ordinaryPool;
case HARD -> hardPool;
case BOSS -> bossPool;
case FANTASY_CHARACTER -> throw new IllegalStateException("Handled fantasy character");
};
@@ -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();
}
}
@@ -25,16 +25,24 @@ public final class AdminExperienceGrant implements AutoCloseable {
private final Consumer<Runnable> nextTick;
private final Function<UUID, Player> players;
private final Consumer<Failure> failures;
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private volatile boolean closed;
public AdminExperienceGrant(CharacterStorage characters, ProgressionRules progression, EquipmentSessions sessions,
Consumer<Runnable> nextTick, Function<UUID, Player> players, Consumer<Failure> failures) {
this(characters, progression, sessions, nextTick, players, failures,
games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());
}
public AdminExperienceGrant(CharacterStorage characters, ProgressionRules progression, EquipmentSessions sessions,
Consumer<Runnable> nextTick, Function<UUID, Player> players, Consumer<Failure> failures,
games.dmg.fantasystick.presentation.ParticleDelivery particles) {
this.characters = Objects.requireNonNull(characters);
this.progression = Objects.requireNonNull(progression);
this.sessions = Objects.requireNonNull(sessions);
this.nextTick = Objects.requireNonNull(nextTick);
this.players = Objects.requireNonNull(players);
this.failures = Objects.requireNonNull(failures);
this.particles = Objects.requireNonNull(particles);
}
/** Returns false before persistence when the captured active identity is no longer authoritative. */
@@ -99,7 +107,8 @@ public final class AdminExperienceGrant implements AutoCloseable {
for (int gained = 1; gained <= levels; gained++) {
int level = saved.level() - levels + gained;
player.sendMessage("Level " + level + "! Gained 1 character point.");
SessionVisuals.burst(player, Color.BLUE, 12);
particles.world(org.bukkit.Particle.DUST, player.getLocation().add(0, 1, 0), 12,
0.3, 0.4, 0.3, 0, new org.bukkit.Particle.DustOptions(Color.BLUE, 1));
player.playSound(player.getLocation(), Sound.ENTITY_PLAYER_LEVELUP, 1, 1);
}
} catch (RuntimeException failure) { report(saved.id(), failure); }
@@ -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;
@@ -69,6 +73,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.persistence.PartyEconomySettings partyEconomySettings;
private games.dmg.fantasystick.persistence.GearSettings gearSettings;
private games.dmg.fantasystick.persistence.CoinSettings coinSettings;
private games.dmg.fantasystick.persistence.HardMobXpSettings hardMobXpSettings;
private games.dmg.fantasystick.persistence.AdventureQuestSettings adventureQuestSettings;
private games.dmg.fantasystick.adventure.AdventureQuestCommands adventureQuestCommands;
private games.dmg.fantasystick.dungeon.DungeonCommands dungeonCommands;
private games.dmg.fantasystick.party.CombatExperience combatExperience;
private games.dmg.fantasystick.persistence.SheetResourcePackSettings sheetResourcePackSettings;
private games.dmg.fantasystick.presentation.SheetResourcePack sheetResourcePack;
private games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings outOfCombatRecoverySettings;
@@ -87,6 +96,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.shop.FantasyArmorerDirectory armorers;
private games.dmg.fantasystick.shop.FantasyArmorerController armorerController;
private games.dmg.fantasystick.shop.ArmorMarket armorMarket;
private games.dmg.fantasystick.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) {
@@ -316,6 +328,17 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
public void attachPartyEconomySettings(games.dmg.fantasystick.persistence.PartyEconomySettings settings){if(closed||partyEconomySettings!=null)throw new IllegalStateException("Party economy settings already attached or command closed");partyEconomySettings=Objects.requireNonNull(settings);}
public void attachGearSettings(games.dmg.fantasystick.persistence.GearSettings settings){if(closed||gearSettings!=null)throw new IllegalStateException("Gear settings already attached or command closed");gearSettings=Objects.requireNonNull(settings);}
public void attachCoinSettings(games.dmg.fantasystick.persistence.CoinSettings settings){if(closed||coinSettings!=null)throw new IllegalStateException("Coin settings already attached or command closed");coinSettings=Objects.requireNonNull(settings);}
public void attachHardMobXpSettings(games.dmg.fantasystick.persistence.HardMobXpSettings settings,
games.dmg.fantasystick.party.CombatExperience experience) {
if (closed || hardMobXpSettings != null) throw new IllegalStateException("Hard-mob XP settings already attached or command closed");
hardMobXpSettings = Objects.requireNonNull(settings); combatExperience = Objects.requireNonNull(experience);
}
public void attachAdventureQuests(games.dmg.fantasystick.adventure.AdventureQuestCommands commands,
games.dmg.fantasystick.persistence.AdventureQuestSettings settings) {
if (closed || adventureQuestCommands != null) throw new IllegalStateException("Adventure quests already attached or command closed");
adventureQuestCommands = Objects.requireNonNull(commands); adventureQuestSettings = Objects.requireNonNull(settings);
}
public void attachDungeons(games.dmg.fantasystick.dungeon.DungeonCommands commands){if(closed||dungeonCommands!=null)throw new IllegalStateException("Dungeons already attached or command closed");dungeonCommands=Objects.requireNonNull(commands);}
public void attachSheetResourcePack(games.dmg.fantasystick.persistence.SheetResourcePackSettings settings,games.dmg.fantasystick.presentation.SheetResourcePack delivery){if(closed||sheetResourcePackSettings!=null)throw new IllegalStateException("Sheet resource pack already attached or command closed");sheetResourcePackSettings=Objects.requireNonNull(settings);sheetResourcePack=Objects.requireNonNull(delivery);}
public void attachOutOfCombatRecovery(games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings settings,
OutOfCombatRecovery recovery) {
@@ -363,6 +386,18 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
characterIssuer = Objects.requireNonNull(issuer);
}
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,
games.dmg.fantasystick.party.PartyRegistrarController controller) {
if (closed || partyRegistrars != null) throw new IllegalStateException("Party Registrars already attached or command closed");
@@ -387,9 +422,28 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (closed) return true;
if (!sender.hasPermission(PERMISSION)) {
sender.sendMessage("You need fantasystick.admin to administer Fantasy Stick.");
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;
}
if (adventureQuestCommands != null && args.length > 0 && args[0].equals("quest-giver")) {
adventureQuestCommands.giver(sender, args); return true;
}
if (args.length > 0 && args[0].equals("character")) {
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;}
if (args.length > 0 && args[0].equals("training-dummy")) {
trainingDummy(sender, args); return true;
@@ -419,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")) {
@@ -471,9 +525,176 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
return true;
}
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: /fstickadmin character <find <name...>|show <character-uuid>|recover <character-uuid> <online-player>|view <online-player>>");
return;
}
var matches = server.getOnlinePlayers().stream()
.filter(org.bukkit.entity.Player::isOnline)
.filter(player -> player.getName().equals(args[2]))
.toList();
if (matches.size() != 1) {
sender.sendMessage("Character view refused: target must exactly name one online player.");
return;
}
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]); }
@@ -485,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()) {
@@ -519,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;}
@@ -534,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.");});});}});}
@@ -570,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,
@@ -651,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();
@@ -691,6 +912,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (partyEconomySettings != null && games.dmg.fantasystick.persistence.PartyEconomySettings.keys().contains(args[1])) { partyEconomyConfiguration(sender,args); return; }
if(gearSettings!=null&&games.dmg.fantasystick.persistence.GearSettings.keys().contains(args[1])){gearConfiguration(sender,args);return;}
if (coinSettings != null && games.dmg.fantasystick.persistence.CoinSettings.keys().contains(args[1])) { coinConfiguration(sender,args); return; }
if (hardMobXpSettings != null && args[1].equals(games.dmg.fantasystick.persistence.HardMobXpSettings.KEY)) {
hardMobXpConfiguration(sender, args); return;
}
if (adventureQuestSettings != null && games.dmg.fantasystick.persistence.AdventureQuestSettings.keys().contains(args[1])) {
adventureQuestConfiguration(sender, args); return;
}
if (args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY)||args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY)) { sheetResourcePackConfiguration(sender,args); return; }
if (args[1].equals(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY)) { adminReviveConfiguration(sender,args); return; }
if (outOfCombatRecoverySettings != null
@@ -1319,9 +1546,43 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void coinConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+coinSettings.value(key)+" (applies to future creation or defeats only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.CoinSettings.Update> operation;try{operation=coinSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Coin setting is invalid; ranges must be ordered whole numbers from 0 through 2147483647.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=failure==null&&coinSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Coin configuration save failed; effective ranges are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+coinSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for future creation or defeats; existing balances and captured rewards are unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void hardMobXpConfiguration(CommandSender sender, String[] args) {
String key = games.dmg.fantasystick.persistence.HardMobXpSettings.KEY;
if (args.length == 2) { sender.sendMessage(key + "=" + hardMobXpSettings.value()
+ " (applies to future uncaptured Witch defeats only)"); return; }
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.HardMobXpSettings.Update> operation;
try { operation = hardMobXpSettings.set(args[2]); }
catch (IllegalArgumentException invalid) {
sender.sendMessage("Hard-mob XP is invalid; enter a whole number from 0 through 2147483647."); return;
}
operation.whenComplete((saved, failure) -> {
if (failure != null) report(null, "config-" + key, failure);
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
boolean applied = failure == null && hardMobXpSettings.publish(saved);
if (applied) combatExperience.configure(combatExperience.rules().withHardPool(saved.pool()));
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
if (failure != null) sender.sendMessage("Hard-mob XP save failed; the effective pool is unchanged.");
else if (!applied) sender.sendMessage("Saved request was superseded; effective " + key + "="
+ hardMobXpSettings.value() + ".");
else sender.sendMessage("Saved " + key + "=" + saved.value()
+ " for future uncaptured Witch defeats; existing progress and captured rewards are unchanged.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void adventureQuestConfiguration(CommandSender sender, String[] args) {
String key=args[1]; if(args.length==2){sender.sendMessage(key+"="+adventureQuestSettings.value(key)+" (applies to the owning later catalog, acceptance or encounter attempt)");return;}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.AdventureQuestSettings.Update> operation;
try{operation=adventureQuestSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Adventure quest setting is invalid; the effective value is unchanged.");return;}
operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=failure==null&&adventureQuestSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Adventure quest configuration save failed; effective values are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+adventureQuestSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for later catalogs, acceptances or attempts; active state is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});
}
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);}});}
@@ -1329,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) {
@@ -1351,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) {
@@ -1361,10 +1622,43 @@ 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 || !sender.hasPermission(PERMISSION)) return List.of();
if (closed) 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("config", "experience", "give", "inspect", "party-registrar", "recover", "revive", "shop", "training-dummy")
: List.of("config", "experience", "give", "inspect", "party-registrar", "recover", "shop", "training-dummy"), args[0]);
? 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")) {
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)
.filter(player -> sessions.active(player.getUniqueId()).filter(sessions::current).isPresent())
.map(org.bukkit.entity.Player::getName).toList(), args[2]);
if(args.length==2&&args[0].equals("shop"))return matching(sender instanceof org.bukkit.entity.Player?List.of("list","place","remove","restock"):List.of("list","remove","restock"),args[1]);
if(args.length==3&&args[0].equals("shop")&&args[1].equals("remove")&&armorers!=null)return matching(armorers.all().stream().map(value->value.id().toString()).toList(),args[2]);
if (args.length == 2 && args[0].equals("training-dummy")) return matching(
@@ -1387,6 +1681,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
keys.addAll(games.dmg.fantasystick.persistence.PartyEconomySettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.GearSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.CoinSettings.keys());
keys.add(games.dmg.fantasystick.persistence.HardMobXpSettings.KEY);
keys.addAll(games.dmg.fantasystick.persistence.AdventureQuestSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.keys());
keys.add(games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY);
keys.add(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY);
@@ -1420,6 +1716,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if(partyEconomySettings!=null&&games.dmg.fantasystick.persistence.PartyEconomySettings.keys().contains(args[1])){String current=partyEconomySettings.value(args[1]);String defaults=games.dmg.fantasystick.persistence.PartyEconomySettings.defaultValue(args[1]);return matching(current.equals(defaults)?List.of(current):List.of(current,defaults),args[2]);}
if(gearSettings!=null&&games.dmg.fantasystick.persistence.GearSettings.keys().contains(args[1])){String current=gearSettings.value(args[1]);String defaults=games.dmg.fantasystick.persistence.GearSettings.defaultValue(args[1]);return matching(current.equals(defaults)?List.of(current):List.of(current,defaults),args[2]);}
if(coinSettings!=null&&games.dmg.fantasystick.persistence.CoinSettings.keys().contains(args[1])){String current=coinSettings.value(args[1]);String defaults=games.dmg.fantasystick.persistence.CoinSettings.defaultValue(args[1]);return matching(current.equals(defaults)?List.of(current):List.of(current,defaults),args[2]);}
if (hardMobXpSettings != null && args[1].equals(games.dmg.fantasystick.persistence.HardMobXpSettings.KEY)) {
String current = hardMobXpSettings.value(); String defaults = games.dmg.fantasystick.persistence.HardMobXpSettings.DEFAULT;
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if(adventureQuestSettings!=null&&games.dmg.fantasystick.persistence.AdventureQuestSettings.keys().contains(args[1])){String current=adventureQuestSettings.value(args[1]);String defaults=games.dmg.fantasystick.persistence.AdventureQuestSettings.defaultValue(args[1]);return matching(current.equals(defaults)?List.of(current):List.of(current,defaults),args[2]);}
if(sheetResourcePackSettings!=null&&(args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY)||args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY)))return matching(List.of(sheetResourcePackSettings.value(args[1])),args[2]);
if (outOfCombatRecoverySettings != null
&& games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.keys().contains(args[1])) {
@@ -1551,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);
@@ -752,7 +757,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
UUID owner = event.getPlayer().getUniqueId();
cancelCustody(owner);
swaps.remove(owner);
sessions.invalidateTail(owner);
sessions.invalidateTail(owner, event.getView());
}
private boolean reservedExchange(Player player, int slot) {
@@ -44,6 +44,7 @@ public final class EquipmentSessions implements AutoCloseable {
private Supplier<Duration> switchDelay;
private java.util.function.BooleanSupplier roleplayDisabled = () -> false;
private java.util.function.Predicate<UUID> pvpDefeat = ignored -> false;
private java.util.function.Predicate<UUID> unavailableCharacter = ignored -> false;
private java.util.function.Function<UUID,java.util.concurrent.CompletionStage<?>> partyDefeat =
ignored -> java.util.concurrent.CompletableFuture.completedStage(null);
private final Clock clock;
@@ -59,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.
@@ -77,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;
@@ -113,7 +117,12 @@ public final class EquipmentSessions implements AutoCloseable {
boolean bookDelivered;
boolean deliveryInvalidated;
boolean conversionReported;
boolean partyDefeatAcknowledged;
boolean tailCallbackQueued;
boolean defeatReturnTeleport;
CharacterRecord deathRecord;
ItemStack deathReference;
Object deathNativeReference;
org.bukkit.inventory.InventoryView deathView;
Attempt(Player player, UUID owner, UUID character) {
this.player = player; this.owner = owner; this.character = character;
@@ -171,6 +180,11 @@ public final class EquipmentSessions implements AutoCloseable {
roleplayDisabled = Objects.requireNonNull(policy);
}
public synchronized void attachUnavailableCharacter(java.util.function.Predicate<UUID> predicate) {
if (!owned.isEmpty()) throw new IllegalStateException("Sessions already started");
unavailableCharacter = Objects.requireNonNull(predicate);
}
public synchronized void attachPvpDefeat(java.util.function.Predicate<UUID> predicate) {
if (!owned.isEmpty()) throw new IllegalStateException("Sessions already started");
pvpDefeat = Objects.requireNonNull(predicate);
@@ -239,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. */
@@ -276,6 +333,44 @@ public final class EquipmentSessions implements AutoCloseable {
? Optional.of(attempt.record) : Optional.empty();
}
/**
* Re-presents only the authentic slot-zero control for a current session. The transformer receives a fresh
* ordinary book control, so stale presentation metadata is never authoritative and construction failure leaves
* the current control untouched.
*/
public synchronized boolean presentBook(Active session, java.util.function.UnaryOperator<ItemStack> transformer) {
Objects.requireNonNull(transformer);
var attempt = active.get(session.player());
if (closed || attempt == null || attempt.record == null || !current(session)) return false;
var currentItem = attempt.player.getInventory().getItem(0);
var currentIdentity = controls.identify(currentItem).orElse(null);
if (currentIdentity == null || currentIdentity.slot() != 0 || !currentIdentity.owner().equals(attempt.owner)
|| !currentIdentity.session().equals(attempt.session)) return false;
ItemStack desired;
try {
desired = Objects.requireNonNull(transformer.apply(controls.create(attempt.record, attempt.owner, attempt.session)[0])).clone();
} catch (RuntimeException failure) {
diagnostic(new Failure(attempt.owner, attempt.character, failure));
return false;
}
var desiredIdentity = controls.identify(desired).orElse(null);
if (desiredIdentity == null || desiredIdentity.slot() != 0 || !desiredIdentity.owner().equals(attempt.owner)
|| !desiredIdentity.session().equals(attempt.session)) return false;
if (Objects.equals(currentItem, desired)) return true;
try {
attempt.player.getInventory().setItem(0, desired);
attempt.player.updateInventory();
return active.get(attempt.owner) == attempt && current(session);
} catch (RuntimeException failure) {
try { attempt.player.getInventory().setItem(0, currentItem); attempt.player.updateInventory(); }
catch (RuntimeException rollback) { failure.addSuppressed(rollback); }
diagnostic(new Failure(attempt.owner, attempt.character, failure));
return false;
}
}
public boolean restoreBook(Active session) { return presentBook(session, java.util.function.UnaryOperator.identity()); }
/** Apply a persisted progression revision to the still-current active session. */
public synchronized boolean acknowledgeProgress(Active session, BigInteger expectedRevision, CharacterRecord saved) {
var attempt = active.get(session.player());
@@ -287,8 +382,8 @@ public final class EquipmentSessions implements AutoCloseable {
if (vitals != null) vitals.updateProgress(attempt.player, attempt.session, previous, saved);
attempt.record = saved; // Persisted truth precedes any reentrant native presentation setter.
if(gear!=null&&!previous.gear().equals(saved.gear()))gear.install(attempt.player.getInventory(),saved,attempt.owner,attempt.session);
if ((!previous.loadout().equals(saved.loadout()) || !previous.cooldowns().equals(saved.cooldowns()))
&& !refreshControls(attempt, session)) return false;
if ((!previous.loadout().equals(saved.loadout()) || !previous.cooldowns().equals(saved.cooldowns())
|| !previous.passives().equals(saved.passives())) && !refreshControls(attempt, session)) return false;
return true;
}
@@ -317,7 +412,7 @@ public final class EquipmentSessions implements AutoCloseable {
private boolean refreshControls(Attempt attempt, Active session) {
var refreshed = controls.create(attempt.record, attempt.owner, attempt.session);
boolean changed = false;
for (int slot = 2; slot < 9; slot++) {
for (int slot = 1; slot < 9; slot++) {
if (active.get(attempt.owner) != attempt || !current(session)) return false;
if (Objects.equals(attempt.player.getInventory().getItem(slot), refreshed[slot])) continue;
attempt.player.getInventory().setItem(slot, refreshed[slot]);
@@ -354,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();
@@ -373,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;
@@ -382,16 +481,31 @@ public final class EquipmentSessions implements AutoCloseable {
}
if (active.containsKey(owner)) return;
cancelEntry(owner);
if (owned.containsKey(owner)) {
var previous = owned.get(owner);
if (previous != null && previous.defeated) {
retryTail(player, previous);
return;
}
if (previous != null) {
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;
}
var attempt = new Attempt(player, owner, 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);
attempt.sheetIdentity = identity.orElseThrow();
if (adventureMode.getAsBoolean() && player.getGameMode() != org.bukkit.GameMode.ADVENTURE) {
attempt.previousGameMode = player.getGameMode().name();
}
@@ -442,9 +556,17 @@ public final class EquipmentSessions implements AutoCloseable {
}
if (failure != null) { fail(attempt, failure); return; }
var record = loaded.character().orElse(null);
if (unavailableCharacter.test(attempt.character)) {
refuse(attempt, "CHARACTER_IN_CUSTODY", "That character was assigned to a dungeon boss before activation completed."); return;
}
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) {
@@ -454,8 +576,8 @@ public final class EquipmentSessions implements AutoCloseable {
if (!state.canEnter(attempt.character, switchDelay.get(), now)) {
Duration remaining = Duration.between(now, state.lastExit().deadline());
long seconds = remaining.getSeconds() + (remaining.getNano() == 0 ? 0 : 1);
refuse(attempt, "COOLDOWN", "Wait " + seconds + " seconds to switch characters (until "
+ state.lastExit().deadline() + "); the same character may be resumed now."); return;
refuse(attempt, "COOLDOWN", "Wait " + seconds
+ " seconds to switch characters; the same character may be resumed now."); return;
}
}
if (!entryNativeSafe(attempt, loaded.state().orElse(null))) {
@@ -622,30 +744,35 @@ public final class EquipmentSessions implements AutoCloseable {
if (failure != null) { diagnostic(attempt, failure); conversionUncertain(attempt, "Death save failed or is uncertain"); return; }
if (dead.isEmpty()) { conversionUncertain(attempt, "Character was missing or already dead; no new Tail authorized"); return; }
attempt.deathResolved = true;
attempt.deathRecord = dead.orElseThrow();
if (closed) { conversionUncertain(attempt, "Shutdown before party defeat and Tail conversion"); return; }
java.util.concurrent.CompletionStage<?> partyMutation;
try { partyMutation = partyDefeat.apply(attempt.character); }
catch (RuntimeException error) { diagnostic(attempt,error); conversionUncertain(attempt,"Party defeat mutation rejected"); return; }
partyMutation.whenComplete((ignored,partyFailure) -> {
if (partyFailure != null) { diagnostic(attempt,partyFailure); conversionUncertain(attempt,"Party defeat mutation failed"); return; }
if (closed) { conversionUncertain(attempt,"Shutdown before Tail conversion"); return; }
try {
nextTick.accept(() -> {
synchronized (EquipmentSessions.this) { deliverTail(attempt, dead.orElseThrow()); }
});
} catch (RuntimeException error) { diagnostic(attempt, error); conversionUncertain(attempt, "Tail callback rejected"); }
synchronized (EquipmentSessions.this) {
if (partyFailure != null) { diagnostic(attempt,partyFailure); conversionUncertain(attempt,"Party defeat mutation failed"); return; }
if (closed) { conversionUncertain(attempt,"Shutdown before Tail conversion"); return; }
attempt.partyDefeatAcknowledged = true;
queueTail(attempt);
}
});
});
try {
attempt.deathReference = copy(attempt.player.getInventory().getItemInOffHand());
attempt.deathView = attempt.player.getOpenInventory();
restoreExit(attempt);
var restored = restoreExit(attempt);
if (restored == EquipmentStorage.RestoreResult.RESTORED) {
var retained = attempt.player.getInventory().getItemInOffHand();
attempt.deathReference = copy(retained);
attempt.deathNativeReference = nativeIdentity(retained);
attempt.deathView = attempt.player.getOpenInventory();
}
// Read the current maximum after owned overlay cleanup, including legitimate
// ordinary modifiers. Never reconstruct attributes from an entry snapshot.
double maximum = Objects.requireNonNull(attempt.player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).getValue();
event.setReviveHealth(maximum);
attempt.player.setHealth(maximum); // Native cancellation only fills health when <= 0.
notice(attempt.owner, "DEFEAT", "§cYour character was defeated.");
attempt.defeatReturnTeleport = true;
try {
if (!attempt.player.teleport(attempt.entryLocation.clone())) {
notice(attempt.owner, "DEFEAT_RETURN_FAILED", "Return to the session entry location was refused; you remain here.");
@@ -653,6 +780,10 @@ public final class EquipmentSessions implements AutoCloseable {
} catch (RuntimeException failure) {
diagnostic(attempt, failure);
notice(attempt.owner, "DEFEAT_RETURN_FAILED", "Return to the session entry location failed; you remain here.");
} finally {
attempt.defeatReturnTeleport = false;
if (!attempt.deliveryInvalidated && owned.get(attempt.owner) == attempt && attempt.player.isOnline())
attempt.deathView = attempt.player.getOpenInventory();
}
} catch (RuntimeException failure) {
diagnostic(attempt, failure);
@@ -676,21 +807,66 @@ public final class EquipmentSessions implements AutoCloseable {
if (attempt != null && attempt.defeated) attempt.deliveryInvalidated = true;
}
private void deliverTail(Attempt attempt, CharacterRecord dead) {
if (attempt.bookAttempted) return;
attempt.bookAttempted = true; // Even an exception after a native write never permits replay.
/** The exact view closed by this plugin's defeat-return teleport may be rebound after teleport. */
public synchronized void invalidateTail(UUID owner, org.bukkit.inventory.InventoryView closedView) {
var attempt = owned.get(owner);
if (attempt == null || !attempt.defeated) return;
if (attempt.defeatReturnTeleport && attempt.deathView == closedView) {
attempt.deathView = null;
return;
}
attempt.deliveryInvalidated = true;
}
private void queueTail(Attempt attempt) {
if (closed || attempt.tailCallbackQueued || attempt.bookAttempted || attempt.bookDelivered
|| attempt.deliveryInvalidated || !attempt.deathResolved || !attempt.partyDefeatAcknowledged
|| !attempt.gearAcknowledged || attempt.deathRecord == null) return;
attempt.tailCallbackQueued = true;
try {
if (!tailContext(attempt)
|| !Objects.equals(attempt.deathReference, copy(attempt.player.getInventory().getItemInOffHand()))
|| !sheets.identify(attempt.deathReference).filter(attempt.character::equals).isPresent()
|| attempt.deathReference.getAmount() < 1
|| attempt.deathReference.getAmount() > attempt.deathReference.getMaxStackSize()) {
nextTick.accept(() -> {
synchronized (EquipmentSessions.this) {
attempt.tailCallbackQueued = false;
deliverTail(attempt, attempt.deathRecord);
}
});
} catch (RuntimeException error) {
attempt.tailCallbackQueued = false;
conversionNotStarted(attempt, "Tail callback was rejected before its native write ("
+ error.getClass().getSimpleName() + ")");
}
}
private void retryTail(Player player, Attempt attempt) {
if (attempt.player != player || !player.isOnline() || attempt.bookAttempted || attempt.bookDelivered
|| attempt.deliveryInvalidated || !tailCustody(attempt)) {
attempt.deliveryInvalidated = true;
notice(attempt.owner, "DEFEAT_INSPECTION_REQUIRED",
"Death or Tail custody is ambiguous; items were retained for administrator inspection.");
return;
}
if (!attempt.deathResolved || !attempt.partyDefeatAcknowledged || !attempt.gearAcknowledged
|| attempt.deathRecord == null) {
notice(attempt.owner, "DEFEAT_PENDING",
"Death persistence, restoration or Tail prerequisites are still pending; no item was changed.");
return;
}
attempt.tailCallbackQueued = false;
deliverTail(attempt, attempt.deathRecord);
}
private void deliverTail(Attempt attempt, CharacterRecord dead) {
if (attempt.bookAttempted || attempt.bookDelivered) return;
try {
if (!tailContext(attempt) || !tailCustody(attempt)) {
conversionUncertain(attempt, "Tail destination/context is stale or equipment restoration is unresolved"); return;
}
// Constructing a book cannot mutate native inventory and remains retryable if it fails.
var book = new games.dmg.fantasystick.items.TailBooks().create(dead);
attempt.bookAttempted = true; // From this point an exception may follow a native write; never replay.
// An exact captured stack of copied references represents ONE character, not N
// deaths. Replace that whole stack with ONE book; never scan/delete other copies,
// move items, overwrite a changed stack, or grant into an available slot.
var book = new games.dmg.fantasystick.items.TailBooks().create(dead);
attempt.player.getInventory().setItemInOffHand(book);
if (!tailContext(attempt) || !book.equals(attempt.player.getInventory().getItemInOffHand())) {
conversionUncertain(attempt, "Context changed during Tail write; delivery may already have happened"); return;
@@ -699,11 +875,25 @@ public final class EquipmentSessions implements AutoCloseable {
release(attempt);
sync(attempt);
} catch (RuntimeException failure) {
diagnostic(attempt, failure);
conversionUncertain(attempt, "Tail native write failed or is uncertain; never replay");
if (attempt.bookAttempted) {
diagnostic(attempt, failure);
conversionUncertain(attempt, "Tail native write failed or is uncertain; never replay");
} else conversionNotStarted(attempt, "Tail construction failed before its native write ("
+ failure.getClass().getSimpleName() + ")");
}
}
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
&& authority != null && sheets.authorizes(attempt.deathReference, authority)
&& attempt.deathReference.getAmount() >= 1
&& attempt.deathReference.getAmount() <= attempt.deathReference.getMaxStackSize();
}
private boolean tailContext(Attempt attempt) {
if (closed || attempt.deliveryInvalidated || owned.get(attempt.owner) != attempt
|| !attempt.player.isOnline() || attempt.player.getOpenInventory() != attempt.deathView) return false;
@@ -712,6 +902,11 @@ public final class EquipmentSessions implements AutoCloseable {
&& marker.phase() == EquipmentSessionMarker.Phase.RESTORED;
}
private void conversionNotStarted(Attempt attempt, String reason) {
diagnostic(attempt, new IllegalStateException(reason + "; character " + attempt.character
+ ". The exact same-lifetime retained sheet may retry after acknowledged prerequisites."));
}
private void conversionUncertain(Attempt attempt, String reason) {
if (attempt.conversionReported) return;
attempt.conversionReported = true;
@@ -853,8 +1048,18 @@ public final class EquipmentSessions implements AutoCloseable {
? receipts.completeExit(attempt.owner, attempt.session, attempt.exitDelay)
: receipts.recordExit(attempt.owner, attempt.session, attempt.exitDelay), (state, failure) -> {
attempt.saving = false;
if (failure != null) diagnostic(attempt, failure);
else if (complete) { attempt.gearAcknowledged = true; release(attempt); }
if (failure != null) {
diagnostic(attempt, failure);
if (attempt.defeated) conversionUncertain(attempt,
"Equipment restoration persistence failed before Tail conversion");
} else if (complete) {
attempt.gearAcknowledged = true;
if (attempt.defeated) queueTail(attempt);
release(attempt);
} else if (attempt.defeated) {
conversionUncertain(attempt,
"Equipment restoration was not acknowledged; Tail conversion remains unsafe");
}
});
}
@@ -899,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;
}
@@ -948,6 +1154,9 @@ public final class EquipmentSessions implements AutoCloseable {
});
} catch (RuntimeException failure) { fail(attempt, failure); }
}
private static Object nativeIdentity(ItemStack item) {
return item == null || item.isEmpty() ? null : org.bukkit.craftbukkit.inventory.CraftItemStack.unwrap(item);
}
private static ItemStack copy(ItemStack item) { return item == null ? null : item.clone(); }
private static ItemStack[] snapshot(Player player) {
return Arrays.stream(player.getInventory().getContents()).map(EquipmentSessions::copy).toArray(ItemStack[]::new);
@@ -1064,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,98 @@
package games.dmg.fantasystick.session;
import games.dmg.fantasystick.persistence.PlayerFeedbackPreferences;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabExecutor;
import org.bukkit.entity.Player;
/** Ordinary-player command for durable account-owned Fantasy Stick particle preferences. */
public final class FantasyParticlesCommand implements TabExecutor, AutoCloseable {
private final PlayerFeedbackPreferences preferences;
private final Consumer<Runnable> nextTick;
private final Consumer<Throwable> failures;
private boolean closed;
public FantasyParticlesCommand(PlayerFeedbackPreferences preferences, Consumer<Runnable> nextTick,
Consumer<Throwable> failures) {
this.preferences = Objects.requireNonNull(preferences);
this.nextTick = Objects.requireNonNull(nextTick);
this.failures = Objects.requireNonNull(failures);
}
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Fantasy particles are a player-account preference.");
return true;
}
if (args.length == 1 && (args[0].equals("players") || args[0].equals("all"))) {
player.sendMessage(status(player.getUniqueId(), args[0]));
return true;
}
if (args.length != 2 || !(args[0].equals("players") || args[0].equals("all"))
|| !(args[1].equals("on") || args[1].equals("off"))) return false;
String route = args[0];
boolean enabled = args[1].equals("on");
UUID account = player.getUniqueId();
var save = route.equals("players")
? preferences.setPlayerAuras(account, enabled) : preferences.setAllParticles(account, enabled);
save.whenComplete((saved, failure) -> {
if (failure != null) report(failure);
try {
nextTick.accept(() -> {
if (closed) return;
if (failure != null) {
if (player.isOnline()) player.sendMessage(
(route.equals("players") ? "Active-character aura" : "All-particles")
+ " preference could not be saved; " + status(account, route));
return;
}
boolean published = preferences.publish(saved);
if (player.isOnline()) player.sendMessage(acknowledgement(account, route, published));
});
} catch (RuntimeException rejected) { report(rejected); }
});
return true;
}
@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("all", "players"), args[0]);
if (args.length == 2 && (args[0].equals("players") || args[0].equals("all")))
return matching(List.of("off", "on"), args[1]);
return List.of();
}
private String status(UUID account, String route) {
if (route.equals("all"))
return "All Fantasy Stick particles are " + state(preferences.allParticlesEnabled(account)) + ".";
boolean saved = preferences.playerAurasEnabled(account);
if (saved && !preferences.allParticlesEnabled(account))
return "Active-character aura particles are on (effectively off while all particles are off).";
return "Active-character aura particles are " + state(saved) + ".";
}
private String acknowledgement(UUID account, String route, boolean published) {
if (route.equals("all")) return "All Fantasy Stick particles are "
+ (published ? "now " : "already ") + state(preferences.allParticlesEnabled(account)) + ".";
boolean saved = preferences.playerAurasEnabled(account);
String prefix = "Active-character aura particles are " + (published ? "now " : "already ") + state(saved);
if (saved && !preferences.allParticlesEnabled(account))
return prefix + " (effectively off while all particles are off).";
return prefix + ".";
}
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();
}
private static String state(boolean enabled) { return enabled ? "on" : "off"; }
private void report(Throwable failure) { try { failures.accept(failure); } catch (RuntimeException ignored) { } }
@Override public void close() { closed = 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();
}
}
@@ -11,28 +11,34 @@ import org.bukkit.scheduler.BukkitTask;
/** Public particles only; never grants gameplay state, protection, health or persistence authority. */
public final class SessionVisuals implements AutoCloseable {
private final EquipmentSessions sessions;
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private final Consumer<Throwable> failures;
private BukkitTask task;
private boolean closed;
public SessionVisuals(EquipmentSessions sessions, Consumer<Throwable> failures) {
this.sessions = Objects.requireNonNull(sessions); this.failures = Objects.requireNonNull(failures);
public SessionVisuals(EquipmentSessions sessions,
games.dmg.fantasystick.presentation.ParticleDelivery particles,
Consumer<Throwable> failures) {
this.sessions = Objects.requireNonNull(sessions);
this.particles = Objects.requireNonNull(particles);
this.failures = Objects.requireNonNull(failures);
}
public void install(Plugin plugin) {
if (closed || task != null) throw new IllegalStateException("Visual lifetime already used");
task = plugin.getServer().getScheduler().runTaskTimer(plugin, () -> {
if (closed) return;
for (var player : plugin.getServer().getOnlinePlayers()) {
for (var source : java.util.List.copyOf(plugin.getServer().getOnlinePlayers())) {
try {
var active = sessions.active(player.getUniqueId());
if (active.isPresent() && sessions.current(active.orElseThrow())) burst(player, Color.BLUE, 3);
var active = sessions.active(source.getUniqueId());
if (active.isPresent() && sessions.current(active.orElseThrow())) particles.aura(source);
} catch (RuntimeException failure) { failures.accept(failure); }
}
}, 1L, 10L);
}
public static void burst(Player player, Color color, int count) {
player.getWorld().spawnParticle(Particle.DUST, player.getLocation().add(0, 1, 0), count,
0.3, 0.4, 0.3, 0, new Particle.DustOptions(color, 1));
games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast().world(Particle.DUST,
player.getLocation().add(0, 1, 0), count, 0.3, 0.4, 0.3, 0,
new Particle.DustOptions(color, 1));
}
@Override public void close() {
closed = true;
@@ -28,6 +28,7 @@ public final class TrainingDummyController implements Listener, TrainingDamage,
private final NamespacedKey idKey;
private final NamespacedKey typeKey;
private final java.util.Set<Entity> relocating = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
private final java.util.Set<Zombie> loaded = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
private Attempt pending;
private boolean closed;
@@ -64,14 +65,18 @@ public final class TrainingDummyController implements Listener, TrainingDamage,
for (var entity : chunk.getEntities()) {
if (!tagged(entity)) continue;
UUID id = id(entity); var target = id == null ? null : desired.get(id);
if (!(entity instanceof Zombie zombie) || target == null || !retained.add(id)) { entity.remove(); continue; }
if (!(entity instanceof Zombie zombie) || target == null || !retained.add(id)) {
entity.remove();
if (entity instanceof Zombie zombie) loaded.remove(zombie);
continue;
}
configure(zombie, target);
var current = zombie.getLocation(); var expected = location(target);
if (current.distanceSquared(expected) > 0.000001 || Math.abs(current.getYaw() - expected.getYaw()) > 0.001
|| Math.abs(current.getPitch() - expected.getPitch()) > 0.001) {
relocating.add(zombie); boolean moved;
try { moved = zombie.teleport(expected); } finally { relocating.remove(zombie); }
if (!moved) { zombie.remove(); spawn(target); }
if (!moved) { zombie.remove(); loaded.remove(zombie); spawn(target); }
}
}
for (var value : desired.values()) if (!retained.contains(value.id())) spawn(value);
@@ -80,7 +85,10 @@ public final class TrainingDummyController implements Listener, TrainingDamage,
public void removeLoaded(UUID id) {
for (var world : plugin.getServer().getWorlds()) {
var entities = world.getEntities(); if (entities != null) for (var entity : entities) {
if (tagged(entity) && id.equals(id(entity))) entity.remove();
if (tagged(entity) && id.equals(id(entity))) {
entity.remove();
if (entity instanceof Zombie zombie) loaded.remove(zombie);
}
}
}
}
@@ -111,8 +119,10 @@ public final class TrainingDummyController implements Listener, TrainingDamage,
private void configure(Zombie zombie, TrainingDummy value) {
zombie.getPersistentDataContainer().set(typeKey, PersistentDataType.STRING, TYPE);
zombie.getPersistentDataContainer().set(idKey, PersistentDataType.STRING, value.id().toString());
loaded.add(zombie);
zombie.setAI(false); zombie.setAware(false); zombie.setGravity(false); zombie.setSilent(true);
zombie.setInvulnerable(false); zombie.setCollidable(false); zombie.setCanPickupItems(false);
zombie.setInvulnerable(false); zombie.setCollidable(true); refreshPushExemptions(zombie);
zombie.setCanPickupItems(false);
zombie.setLeashHolder(null); zombie.setRemoveWhenFarAway(false); zombie.setPersistent(true);
zombie.setShouldBurnInDay(false); zombie.setBaby(false); zombie.setCanBreakDoors(false);
zombie.setFireTicks(0); zombie.setFreezeTicks(0); zombie.setVelocity(new org.bukkit.util.Vector());
@@ -121,6 +131,29 @@ public final class TrainingDummyController implements Listener, TrainingDamage,
zombie.setCustomNameVisible(true);
}
private void refreshPushExemptions(Zombie dummy) {
var exemptions = dummy.getCollidableExemptions();
exemptions.clear();
var world = dummy.getLocation().getWorld();
if (world == null) return;
var entities = world.getEntities();
if (entities == null) return;
for (var entity : entities) {
if (entity != dummy && entity instanceof LivingEntity) exemptions.add(entity.getUniqueId());
}
}
private void updatePushExemption(Entity entity, boolean present) {
if (!(entity instanceof LivingEntity)) return;
var world = entity.getWorld();
if (world == null) return;
for (var dummy : java.util.List.copyOf(loaded)) {
if (dummy == entity || !world.equals(dummy.getWorld()) || !managed(dummy)) continue;
if (present) dummy.getCollidableExemptions().add(entity.getUniqueId());
else dummy.getCollidableExemptions().remove(entity.getUniqueId());
}
}
private Location location(TrainingDummy value) {
return new Location(plugin.getServer().getWorld(value.world()), value.x(), value.y(), value.z(), value.yaw(), value.pitch());
}
@@ -180,9 +213,18 @@ public final class TrainingDummyController implements Listener, TrainingDamage,
}
@EventHandler(priority = EventPriority.MONITOR)
public void chunk(org.bukkit.event.world.ChunkLoadEvent event) { reconcile(event.getChunk()); }
@EventHandler(priority = EventPriority.MONITOR)
public void entityAdded(com.destroystokyo.paper.event.entity.EntityAddToWorldEvent event) {
updatePushExemption(event.getEntity(), true);
}
@EventHandler(priority = EventPriority.MONITOR)
public void entityRemoved(com.destroystokyo.paper.event.entity.EntityRemoveFromWorldEvent event) {
if (event.getEntity() instanceof Zombie zombie) loaded.remove(zombie);
updatePushExemption(event.getEntity(), false);
}
@Override public void close() {
closed = true; pending = null;
closed = true; pending = null; loaded.clear();
for (var world : plugin.getServer().getWorlds()) {
var entities = world.getEntities(); if (entities != null) for (var entity : entities) if (tagged(entity)) entity.remove();
}
+37 -20
View File
@@ -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
@@ -56,11 +56,28 @@ boss-coin-drop-max: 50
# Character-bound combat rewards. Restricted hostile/neutral/boss history uses mob death positions.
passive-xp: 1
ordinary-xp: 10
hard-mob-xp: 20
boss-xp: 100
restricted-kill-spacing-blocks: 3
# UTC-daily shared adventure catalogs, camp attempts and per-unit completion rewards.
adventure-daily-site-count: 3
adventure-site-radius: 16
adventure-site-reset-seconds: 30
# Maximum inclusive 3D block-center distance linking non-overlapping dungeon rooms.
dungeon-room-link-distance: 10
adventure-camp-enemies: 8
adventure-camp-reward-xp: 100
adventure-camp-reward-coins: 20
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
@@ -72,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
@@ -82,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
@@ -92,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
@@ -117,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
@@ -157,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
@@ -165,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
@@ -174,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
@@ -184,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
@@ -194,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 -6
View File
@@ -6,13 +6,13 @@ depend: [ProtocolLib]
description: Transferable characters, native fantasy health, recoverable equipment, character books and parties.
author: dmg.games
commands:
fantasystick:
description: Issue or revive sheets, grant fantasy experience, recover sessions or configure Fantasy Stick.
usage: /fantasystick <give|revive> <online-player> | experience give <active-character-name...> <amount> | <inspect|recover> <player-uuid> | config <setting> [value] | shop <place [name...]|remove <shop-uuid>|list|restock> | party-registrar <place [name...]|remove <registrar-uuid>|list> | training-dummy <place [name...]|remove <dummy-uuid>|list>
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
fantasyfeedback:
description: Read or change routine Fantasy Stick combat and ability feedback for your player account.
usage: /fantasyfeedback [on|off]
permissions:
fantasystick.admin:
description: Issue/revive sheets, grant fantasy experience, recover sessions and configure fantasy mode.
@@ -135,7 +135,7 @@ class AbilityEngineNativeTest {
var registry = (AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
var effects = new AtomicInteger();
registry.register(new AbilityDefinition("test_focus", "Test Focus", Material.AMETHYST_SHARD,
List.of("A registered test effect"), "Use the equipped shard", Map.of(), BigInteger.ONE,
List.of("A registered test effect"), "Use the equipped shard", Map.of(), BigInteger.ONE, AbilityDefinition.Category.BUF,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, Duration.ofSeconds(4),
Map.of("range", BigDecimal.valueOf(12)), AbilityDefinition.TargetPolicy.SELF,
execution -> { assertTrue(execution.stillCurrent()); effects.incrementAndGet(); }));
@@ -206,7 +206,7 @@ class AbilityEngineNativeTest {
private static AbilityDefinition definition(String id, AbilityDefinition.Activation activation,
AbilityDefinition.TargetPolicy targetPolicy, games.dmg.fantasystick.ability.AbilityEffect effect) {
return new AbilityDefinition(id, id, Material.AMETHYST_SHARD, List.of("Test definition"), "Use test input",
Map.of(), BigInteger.ONE, activation, ControlInput.Gesture.USE, Duration.ZERO,
Map.of(), BigInteger.ONE, AbilityDefinition.Category.ATK, activation, ControlInput.Gesture.USE, Duration.ZERO,
Map.of(), targetPolicy, effect);
}
@@ -36,16 +36,19 @@ class AbilityRegistryNativeTest {
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "UPPER", valid.requirements(), valid.parameters(), valid.cooldown())),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "", valid.requirements(), valid.parameters(), valid.cooldown())),
() -> assertThrows(IllegalArgumentException.class, () -> new AbilityDefinition("x", " ", Material.STONE,
List.of("Lore"), "Use", Map.of(Stat.INT, BigInteger.TEN), BigInteger.ONE,
List.of("Lore"), "Use", Map.of(Stat.INT, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, Duration.ZERO,
Map.of(), AbilityDefinition.TargetPolicy.HOSTILE, ignored -> {})),
() -> assertThrows(NullPointerException.class, () -> new AbilityDefinition("category", "Category", Material.STONE,
List.of("Lore"), "Use", Map.of(), BigInteger.ONE, null, AbilityDefinition.Activation.IMMEDIATE,
ControlInput.Gesture.USE, Duration.ZERO, Map.of(), AbilityDefinition.TargetPolicy.SELF, ignored -> {})),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "low", Map.of(Stat.INT, BigInteger.valueOf(9)), Map.of(), Duration.ZERO)),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "negative", valid.requirements(), Map.of("range", BigDecimal.valueOf(-1)), Duration.ZERO)),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "infinite", valid.requirements(), Map.of("range", new BigDecimal("1E1000000")), Duration.ZERO)),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "cooldown", valid.requirements(), Map.of(), Duration.ofSeconds(-1))),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "long_cooldown", valid.requirements(), Map.of(), Duration.ofDays(366))),
() -> assertThrows(IllegalArgumentException.class, () -> new AbilityDefinition("air", "Air", Material.AIR,
List.of("Lore"), "Use", Map.of(), BigInteger.ONE, AbilityDefinition.Activation.IMMEDIATE,
List.of("Lore"), "Use", Map.of(), BigInteger.ONE, AbilityDefinition.Category.ATK, AbilityDefinition.Activation.IMMEDIATE,
ControlInput.Gesture.USE, Duration.ZERO, Map.of(), AbilityDefinition.TargetPolicy.SELF, ignored -> {}))
);
}
@@ -68,7 +71,7 @@ class AbilityRegistryNativeTest {
for (var policy : AbilityDefinition.TargetPolicy.values()) {
var base = definition("x");
assertDoesNotThrow(() -> new AbilityDefinition("a_" + activation.name().toLowerCase() + "_" + policy.name().toLowerCase(),
base.name(), base.icon(), base.lore(), base.instructions(), base.requirements(), base.pointCost(), activation,
base.name(), base.icon(), base.lore(), base.instructions(), base.requirements(), base.pointCost(), base.category(), activation,
base.gesture(), base.cooldown(), base.parameters(), policy, base.effect()));
}
}
@@ -76,7 +79,7 @@ class AbilityRegistryNativeTest {
static AbilityDefinition definition(String id) {
return new AbilityDefinition(id, "Test Bolt", Material.AMETHYST_SHARD, List.of("A focused test effect"),
"Use the equipped control", Map.of(Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE,
"Use the equipped control", Map.of(Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.ATK,
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.USE, Duration.ofSeconds(4),
Map.of("range", BigDecimal.valueOf(12), "duration", BigDecimal.valueOf(5)),
AbilityDefinition.TargetPolicy.HOSTILE, ignored -> {});
@@ -85,6 +88,6 @@ class AbilityRegistryNativeTest {
private static AbilityDefinition copy(AbilityDefinition value, String id, Map<Stat, BigInteger> requirements,
Map<String, BigDecimal> parameters, Duration cooldown) {
return new AbilityDefinition(id, value.name(), value.icon(), value.lore(), value.instructions(), requirements,
value.pointCost(), value.activation(), value.gesture(), cooldown, parameters, value.targetPolicy(), value.effect());
value.pointCost(), value.category(), value.activation(), value.gesture(), cooldown, parameters, value.targetPolicy(), value.effect());
}
}
@@ -0,0 +1,366 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import net.minecraft.network.chat.ClickEvent;
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;
/** Actual registered admin command through authoritative storage and native virtual-book output. */
class AdminCharacterBookNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredCommandOpensTheTargetsAuthoritativeReadOnlyBook() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
admin.accountName = "Admin";
target.accountName = "Target";
fixture.players.put(admin.core.id, admin.player);
fixture.players.put(target.core.id, target.player);
fixture.activate(target);
target.core.health = 7.0;
org.bukkit.inventory.ItemStack[] adminInventory = admin.core.snapshot();
org.bukkit.inventory.ItemStack[] targetInventory = target.core.snapshot();
Path character = data.resolve("characters").resolve(target.core.record.id() + ".yml");
byte[] savedCharacter = Files.readAllBytes(character);
assertTrue(fixture.command.execute(admin.player, "fantasystick",
new String[]{"character", "view", "Target"}));
assertTrue(admin.books.isEmpty(), "Authoritative storage must complete before opening");
assertTrue(admin.messages.isEmpty(), admin.messages::toString);
var controller = FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "books");
assertEquals(1, ((java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(controller,
"adminPending")).size());
characters(fixture).find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
fixture.tick();
assertEquals(1, admin.books.size());
assertTrue(target.books.isEmpty(), "Inspection must not interrupt the target");
var pages = nativePages(admin);
String text = pages.stream().map(Component::getString)
.collect(java.util.stream.Collectors.joining("\n"));
assertTrue(text.replace("\n", "").contains(target.core.record.name()));
assertTrue(text.contains("Health: 7/10"), text);
assertTrue(text.contains("Active abilities"));
assertTrue(text.contains("Passive abilities"));
assertTrue(text.contains("Party"));
assertFalse(text.contains("[+1") || text.contains("[Learn]") || text.contains("[Unlearn]"));
assertTrue(pages.stream().allMatch(AdminCharacterBookNativeTest::onlyNativePageLinks));
dispatchCustom(fixture, admin, "fantasystick:character_stat",
"\"" + UUID.randomUUID() + ":STR\"");
dispatchCustom(fixture, admin, "fantasystick:ability_unlock",
"\"" + UUID.randomUUID() + ":fireball\"");
assertArrayEquals(adminInventory, admin.core.snapshot());
assertArrayEquals(targetInventory, target.core.snapshot());
assertArrayEquals(savedCharacter, Files.readAllBytes(character));
}
}
@Test void largeAuthoritativeRecordsRemainCompleteThroughBoundedReadOnlyNavigation() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data); admin.accountName = "Admin";
var target = new FantasyStickLifecycleNativeTest.Actor(data); target.accountName = "Target";
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(target.core.record.id()).orElseThrow();
var stats = new java.util.EnumMap<>(old.stats());
String digits = "1234567890".repeat(4_000);
stats.put(games.dmg.fantasystick.progression.Stat.STR, new java.math.BigInteger(digits));
var cooldowns = new java.util.HashMap<>(old.cooldowns());
cooldowns.put("unknown_saved", java.time.Instant.parse("2030-01-02T03:04:05Z"));
var large = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(),
old.revision().add(java.math.BigInteger.ONE), stats, old.level(), old.experience(),
old.unspentPoints(), old.health(), java.util.Set.of("unknown_saved"),
java.util.List.of("unknown_saved", "", "", "", "", "", ""), cooldowns,
old.restrictedKills(), false, null, null);
repository.replace(large, old.revision());
fixture.players.put(admin.core.id, admin.player);
fixture.players.put(target.core.id, target.player);
fixture.activate(target);
Path character = data.resolve("characters").resolve(target.core.record.id() + ".yml");
byte[] savedCharacter = Files.readAllBytes(character);
org.bukkit.inventory.ItemStack[] adminInventory = admin.core.snapshot();
org.bukkit.inventory.ItemStack[] targetInventory = target.core.snapshot();
fixture.command.execute(admin.player, "fantasystick",
new String[]{"character", "view", "Target"});
drainReads(fixture); runQueued(fixture);
var all = new StringBuilder();
var content = new StringBuilder();
int volumes = 0;
while (true) {
volumes++;
var pages = nativePages(admin);
assertTrue(pages.size() <= 100, "Every administrator view must stay bounded");
pages.forEach(page -> {
String text = page.getString();
all.append(text);
if (!text.startsWith("Character pages\n\n")) content.append(text);
});
var next = customLinkOrNull(pages, "[Next pages]");
if (next == null) break;
int before = admin.books.size();
PartyBookNativeTest.click(fixture, admin, next);
if (volumes == 1) PartyBookNativeTest.click(fixture, admin, next);
drainReads(fixture); runQueued(fixture);
assertEquals(before + 1, admin.books.size(), "Repeated navigation payloads are single-use");
}
assertTrue(volumes > 1);
assertTrue(content.toString().replace("\n", "").contains("STR: " + digits + "DEX:"),
"Every persisted stat digit must remain reachable in order across bounded volumes");
assertTrue(all.toString().contains("Gear"));
assertTrue(all.toString().contains("Active abilities"));
assertTrue(all.toString().contains("unknown_saved"));
assertTrue(all.toString().contains("Party"));
assertArrayEquals(adminInventory, admin.core.snapshot());
assertArrayEquals(targetInventory, target.core.snapshot());
assertArrayEquals(savedCharacter, Files.readAllBytes(character));
}
}
@Test void accountNameSelectionSupportsSelfAndDuplicateFantasyNames() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data); admin.accountName = "Admin";
var other = new FantasyStickLifecycleNativeTest.Actor(data); other.accountName = "Other";
fixture.players.put(admin.core.id, admin.player);
fixture.players.put(other.core.id, other.player);
var storage = characters(fixture);
var renamedAdmin = storage.renameCharacter(admin.core.record.id(), "Duplicate Fantasy Name")
.get(5, TimeUnit.SECONDS).orElseThrow();
var renamedOther = storage.renameCharacter(other.core.record.id(), "Duplicate Fantasy Name")
.get(5, TimeUnit.SECONDS).orElseThrow();
admin.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(renamedAdmin));
other.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(renamedOther));
fixture.activate(admin); fixture.activate(other);
admin.core.health = 6.0; other.core.health = 8.0;
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "Admin"});
drainReads(fixture); runQueued(fixture);
assertTrue(text(admin).contains("Health: 6/10"));
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "Other"});
drainReads(fixture); runQueued(fixture);
assertTrue(text(admin).contains("Health: 8/10"));
assertEquals(2, admin.books.size());
}
}
@Test void stalePermissionSessionAndSupersededReadsNeverOpenCharacterData() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data); admin.accountName = "Admin";
var first = new FantasyStickLifecycleNativeTest.Actor(data); first.accountName = "First";
var second = new FantasyStickLifecycleNativeTest.Actor(data); second.accountName = "Second";
fixture.players.put(admin.core.id, admin.player);
fixture.players.put(first.core.id, first.player);
fixture.players.put(second.core.id, second.player);
fixture.activate(first); fixture.activate(second);
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "First"});
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "Second"});
drainReads(fixture); runQueued(fixture);
assertEquals(1, admin.books.size(), "Only the latest authoritative request may open");
assertTrue(nativePages(admin).stream().map(Component::getString)
.collect(java.util.stream.Collectors.joining()).contains(second.core.record.name()));
int opened = admin.books.size();
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "First"});
drainReads(fixture);
var permissionResponse = fixture.tasks.poll(); assertNotNull(permissionResponse);
admin.allowed = false; permissionResponse.run();
assertEquals(opened, admin.books.size());
assertTrue(admin.messages.getLast().contains("permission changed"));
admin.allowed = true;
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "First"});
drainReads(fixture);
var sessionResponse = fixture.tasks.poll(); assertNotNull(sessionResponse);
((games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions")).exit(first.player);
sessionResponse.run();
assertEquals(opened, admin.books.size());
assertTrue(admin.messages.getLast().contains("session changed"));
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "Second"});
drainReads(fixture);
var retiredLifetimeResponse = fixture.tasks.poll(); assertNotNull(retiredLifetimeResponse);
fixture.plugin.setEnabled(false);
retiredLifetimeResponse.run();
assertEquals(opened, admin.books.size(), "A retired plugin lifetime cannot open character data");
}
}
@Test void malformedAuthoritativeReadReportsFailureAndAllowsAValidRetry() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data); admin.accountName = "Admin";
var target = new FantasyStickLifecycleNativeTest.Actor(data); target.accountName = "Target";
fixture.players.put(admin.core.id, admin.player);
fixture.players.put(target.core.id, target.player);
fixture.activate(target);
Path character = data.resolve("characters").resolve(target.core.record.id() + ".yml");
byte[] valid = Files.readAllBytes(character);
Files.writeString(character, "schema-version: malformed\n");
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "Target"});
drainReads(fixture); runQueued(fixture);
assertTrue(admin.books.isEmpty());
assertTrue(admin.messages.getLast().contains("could not be loaded"));
assertEquals("schema-version: malformed\n", Files.readString(character));
Files.write(character, valid);
fixture.command.execute(admin.player, "fantasystick", new String[]{"character", "view", "Target"});
drainReads(fixture); runQueued(fixture);
assertEquals(1, admin.books.size());
assertArrayEquals(valid, Files.readAllBytes(character));
}
}
@Test void deadAndMissingAuthoritativeCharactersAreRefusedWithoutOpening() throws Exception {
for (String state : java.util.List.of("dead", "missing")) {
Path dir = data.resolve(state);
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(dir)) {
fixture.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(dir); admin.accountName = "Admin";
var target = new FantasyStickLifecycleNativeTest.Actor(dir); target.accountName = "Target";
fixture.players.put(admin.core.id, admin.player);
fixture.players.put(target.core.id, target.player);
fixture.activate(target);
Path character = dir.resolve("characters").resolve(target.core.record.id() + ".yml");
if (state.equals("missing")) {
Files.delete(character);
} else {
var old = target.core.record;
var dead = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(),
old.revision().add(java.math.BigInteger.ONE), old.stats(), old.level(), old.experience(),
old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.cooldowns(),
old.restrictedKills(), true, UUID.randomUUID(), java.time.Instant.parse("2026-09-23T00:00:00Z"));
new games.dmg.fantasystick.persistence.YamlCharacterRepository(dir)
.replace(dead, old.revision());
}
fixture.command.execute(admin.player, "fantasystick",
new String[]{"character", "view", "Target"});
drainReads(fixture); runQueued(fixture);
assertTrue(admin.books.isEmpty());
assertTrue(admin.messages.getLast().contains("unavailable"));
}
}
}
@Test void commandAndCompletionRequireAnAuthorizedOnlinePlayerAndExactActiveAccount() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data); admin.accountName = "Admin";
var active = new FantasyStickLifecycleNativeTest.Actor(data); active.accountName = "ActiveTarget";
var inactive = new FantasyStickLifecycleNativeTest.Actor(data); inactive.accountName = "InactiveTarget";
var offline = new FantasyStickLifecycleNativeTest.Actor(data); offline.accountName = "OfflineTarget";
offline.core.online = false;
fixture.players.put(admin.core.id, admin.player);
fixture.players.put(active.core.id, active.player);
fixture.players.put(inactive.core.id, inactive.player);
fixture.players.put(offline.core.id, offline.player);
fixture.activate(active);
assertEquals(java.util.List.of("character", "config"),
fixture.command.tabComplete(admin.player, "fantasystick", new String[]{"c"}));
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"),
fixture.command.tabComplete(admin.player, "fantasystick", new String[]{"character", "view", "Act"}));
assertTrue(fixture.command.tabComplete(admin.player, "fantasystick",
new String[]{"character", "view", "ActiveTarget", ""}).isEmpty());
admin.allowed = false;
assertTrue(fixture.command.tabComplete(admin.player, "fantasystick",
new String[]{"character", ""}).isEmpty());
fixture.command.getExecutor().onCommand(admin.player, fixture.command, "fantasystick",
new String[]{"character", "view", "ActiveTarget"});
assertTrue(admin.books.isEmpty()); assertTrue(fixture.tasks.isEmpty());
admin.allowed = true;
var console = new EquipmentAdminCommandNativeTest.Sender();
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"));
for (String[] args : java.util.List.of(
new String[]{"character"},
new String[]{"character", "view"},
new String[]{"character", "view", "activetarget"},
new String[]{"character", "view", "InactiveTarget"},
new String[]{"character", "view", "OfflineTarget"},
new String[]{"character", "view", "ActiveTarget", "extra"})) {
fixture.command.execute(admin.player, "fantasystick", args);
assertTrue(admin.books.isEmpty()); assertTrue(fixture.tasks.isEmpty());
}
}
}
private static void dispatchCustom(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor,
String key, String payload) {
var connection = org.mockito.Mockito.mock(io.papermc.paper.connection.PlayerGameConnection.class);
org.mockito.Mockito.when(connection.getPlayer()).thenReturn(actor.player);
fixture.dispatcher.accept(new io.papermc.paper.event.player.PaperPlayerCustomClickEvent(
net.kyori.adventure.key.Key.key(key), connection,
net.minecraft.nbt.StringTag.valueOf(payload)));
}
private static net.minecraft.network.chat.ClickEvent.Custom customLinkOrNull(
java.util.List<Component> pages, String label) {
for (var page : pages) {
var click = PartyBookNativeTest.find(page, label);
if (click != null) return click;
}
return null;
}
private static void drainReads(FantasyStickLifecycleNativeTest.Fixture fixture) throws Exception {
characters(fixture).find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
}
private static void runQueued(FantasyStickLifecycleNativeTest.Fixture fixture) {
Runnable task;
while ((task = fixture.tasks.poll()) != null) task.run();
}
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 String text(FantasyStickLifecycleNativeTest.Actor actor) {
return nativePages(actor).stream().map(Component::getString)
.collect(java.util.stream.Collectors.joining("\n"));
}
private static java.util.List<Component> nativePages(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();
}
private static boolean onlyNativePageLinks(Component component) {
var click = component.getStyle().getClickEvent();
if (click != null && !(click instanceof ClickEvent.ChangePage)) return false;
return component.getSiblings().stream().allMatch(AdminCharacterBookNativeTest::onlyNativePageLinks);
}
}
@@ -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");
}
}
@@ -23,6 +23,11 @@ class AdminExperienceNativeTest {
var storage = storage(f);
storage.renameCharacter(target.core.record.id(), "Mira 雪").get();
f.players.put(target.core.id, target.player); f.activate(target);
var viewer = new FantasyStickLifecycleNativeTest.Actor(data);
viewer.core.inventory.setItemInOffHand(null); viewer.core.location.setWorld(target.world);
f.players.put(viewer.core.id, viewer.player);
f.particlesCommand.execute(target.player, "fantasyparticles", new String[]{"all", "off"}); f.tick();
target.particleCalls.clear(); viewer.particleCalls.clear();
var sender = new EquipmentAdminCommandNativeTest.Sender();
var before = storage.find(target.core.record.id()).get().orElseThrow();
@@ -47,8 +52,8 @@ class AdminExperienceNativeTest {
assertTrue(target.messages.stream().anyMatch(message -> message.contains("Level 2")));
assertTrue(target.messages.stream().anyMatch(message -> message.contains("Level 3")));
assertEquals(2, target.sounds.size());
assertEquals(2, org.mockito.Mockito.mockingDetails(target.world).getInvocations().stream()
.filter(invocation -> invocation.getMethod().getName().equals("spawnParticle")).count());
assertTrue(target.particleCalls.isEmpty(), "The opted-out recipient receives no progression bursts");
assertEquals(2, viewer.particleCalls.size(), "An enabled nearby viewer retains both progression bursts");
PartyBookNativeTest.open(f, target);
String text = bookText(target.books.getLast());
assertTrue(text.contains("Level: 3"), text); assertTrue(text.contains("XP: 50/100"), text);
@@ -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());
}
}
@@ -0,0 +1,543 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.util.*;
import org.bukkit.*;
import org.bukkit.entity.*;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.persistence.PersistentDataContainer;
import org.bukkit.persistence.PersistentDataType;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class AdventureQuestNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredAdventureSiteTeleportIsAsyncAuthorizedRevalidatedAndReadOnly() throws Exception {
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);
var admin=new FantasyStickLifecycleNativeTest.Actor(data);var destination=new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(admin.core.id,admin.player);f.players.put(destination.core.id,destination.player);
doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
UUID startWorld=UUID.randomUUID(),targetWorld=UUID.randomUUID();
when(admin.world.getUID()).thenReturn(startWorld);when(admin.world.getName()).thenReturn("Start Realm");
when(destination.world.getUID()).thenReturn(targetWorld);when(destination.world.getName()).thenReturn("Maintenance Realm");
admin.core.location.setYaw(37.5f);admin.core.location.setPitch(-12.25f);f.activate(admin);
var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");
var activeSession=sessions.active(admin.core.id).orElseThrow();
var directory=(games.dmg.fantasystick.adventure.AdventureQuestDirectory)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
var camp=directory.placeSite(games.dmg.fantasystick.adventure.AdventureQuestState.SiteType.CAMP,
"Negative Camp",targetWorld,-32.75,70.5,48.125).get(5,java.util.concurrent.TimeUnit.SECONDS);
var before=directory.snapshot();var inventory=admin.core.snapshot();var cursor=admin.core.cursor.clone();
var chunkFuture=new java.util.concurrent.CompletableFuture<Chunk>();
doReturn(chunkFuture).when(destination.world).getChunkAtAsync(-3,3);
var teleports=new ArrayList<Location>();
doAnswer(call->{teleports.add(((Location)call.getArgument(0)).clone());return java.util.concurrent.CompletableFuture.completedFuture(true);})
.when(admin.player).teleportAsync(any(Location.class));
assertTrue(f.command.tabComplete(admin.player,"fantasystick",new String[]{"adventure-site",""}).contains("teleport"));
assertEquals(List.of(camp.id().toString()),f.command.tabComplete(admin.player,"fantasystick",new String[]{"adventure-site","teleport",""}));
f.command.execute(admin.player,"fantasystick",new String[]{"adventure-site","teleport",camp.id().toString()});
assertTrue(teleports.isEmpty(),"teleport waits for asynchronous chunk loading");
chunkFuture.complete(mock(Chunk.class));drainOne(f);assertEquals(1,teleports.size());drainOne(f);
var target=teleports.getFirst();assertSame(destination.world,target.getWorld());
assertEquals(-32.75,target.getX());assertEquals(70.5,target.getY());assertEquals(48.125,target.getZ());
assertEquals(37.5f,target.getYaw());assertEquals(-12.25f,target.getPitch());
assertEquals("Teleported to Negative Camp (monster camp) at Maintenance Realm (-33, 70, 48).",admin.messages.getLast());
assertEquals(before,directory.snapshot());assertArrayEquals(inventory,admin.core.snapshot());assertEquals(cursor,admin.core.cursor);
assertEquals(activeSession,sessions.active(admin.core.id).orElseThrow());
var champion=directory.placeSite(games.dmg.fantasystick.adventure.AdventureQuestState.SiteType.CHAMPION,
"Summit",targetWorld,9,80,-7).get(5,java.util.concurrent.TimeUnit.SECONDS);
doReturn(java.util.concurrent.CompletableFuture.completedFuture(mock(Chunk.class))).when(destination.world).getChunkAtAsync(0,-1);
f.command.execute(admin.player,"fantasystick",new String[]{"adventure-site","teleport",champion.id().toString()});drainOne(f);drainOne(f);
assertTrue(admin.messages.getLast().contains("Summit (champion)"));
var local=directory.placeSite(games.dmg.fantasystick.adventure.AdventureQuestState.SiteType.CAMP,
"Local Camp",startWorld,4,65,4).get(5,java.util.concurrent.TimeUnit.SECONDS);
doReturn(java.util.concurrent.CompletableFuture.completedFuture(mock(Chunk.class))).when(admin.world).getChunkAtAsync(0,0);
f.command.execute(admin.player,"fantasystick",new String[]{"adventure-site","teleport",local.id().toString()});drainOne(f);drainOne(f);
assertSame(admin.world,teleports.getLast().getWorld());
var unavailable=directory.placeSite(games.dmg.fantasystick.adventure.AdventureQuestState.SiteType.CAMP,
"Lost",UUID.randomUUID(),1,2,3).get(5,java.util.concurrent.TimeUnit.SECONDS);
f.command.execute(admin.player,"fantasystick",new String[]{"adventure-site","teleport",unavailable.id().toString()});
assertEquals("Adventure-site teleport refused: destination world is unavailable.",admin.messages.getLast());
assertEquals(3,teleports.size());
var staleFuture=new java.util.concurrent.CompletableFuture<Chunk>();doReturn(staleFuture).when(destination.world).getChunkAtAsync(0,0);
var stale=directory.placeSite(games.dmg.fantasystick.adventure.AdventureQuestState.SiteType.CAMP,
"Stale",targetWorld,1,64,1).get(5,java.util.concurrent.TimeUnit.SECONDS);
f.command.execute(admin.player,"fantasystick",new String[]{"adventure-site","teleport",stale.id().toString()});
directory.removeSite(stale.id()).get(5,java.util.concurrent.TimeUnit.SECONDS);staleFuture.complete(mock(Chunk.class));drainOne(f);
assertEquals("Adventure-site teleport refused: destination changed or is no longer registered.",admin.messages.getLast());assertEquals(3,teleports.size());
var revokedFuture=new java.util.concurrent.CompletableFuture<Chunk>();doReturn(revokedFuture).when(destination.world).getChunkAtAsync(-3,3);
f.command.execute(admin.player,"fantasystick",new String[]{"adventure-site","teleport",camp.id().toString()});
doReturn(false).when(admin.player).hasPermission("fantasystick.admin");revokedFuture.complete(mock(Chunk.class));drainOne(f);
assertEquals("Adventure-site teleport refused: permission or player session changed.",admin.messages.getLast());assertEquals(3,teleports.size());
assertTrue(f.command.tabComplete(admin.player,"fantasystick",new String[]{"adventure-site","teleport",""}).isEmpty());
doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
doReturn(java.util.concurrent.CompletableFuture.completedFuture(false)).when(admin.player).teleportAsync(any(Location.class));
var originalLocation=admin.core.location.clone();
f.command.execute(admin.player,"fantasystick",new String[]{"adventure-site","teleport",camp.id().toString()});drainOne(f);drainOne(f);
assertEquals("Adventure-site teleport failed; your location is unchanged.",admin.messages.getLast());
assertEquals(originalLocation,admin.core.location);
var console=new EquipmentAdminCommandNativeTest.Sender();
f.command.execute(console.port,"fantasystick",new String[]{"adventure-site","teleport",camp.id().toString()});
assertEquals("Adventure-site teleport requires a player.",console.messages.getLast());
assertFalse(f.command.tabComplete(console.port,"fantasystick",new String[]{"adventure-site",""}).contains("teleport"));
}
}
@SuppressWarnings({"unchecked", "rawtypes"})
@Test void registeredCommandsGiverMenuAcceptanceCampCombatAndExactRewardUseConnectedPath() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"adventure-daily-site-count: 3\nadventure-site-reset-seconds: 0\nadventure-camp-enemies: 4\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 worldId=UUID.randomUUID();when(actor.world.getUID()).thenReturn(worldId);when(actor.world.getName()).thenReturn("Adventure Realm");when(actor.world.isChunkLoaded(anyInt(),anyInt())).thenReturn(true);
int chunkX=actor.core.location.getBlockX()>>4,chunkZ=actor.core.location.getBlockZ()>>4;
var chunk=mock(Chunk.class);when(chunk.getWorld()).thenReturn(actor.world);when(chunk.getX()).thenReturn(chunkX);when(chunk.getZ()).thenReturn(chunkZ);when(chunk.getEntities()).thenReturn(new Entity[0]);when(actor.world.getChunkAt(anyInt(),anyInt())).thenReturn(chunk);when(actor.world.getLoadedChunks()).thenReturn(new Chunk[]{chunk});
var spawned=new ArrayList<LivingEntity>();
doAnswer(call->{Class<?> type=call.getArgument(1);LivingEntity entity=type==Villager.class?mock(Villager.class):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));
assertEquals(List.of("adventure-camp-enemies","adventure-camp-reward-coins","adventure-camp-reward-xp"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","adventure-camp"}));
f.command.execute(actor.player,"fantasystick",new String[]{"config","adventure-site-radius","20"});drainOne(f);
var questSettings=(games.dmg.fantasystick.persistence.AdventureQuestSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestSettings");assertEquals(20,questSettings.rules().radius());
f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","place","camp","20","60","Dawn Camp"}); 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");
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");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();
doReturn(false).when(actor.player).hasPermission("fantasystick.admin");f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll","confirm"});assertTrue(actor.messages.getLast().contains("Permission denied"));assertTrue(f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure",""}).isEmpty());assertEquals(originalCatalog,directory.snapshot().catalog());doReturn(true).when(actor.player).hasPermission("fantasystick.admin");f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll","confirm","extra"});assertTrue(actor.messages.getLast().contains("Usage"));assertEquals(originalCatalog,directory.snapshot().catalog());
f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll"});assertTrue(actor.messages.getLast().contains("confirm"));assertEquals(originalCatalog,directory.snapshot().catalog());assertEquals(List.of("reroll"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure","r"}));assertEquals(List.of("confirm"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure","reroll","c"}));
f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll","confirm"});for(int attempt=0;attempt<10&&actor.messages.stream().noneMatch(message->message.startsWith("Reset daily adventures:"));attempt++)drainOne(f);assertNotEquals(originalCatalog.revision(),directory.snapshot().catalog().revision());assertTrue(actor.messages.getLast().contains("Reset daily adventures")&&actor.messages.getLast().contains("expires"));
var stale=mock(InventoryClickEvent.class);when(stale.getWhoClicked()).thenReturn(actor.player);when(stale.getInventory()).thenReturn(menu);when(stale.getCurrentItem()).thenReturn(menu.getItem(0));controller.click(stale);drainOne(f);assertTrue(actor.messages.getLast().contains("stale"));
f.dispatcher.accept(new PlayerInteractEntityEvent(actor.player,giver,EquipmentSlot.HAND));drainOne(f);menu=actor.inventories.getLast();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);
assertTrue(actor.messages.stream().anyMatch(message -> message.equals(
"Accepted Dawn Camp; expires " + directory.active().getFirst().expiresAt()
+ ". Follow the Quest Compass.")));
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()));
var siteRecord=directory.snapshot().sites().getFirst();assertEquals(games.dmg.fantasystick.adventure.AdventureQuestState.Arena.rectangle(20,60),siteRecord.arena());actor.core.location.setZ(siteRecord.z()+25);
var partyA=new FantasyStickLifecycleNativeTest.Actor(data);var partyB=new FantasyStickLifecycleNativeTest.Actor(data);partyA.core.location.setWorld(actor.world);partyB.core.location.setWorld(actor.world);f.players.put(partyA.core.id,partyA.player);f.players.put(partyB.core.id,partyB.player);f.activate(partyA);f.activate(partyB);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=directory.snapshot().catalog();var siteId=catalog.sites().getFirst();assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Acceptance.APPLIED,directory.accept(catalog.revision(),siteId,games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.PARTY,party.id(),Set.of(partyA.core.record.id(),partyB.core.record.id())).get(5,java.util.concurrent.TimeUnit.SECONDS).status());
var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var actorCoins=storage.find(actor.core.record.id()).get().orElseThrow().coins();var partyACoins=storage.find(partyA.core.record.id()).get().orElseThrow().coins();var partyBCoins=storage.find(partyB.core.record.id()).get().orElseThrow().coins();
for(Runnable pulse:List.copyOf(f.pulses))pulse.run();for(int retry=0;retry<10&&spawned.stream().filter(entity->!(entity instanceof Villager)).count()<4;retry++)drainOne(f);
var enemies=spawned.stream().filter(entity->!(entity instanceof Villager)).toList();assertEquals(4,enemies.size());assertTrue(controller.hasActiveEncounter());
var enemy=enemies.getFirst();assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CAMP,controller.encounterOrigin(enemy).orElseThrow().kind());when(enemy.getLocation()).thenReturn(new Location(actor.world,siteRecord.x()+11,siteRecord.y(),siteRecord.z()));when(actor.world.getEntities()).thenReturn(new ArrayList<Entity>(spawned));for(Runnable pulse:List.copyOf(f.pulses))pulse.run();verify(enemy).teleport(argThat((Location location)->location.getX()==siteRecord.x()&&location.getY()==siteRecord.y()&&location.getZ()==siteRecord.z()));
var 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(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||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());
}
}
@SuppressWarnings({"unchecked", "rawtypes"})
@Test void confirmedCommandResetsActiveEncounterClaimsMenusAndRegisteredParticipants() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"adventure-daily-site-count: 1\nadventure-camp-enemies: 4\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");
UUID worldId=UUID.randomUUID();when(admin.world.getUID()).thenReturn(worldId);
when(admin.world.isChunkLoaded(anyInt(),anyInt())).thenReturn(true);
var chunk=mock(Chunk.class);when(chunk.getWorld()).thenReturn(admin.world);
when(chunk.getX()).thenReturn(admin.core.location.getBlockX()>>4);
when(chunk.getZ()).thenReturn(admin.core.location.getBlockZ()>>4);
when(chunk.getEntities()).thenReturn(new Entity[0]);
when(admin.world.getChunkAt(anyInt(),anyInt())).thenReturn(chunk);
when(admin.world.getLoadedChunks()).thenReturn(new Chunk[]{chunk});
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(admin.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(admin.world).spawn(any(Location.class),any(Class.class),any(java.util.function.Consumer.class));
when(admin.world.getEntities()).thenAnswer(call->new ArrayList<Entity>(spawned));
var directory=(games.dmg.fantasystick.adventure.AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
var site=directory.placeSite(games.dmg.fantasystick.adventure.AdventureQuestState.SiteType.CAMP,"Reset Camp",worldId,admin.core.location.getX(),admin.core.location.getY(),admin.core.location.getZ()).get(5,java.util.concurrent.TimeUnit.SECONDS);
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()));var first=directory.ensureToday().get(5,java.util.concurrent.TimeUnit.SECONDS);
UUID claimed=UUID.randomUUID();var claimedQuest=directory.accept(first.revision(),site.id(),games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,claimed,Set.of(claimed)).get(5,java.util.concurrent.TimeUnit.SECONDS).quest().orElseThrow();assertTrue(directory.complete(claimedQuest.id(),Set.of(claimed)).get(5,java.util.concurrent.TimeUnit.SECONDS));
f.activate(admin);
var controller=(games.dmg.fantasystick.adventure.AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");
controller.open(admin.player);drainOne(f);var staleMenu=admin.inventories.getLast();var click=mock(InventoryClickEvent.class);when(click.getWhoClicked()).thenReturn(admin.player);when(click.getInventory()).thenReturn(staleMenu);when(click.getCurrentItem()).thenReturn(staleMenu.getItem(0));controller.click(click);drainOne(f);
for(Runnable pulse:List.copyOf(f.pulses))pulse.run();for(int attempt=0;attempt<10&&spawned.stream().noneMatch(entity->!(entity instanceof Villager));attempt++)drainOne(f);assertTrue(controller.hasActiveEncounter());var enemy=spawned.stream().filter(entity->!(entity instanceof Villager)).findFirst().orElseThrow();var managedProjectile=mock(org.bukkit.entity.Projectile.class);var projectileData=new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());projectileData.set(new NamespacedKey(f.plugin,"managed_npc_type"),PersistentDataType.STRING,"adventure_projectile");when(managedProjectile.getPersistentDataContainer()).thenReturn(projectileData);when(admin.world.getEntities()).thenAnswer(call->{var entities=new ArrayList<Entity>(spawned);entities.add(managedProjectile);return entities;});
var participant=new java.util.concurrent.atomic.AtomicReference<games.dmg.fantasystick.adventure.AdventureQuestState.Reset>();
controller.registerResetParticipant(reset->{participant.set(reset);return java.util.concurrent.CompletableFuture.completedFuture(null);});
f.command.execute(admin.player,"fantasystick",new String[]{"adventure","reroll","confirm"});
for(int attempt=0;attempt<15&&(directory.snapshot().reset()!=null||admin.messages.stream().noneMatch(message->message.startsWith("Reset daily adventures:")));attempt++)drainOne(f);
var replacement=directory.snapshot().catalog();assertNotEquals(first.revision(),replacement.revision());assertTrue(directory.active().isEmpty());assertTrue(directory.snapshot().unitClaims().isEmpty());assertTrue(directory.snapshot().characterClaims().isEmpty());assertTrue(directory.snapshot().rosters().isEmpty());assertFalse(controller.hasActiveEncounter());verify(enemy,atLeastOnce()).remove();verify(managedProjectile,atLeastOnce()).remove();assertNotNull(participant.get());assertEquals(replacement.revision(),participant.get().generation());assertNull(directory.snapshot().reset());
controller.click(click);drainOne(f);assertTrue(admin.messages.getLast().contains("stale"));
assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Acceptance.APPLIED,directory.accept(replacement.revision(),site.id(),games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,claimed,Set.of(claimed)).get(5,java.util.concurrent.TimeUnit.SECONDS).status());
}
}
@Test void registeredSitePlacementRecognizesCircleAndRectangleDimensions() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(admin.core.id, admin.player);
doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
when(admin.world.getUID()).thenReturn(UUID.randomUUID());
when(admin.world.getName()).thenReturn("Arena World");
f.command.execute(admin.player, "fantasystick",
new String[]{"adventure-site", "place", "camp", "10", "Round Trial"});
drainOne(f);
f.command.execute(admin.player, "fantasystick",
new String[]{"adventure-site", "place", "champion", "20", "30", "Square Trial"});
drainOne(f);
var directory = (games.dmg.fantasystick.adventure.AdventureQuestDirectory)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "adventureQuests");
assertEquals(List.of("Round Trial", "Square Trial"),
directory.snapshot().sites().stream().map(games.dmg.fantasystick.adventure.AdventureQuestState.Site::name).toList());
var circle = directory.snapshot().sites().get(0);
var rectangle = directory.snapshot().sites().get(1);
assertEquals(games.dmg.fantasystick.adventure.AdventureQuestState.Arena.circle(10), circle.arena());
assertEquals(games.dmg.fantasystick.adventure.AdventureQuestState.Arena.rectangle(20, 30), rectangle.arena());
f.command.execute(admin.player, "fantasystick", new String[]{"adventure-site", "list"});
assertTrue(admin.messages.stream().anyMatch(message -> message.contains(circle.id().toString())
&& message.contains("circle radius=10")));
assertTrue(admin.messages.stream().anyMatch(message -> message.contains(rectangle.id().toString())
&& message.contains("rectangle x=20 z=30")));
assertEquals(List.of("16"), f.command.tabComplete(admin.player, "fantasystick",
new String[]{"adventure-site", "place", "camp", "1"}));
assertEquals(List.of("128", "16"), f.command.tabComplete(admin.player, "fantasystick",
new String[]{"adventure-site", "place", "camp", "20", "1"}));
int beforeInvalid = directory.snapshot().sites().size();
for (String[] invalid : List.of(
new String[]{"adventure-site", "place", "camp", "3"},
new String[]{"adventure-site", "place", "camp", "65"},
new String[]{"adventure-site", "place", "camp", "7", "20"},
new String[]{"adventure-site", "place", "camp", "20", "129"},
new String[]{"adventure-site", "place", "camp", "4.5"})) {
f.command.execute(admin.player, "fantasystick", invalid);
assertTrue(admin.messages.getLast().startsWith("Usage:"));
}
assertEquals(beforeInvalid, directory.snapshot().sites().size());
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()));
var catalog = directory.ensureToday().get(5, java.util.concurrent.TimeUnit.SECONDS);
f.activate(admin);
var outsider = new FantasyStickLifecycleNativeTest.Actor(data);
outsider.core.location.setWorld(admin.world);
f.players.put(outsider.core.id, outsider.player);
var rectangleQuest = directory.accept(catalog.revision(), rectangle.id(),
games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,
admin.core.record.id(), Set.of(admin.core.record.id())).get(5, java.util.concurrent.TimeUnit.SECONDS)
.quest().orElseThrow();
admin.particles.clear(); admin.particleCalls.clear(); outsider.particles.clear();
for (Runnable pulse : List.copyOf(f.pulses)) pulse.run();
assertFalse(admin.particleCalls.isEmpty());
assertTrue(admin.particleCalls.size() <= 256);
assertTrue(outsider.particleCalls.isEmpty());
for (Object[] call : admin.particleCalls) {
assertEquals(Particle.END_ROD, call[0]);
var point = (Location) call[1];
double dx = point.getX() - rectangle.x(), dz = point.getZ() - rectangle.z();
assertTrue(Math.abs(Math.abs(dx) - 10) < 0.000001 || Math.abs(Math.abs(dz) - 15) < 0.000001);
assertTrue(Math.abs(dx) <= 10.000001 && Math.abs(dz) <= 15.000001);
}
var particlePreferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "feedbackPreferences");
assertTrue(particlePreferences.publish(particlePreferences.setAllParticles(admin.core.id, false)
.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)));
admin.particleCalls.clear(); for (Runnable pulse : List.copyOf(f.pulses)) pulse.run();
assertTrue(admin.particleCalls.isEmpty(), () -> "All-particle opt-out suppresses eligible adventure boundaries: "
+ admin.particleCalls.stream().map(call -> java.util.Arrays.toString(call)).toList());
assertTrue(particlePreferences.publish(particlePreferences.setAllParticles(admin.core.id, true)
.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)));
admin.core.location.setX(rectangle.x() + 65);
admin.particles.clear(); admin.particleCalls.clear();
for (Runnable pulse : List.copyOf(f.pulses)) pulse.run();
assertTrue(admin.particleCalls.isEmpty(), "the boundary is bounded to ordinary view range");
admin.core.location.setX(rectangle.x());
assertTrue(directory.abandon(rectangleQuest.id(), rectangleQuest.unit()).get(5, java.util.concurrent.TimeUnit.SECONDS));
for (Runnable pulse : List.copyOf(f.pulses)) pulse.run();
assertTrue(admin.particleCalls.isEmpty(), "abandonment removes particle eligibility");
var circleQuest = directory.accept(catalog.revision(), circle.id(),
games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,
admin.core.record.id(), Set.of(admin.core.record.id())).get(5, java.util.concurrent.TimeUnit.SECONDS)
.quest().orElseThrow();
admin.particleCalls.clear();
for (Runnable pulse : List.copyOf(f.pulses)) pulse.run();
assertFalse(admin.particleCalls.isEmpty());
for (Object[] call : admin.particleCalls) {
var point = (Location) call[1];
assertEquals(10, Math.hypot(point.getX() - circle.x(), point.getZ() - circle.z()), 0.000001);
}
assertTrue(directory.abandon(circleQuest.id(), circleQuest.unit()).get(5, java.util.concurrent.TimeUnit.SECONDS));
verify(admin.world, never()).getChunkAt(anyInt(), anyInt());
}
}
@Test void readableLocationsFloorCoordinatesHideInternalIdsAndHandleUnavailableWorlds() 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 actor = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(actor.core.id, actor.player);
doReturn(true).when(actor.player).hasPermission("fantasystick.admin");
UUID worldId = UUID.randomUUID();
when(actor.world.getUID()).thenReturn(worldId);
when(actor.world.getName()).thenReturn("Adventure Realm");
actor.core.location.setX(12.875);
actor.core.location.setY(-0.125);
actor.core.location.setZ(-8.001);
f.command.execute(actor.player, "fantasystick",
new String[]{"adventure-site", "place", "camp"});
drainOne(f);
var directory = (games.dmg.fantasystick.adventure.AdventureQuestDirectory)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "adventureQuests");
var site = directory.snapshot().sites().getFirst();
assertNull(site.arena());
assertTrue(actor.messages.getLast().contains("circle radius=16"));
assertEquals(12.875, site.x());
assertEquals(-0.125, site.y());
assertEquals(-8.001, site.z());
assertTrue(actor.messages.getLast().contains(site.id().toString()));
assertTrue(actor.messages.getLast().contains("Monster Camp"));
assertTrue(actor.messages.getLast().contains("Adventure Realm (12, -1, -9)"));
assertFalse(actor.messages.getLast().contains(worldId.toString()));
f.command.execute(actor.player, "fantasystick", new String[]{"quest-giver", "place"});
drainOne(f);
var giver = directory.snapshot().givers().getFirst();
assertEquals(12.875, giver.x());
assertEquals(-0.125, giver.y());
assertEquals(-8.001, giver.z());
assertEquals(45f, giver.yaw());
assertEquals(10f, giver.pitch());
assertTrue(actor.messages.getLast().contains(giver.id().toString()));
assertTrue(actor.messages.getLast().contains("Adventure Realm (12, -1, -9)"));
assertFalse(actor.messages.getLast().contains(worldId.toString()));
f.command.execute(actor.player, "fantasystick", new String[]{"adventure-site", "list"});
assertTrue(actor.messages.getLast().contains(site.id().toString()));
assertTrue(actor.messages.getLast().contains("Adventure Realm (12, -1, -9)"));
f.command.execute(actor.player, "fantasystick", new String[]{"quest-giver", "list"});
assertTrue(actor.messages.getLast().contains(giver.id().toString()));
assertTrue(actor.messages.getLast().contains("Adventure Realm (12, -1, -9)"));
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);
f.activate(actor);
var controller = (games.dmg.fantasystick.adventure.AdventureQuestController)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "adventureQuestController");
controller.open(actor.player);
drainOne(f);
var menu = actor.inventories.getLast();
var offered = menu.getItem(0);
var metadata = offered.getItemMeta().getPersistentDataContainer();
var siteKey = new NamespacedKey(f.plugin, "quest_offer_site");
var catalogKey = new NamespacedKey(f.plugin, "quest_offer_catalog");
assertEquals(site.id().toString(), metadata.get(siteKey, PersistentDataType.STRING));
assertEquals(directory.snapshot().catalog().revision().toString(),
metadata.get(catalogKey, PersistentDataType.STRING));
String visibleOffer = net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText()
.serialize(offered.getItemMeta().displayName()) + offered.getItemMeta().lore().stream()
.map(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText()::serialize)
.reduce("", String::concat);
assertFalse(visibleOffer.contains(site.id().toString()));
assertFalse(visibleOffer.contains(directory.snapshot().catalog().revision().toString()));
assertFalse(visibleOffer.contains(worldId.toString()));
assertFalse(visibleOffer.contains("Adventure Realm ("), "coordinates remain hidden before acceptance");
var click = mock(InventoryClickEvent.class);
when(click.getWhoClicked()).thenReturn(actor.player);
when(click.getInventory()).thenReturn(menu);
when(click.getCurrentItem()).thenReturn(offered);
controller.click(click);
drainOne(f);
var quest = directory.active().getFirst();
String accepted = actor.messages.getLast();
assertEquals("Accepted Monster Camp; expires "+quest.expiresAt()+". Follow the Quest Compass.",accepted);
for (String hidden : List.of("(12, -1, -9)", worldId.toString(), site.id().toString(),
directory.snapshot().catalog().revision().toString(), quest.id().toString(),
quest.unit().toString(), actor.core.record.id().toString())) {
assertFalse(accepted.contains(hidden));
}
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.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"});
assertTrue(actor.messages.getLast().contains(site.id().toString()));
assertTrue(actor.messages.getLast().contains("unavailable world (12, -1, -9)"));
assertFalse(actor.messages.getLast().contains(worldId.toString()));
f.command.execute(actor.player, "fantasystick", new String[]{"quest-giver", "list"});
assertTrue(actor.messages.getLast().contains(giver.id().toString()));
assertTrue(actor.messages.getLast().contains("unavailable world (12, -1, -9)"));
assertFalse(actor.messages.getLast().contains(worldId.toString()));
}
}
@Test void acceptedQuestPublishesBoundCompassGuidanceToggleAndExactBookRestoration() 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 actor=new FantasyStickLifecycleNativeTest.Actor(data);
var targetWorldActor=new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(actor.core.id,actor.player);
UUID targetWorld=UUID.randomUUID(),otherWorld=UUID.randomUUID();
when(actor.world.getUID()).thenReturn(targetWorld);
when(actor.world.getName()).thenReturn("Adventure Realm");
when(targetWorldActor.world.getUID()).thenReturn(targetWorld);
when(targetWorldActor.world.getName()).thenReturn("Adventure Realm");
actor.core.location.setX(0);actor.core.location.setY(64);actor.core.location.setZ(0);
f.activate(actor);var ordinaryCompass=new org.bukkit.inventory.ItemStack(Material.COMPASS);actor.core.inventory.setItem(10,ordinaryCompass);actor.core.cursor=new org.bukkit.inventory.ItemStack(Material.DIAMOND);
var directory=(games.dmg.fantasystick.adventure.AdventureQuestDirectory)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
var site=directory.placeSite(games.dmg.fantasystick.adventure.AdventureQuestState.SiteType.CAMP,
"Compass Camp",targetWorld,10.4,69.8,-10.2,games.dmg.fantasystick.adventure.AdventureQuestState.Arena.circle(4))
.get(5,java.util.concurrent.TimeUnit.SECONDS);
var catalog=directory.ensureToday().get(5,java.util.concurrent.TimeUnit.SECONDS);
var quest=directory.accept(catalog.revision(),site.id(),games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,
actor.core.record.id(),Set.of(actor.core.record.id())).get(5,java.util.concurrent.TimeUnit.SECONDS).quest().orElseThrow();
var controller=(games.dmg.fantasystick.adventure.AdventureQuestController)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");
for(Runnable pulse:List.copyOf(f.pulses))pulse.run();
var compass=actor.core.inventory.getItem(0);
assertEquals(Material.COMPASS,compass.getType());
assertEquals("Quest Compass",net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(compass.getItemMeta().displayName()));
var tracker=org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(compass)
.get(net.minecraft.core.component.DataComponents.LODESTONE_TRACKER);
assertNotNull(tracker);assertFalse(tracker.tracked());assertTrue(tracker.target().isPresent());
assertEquals(new net.minecraft.core.BlockPos(10,69,-11),tracker.target().orElseThrow().pos());
verify(actor.player,never()).setCompassTarget(any(Location.class));
String lore=compass.getItemMeta().lore().stream().map(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText()::serialize).reduce("",(a,b)->a+"\n"+b);
assertTrue(lore.contains("Compass Camp"));assertTrue(lore.contains("Monster camp"));
assertTrue(lore.contains("World: Adventure Realm"));assertTrue(lore.contains("Distance: 15 blocks"));
assertTrue(lore.contains("Expires: "+quest.expiresAt()));
for(String hidden:List.of("(10, 69, -11)",targetWorld.toString(),site.id().toString(),quest.id().toString()))assertFalse(lore.contains(hidden));
assertEquals(List.of("Compass Camp — 15 blocks NE · ↑ +6"),actor.actionBars);
var use=new org.bukkit.event.player.PlayerInteractEvent(actor.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
compass,null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND);
f.dispatcher.accept(use);assertEquals(org.bukkit.event.Event.Result.DENY,use.useItemInHand());
var characters=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");
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.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.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.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());
int guidance=actor.actionBars.size();for(Runnable pulse:List.copyOf(f.pulses))pulse.run();
assertEquals(guidance,actor.actionBars.size(),"inside the authoritative arena suppresses guidance");
assertEquals(Material.COMPASS,actor.core.inventory.getItem(0).getType(),"arrival keeps the bound return compass");
actor.core.location.setX(site.x()+8);for(Runnable pulse:List.copyOf(f.pulses))pulse.run();
assertEquals(guidance+1,actor.actionBars.size());
var other=mock(World.class);when(other.getUID()).thenReturn(otherWorld);actor.core.location.setWorld(other);
f.players.put(targetWorldActor.core.id,targetWorldActor.player);
for(Runnable pulse:List.copyOf(f.pulses))pulse.run();
assertEquals("Travel to Adventure Realm",actor.actionBars.getLast());
f.players.remove(targetWorldActor.core.id);
for(Runnable pulse:List.copyOf(f.pulses))pulse.run();
assertEquals("Travel to unavailable world",actor.actionBars.getLast());
assertTrue(directory.abandon(quest.id(),quest.unit()).get(5,java.util.concurrent.TimeUnit.SECONDS));
for(Runnable pulse:List.copyOf(f.pulses))pulse.run();
assertEquals(Material.BOOK,actor.core.inventory.getItem(0).getType());
assertEquals(ordinaryCompass,actor.core.inventory.getItem(10));assertEquals(Material.DIAMOND,actor.core.cursor.getType());
verify(actor.world,never()).getChunkAt(anyInt(),anyInt());
assertTrue(directory.snapshot().tracking().isEmpty());
assertFalse(controller.trackingAvailable(actor.player));
}
}
@Test void activeQuestPaperClaimsBankedRewardWithoutAbandonAndRequiresRightClickToAbandon() 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 actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);UUID world=UUID.randomUUID();when(actor.world.getUID()).thenReturn(world);when(actor.world.getName()).thenReturn("Turn-in Realm");f.activate(actor);
var directory=(games.dmg.fantasystick.adventure.AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");var site=directory.placeSite(games.dmg.fantasystick.adventure.AdventureQuestState.SiteType.CAMP,"Partial Work",world,1,64,1).get(5,java.util.concurrent.TimeUnit.SECONDS);var catalog=directory.ensureToday().get(5,java.util.concurrent.TimeUnit.SECONDS);var quest=directory.accept(catalog.revision(),site.id(),games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,actor.core.record.id(),Set.of(actor.core.record.id())).get(5,java.util.concurrent.TimeUnit.SECONDS).quest().orElseThrow();assertTrue(directory.bank(quest.id(),UUID.randomUUID(),Map.of(actor.core.record.id(),new games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward(java.math.BigInteger.valueOf(5),java.math.BigInteger.valueOf(2)))).get(5,java.util.concurrent.TimeUnit.SECONDS));
var controller=(games.dmg.fantasystick.adventure.AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");int menus=actor.inventories.size();controller.open(actor.player);for(int retry=0;retry<10&&actor.inventories.size()==menus;retry++)drainOne(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));controller.click(click);for(int retry=0;retry<50&&directory.unclaimed(catalog.revision(),site.id(),actor.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);}assertTrue(directory.findQuest(quest.id()).isPresent(),"claiming partial rewards never abandons the active quest");
menus=actor.inventories.size();controller.open(actor.player);for(int retry=0;retry<10&&actor.inventories.size()==menus;retry++)drainOne(f);menu=actor.inventories.getLast();when(click.getInventory()).thenReturn(menu);when(click.getCurrentItem()).thenReturn(menu.getItem(0));controller.click(click);assertTrue(directory.findQuest(quest.id()).isPresent());assertTrue(actor.messages.getLast().contains("right-click"));when(click.isRightClick()).thenReturn(true);controller.click(click);for(int retry=0;retry<10&&directory.findQuest(quest.id()).isPresent();retry++)drainOne(f);assertTrue(directory.findQuest(quest.id()).isEmpty());var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var saved=storage.find(actor.core.record.id()).get().orElseThrow();assertEquals(java.math.BigInteger.valueOf(5),saved.experience());assertEquals(actor.core.record.coins().add(java.math.BigInteger.valueOf(2)),saved.coins());
}
}
@Test void dungeonTrackingTargetsOnlyEntranceAndSuppressesGuidanceInsideAnyRegisteredRoom() 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 actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);
UUID world=UUID.randomUUID();when(actor.world.getUID()).thenReturn(world);when(actor.world.getName()).thenReturn("Dungeon Realm");
actor.core.location.setX(0);actor.core.location.setY(60);actor.core.location.setZ(0);f.activate(actor);
var directory=(games.dmg.fantasystick.adventure.AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");
var entrance=new games.dmg.fantasystick.adventure.AdventureQuestState.Site(UUID.randomUUID(),games.dmg.fantasystick.adventure.AdventureQuestState.SiteType.DUNGEON,"Deep Keep",world,40,70,-20);
directory.registerDungeonSites(()->List.of(entrance));directory.registerDungeonAvailability(ignored->true);
var catalog=directory.ensureToday().get(5,java.util.concurrent.TimeUnit.SECONDS);
var quest=directory.accept(catalog.revision(),entrance.id(),games.dmg.fantasystick.adventure.AdventureQuestState.UnitType.SOLO,actor.core.record.id(),Set.of(actor.core.record.id())).get(5,java.util.concurrent.TimeUnit.SECONDS).quest().orElseThrow();
var controller=(games.dmg.fantasystick.adventure.AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");
var inside=new java.util.concurrent.atomic.AtomicBoolean(true);controller.registerDungeonRoomPresence((id,location)->id.equals(entrance.id())&&inside.get());
for(Runnable pulse:List.copyOf(f.pulses))pulse.run();
assertEquals(Material.COMPASS,actor.core.inventory.getItem(0).getType());assertTrue(actor.actionBars.isEmpty());
String visible=actor.core.inventory.getItem(0).getItemMeta().lore().stream().map(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText()::serialize).reduce("",String::concat);
assertTrue(visible.contains("Dungeon entrance"));assertTrue(visible.contains("World: Dungeon Realm"));
for(String hidden:List.of("(40, 70, -20)",world.toString(),entrance.id().toString(),quest.id().toString(),quest.catalog().toString(),quest.unit().toString(),actor.core.record.id().toString()))assertFalse(visible.contains(hidden));
inside.set(false);for(Runnable pulse:List.copyOf(f.pulses))pulse.run();
assertEquals(List.of("Deep Keep — 45 blocks NE · ↑ +10"),actor.actionBars);
assertTrue(directory.closeOffer(entrance.id()).get(5,java.util.concurrent.TimeUnit.SECONDS));
for(Runnable pulse:List.copyOf(f.pulses))pulse.run();
assertEquals(Material.BOOK,actor.core.inventory.getItem(0).getType());assertTrue(directory.snapshot().tracking().isEmpty());
}
}
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();}
}
@@ -28,6 +28,7 @@ class BasicAttackNativeTest {
var basic = new games.dmg.fantasystick.combat.BasicAttack(sessions, clock);
var target = mock(org.bukkit.entity.LivingEntity.class);
var cancelled = new java.util.concurrent.atomic.AtomicBoolean(true);
var zero = new java.util.concurrent.atomic.AtomicBoolean(false);
doAnswer(call -> {
var source = mock(org.bukkit.damage.DamageSource.class);
when(source.getCausingEntity()).thenReturn(a.player);
@@ -35,6 +36,7 @@ class BasicAttackNativeTest {
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, (Double) call.getArgument(0));
event.setCancelled(cancelled.get());
if (zero.get()) event.setDamage(0);
basic.acceptedDamage(event);
return null;
}).when(target).damage(anyDouble(), same(a.player));
@@ -43,12 +45,13 @@ class BasicAttackNativeTest {
games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, target, 0, 1,
() -> sessions.current(active));
basic.accept(input); // Cancelled: no cadence spent.
cancelled.set(false);
basic.accept(input); // Accepted at t=0.
cancelled.set(false); zero.set(true);
basic.accept(input); // Zero final damage: no cadence spent.
zero.set(false); basic.accept(input); // Accepted at t=0.
clock.advance(java.time.Duration.ofMillis(999)); basic.accept(input);
verify(target, times(2)).damage(1.0, a.player);
clock.advance(java.time.Duration.ofMillis(1)); basic.accept(input);
verify(target, times(3)).damage(1.0, a.player);
clock.advance(java.time.Duration.ofMillis(1)); basic.accept(input);
verify(target, times(4)).damage(1.0, a.player);
a.core.inventory.setItemInOffHand(null); f.changed(a);
clock.advance(java.time.Duration.ofSeconds(1)); basic.accept(input);
verifyNoMoreInteractions(target);
@@ -88,6 +91,32 @@ class BasicAttackNativeTest {
}
}
@Test void packagedBasicStickUsesBasePlusStrengthModifierAcrossApprovedBoundaries() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
int[] strengths = {0, 9, 10, 11, 12, 13, 14, 15};
double[] expected = {1, 1, 1, 1, 2, 2, 3, 3};
for (int index = 0; index < strengths.length; index++) {
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
withStrength(data, actor, java.math.BigInteger.valueOf(strengths[index]));
f.players.put(actor.core.id, actor.player); f.activate(actor);
actor.core.inventory.getInventory().setSelectedSlot(1);
var target = mock(org.bukkit.entity.LivingEntity.class);
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(actor.player, target, true);
f.dispatcher.accept(attack);
assertTrue(attack.isCancelled()); verify(target).damage(expected[index], actor.player);
actor.core.inventory.setItemInOffHand(null); f.changed(actor); f.awaitCharacterAndReceipt(actor);
}
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
withStrength(data, actor, java.math.BigInteger.TEN.pow(1000));
f.players.put(actor.core.id, actor.player); f.activate(actor);
actor.core.inventory.getInventory().setSelectedSlot(1);
var target = mock(org.bukkit.entity.LivingEntity.class);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(actor.player, target, true));
verify(target).damage(Double.MAX_VALUE, actor.player);
}
}
@Test void packagedBasicStickInputDealsOneLowStrengthHitWithoutVanillaContinuation() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
@@ -106,7 +135,20 @@ class BasicAttackNativeTest {
}
}
private static final class MutableClock extends Clock {
private static void withStrength(Path data, FantasyStickLifecycleNativeTest.Actor actor,
java.math.BigInteger strength) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
var stats = new java.util.EnumMap<>(old.stats());
stats.put(games.dmg.fantasystick.progression.Stat.STR, strength);
repository.replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(),
old.revision().add(java.math.BigInteger.ONE), stats, old.level(), old.experience(), old.unspentPoints(),
old.health(), old.learned(), old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(),
false, null, null, old.coins(), old.coinsInitialized(), old.rewardReceipts(), old.pvpCoinEscrow(), old.gear()),
old.revision());
}
static final class MutableClock extends Clock {
private Instant now;
MutableClock(Instant now) { this.now = now; }
void advance(java.time.Duration duration) { now = now.plus(duration); }
@@ -48,8 +48,16 @@ class ChainLightningNativeTest {
.when(caster.world).rayTraceEntities(any(Location.class), any(org.bukkit.util.Vector.class), eq(16.0),
org.mockito.ArgumentMatchers.<java.util.function.Predicate<org.bukkit.entity.Entity>>any());
doReturn(List.of(second)).when(first).getNearbyEntities(anyDouble(), anyDouble(), anyDouble()); doReturn(true).when(first).hasLineOfSight(second);
var viewer = new FantasyStickLifecycleNativeTest.Actor(data);
viewer.core.inventory.setItemInOffHand(null); viewer.core.location.setWorld(caster.world);
fixture.players.put(viewer.core.id, viewer.player);
fixture.particlesCommand.execute(caster.player, "fantasyparticles", new String[]{"all", "off"}); PartyBookNativeTest.drain(fixture);
caster.particleCalls.clear(); viewer.particleCalls.clear();
var damage = new LinkedHashMap<UUID, Double>(); nativeDamage(fixture, first, damage); nativeDamage(fixture, second, damage);
use(fixture, caster); drain(fixture, 2);
assertTrue(caster.particleCalls.isEmpty(), "The opted-out caster receives no Chain Lightning particles");
assertEquals(2, viewer.particleCalls.stream().filter(call -> call[0] == org.bukkit.Particle.ELECTRIC_SPARK
&& call[2].equals(18)).count(), "An enabled nearby viewer retains every chain burst");
assertFalse(damage.isEmpty(), caster.messages.toString());
assertEquals(13, damage.get(first.getUniqueId()), 1.0e-10); assertEquals(9.75, damage.get(second.getUniqueId()), 1.0e-10);
assertEquals(1, caster.messages.stream().filter("Chain Lightning did 13 damage to Zombie."::equals).count());
@@ -0,0 +1,55 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.util.*;
import org.bukkit.*;
import org.bukkit.attribute.Attribute;
import org.bukkit.attribute.AttributeInstance;
import org.bukkit.entity.*;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ChampionQuestNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@SuppressWarnings({"unchecked", "rawtypes"})
@Test void registeredChampionOfferNativeEncounterBossBarAndExactRewardUseConnectedPath() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"adventure-daily-site-count: 1\nadventure-site-reset-seconds: 0\n"+
"adventure-champion-health: 100\nadventure-champion-damage: 8\n"+
"adventure-champion-reward-xp: 150\nadventure-champion-reward-coins: 40\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 worldId=UUID.randomUUID();when(actor.world.getUID()).thenReturn(worldId);when(actor.world.isChunkLoaded(anyInt(),anyInt())).thenReturn(true);int chunkX=actor.core.location.getBlockX()>>4,chunkZ=actor.core.location.getBlockZ()>>4;var chunk=mock(Chunk.class);when(chunk.getWorld()).thenReturn(actor.world);when(chunk.getX()).thenReturn(chunkX);when(chunk.getZ()).thenReturn(chunkZ);when(chunk.getEntities()).thenReturn(new Entity[0]);when(actor.world.getChunkAt(anyInt(),anyInt())).thenReturn(chunk);when(actor.world.getLoadedChunks()).thenReturn(new Chunk[]{chunk});
var spawned=new ArrayList<LivingEntity>();var maxHealth=mock(AttributeInstance.class);when(maxHealth.getValue()).thenReturn(100.0);var attack=mock(AttributeInstance.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.getWorld()).thenReturn(actor.world);when(entity.getLocation()).thenReturn(((Location)call.getArgument(0)).clone());when(entity.getHealth()).thenReturn(100.0);if(entity instanceof Vindicator){when(entity.getAttribute(Attribute.MAX_HEALTH)).thenReturn(maxHealth);when(entity.getAttribute(Attribute.ATTACK_DAMAGE)).thenReturn(attack);}((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));
assertEquals(List.of("adventure-champion-damage","adventure-champion-health","adventure-champion-reward-coins","adventure-champion-reward-xp"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"config","adventure-champion"}));
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 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());
@@ -334,26 +334,60 @@ class CharacterBookNativeTest {
assertTrue(pages.get(2).getString().contains("3: No Ability"));
int index = firstPageStarting(pages, "Ability index");
assertTrue(index > 2);
var indexedNames = new java.util.ArrayList<String>();
var categorized = new java.util.LinkedHashMap<String, java.util.List<String>>();
var indexPageByAbility = new java.util.HashMap<String, Integer>();
int partyPage = firstPageStarting(pages, "Party");
for (int page = index; page < pages.size() && pages.get(page).getString().startsWith("Ability index"); page++) {
for (String line : pages.get(page).getString().split("\\n")) if (!line.isBlank() && !line.equals("Ability index")) {
indexedNames.add(line);
var jump = changePage(pages.get(page), line);
assertNotNull(jump, line);
var lines = pages.get(page).getString().split("\\n");
assertTrue(lines.length >= 2); String category = lines[1];
assertTrue(java.util.List.of("ATK", "BUF", "HEAL").contains(category));
var names = categorized.computeIfAbsent(category, ignored -> new java.util.ArrayList<>());
for (int line = 2; line < lines.length; line++) if (!lines[line].isBlank()) {
names.add(lines[line]); indexPageByAbility.put(lines[line], page);
var jump = changePage(pages.get(page), lines[line]);
assertNotNull(jump, lines[line]);
assertTrue(jump.page() - 1 < partyPage, "Ability details must precede party controls");
}
}
var sortedNames = indexedNames.stream().sorted().toList();
assertEquals(sortedNames, indexedNames);
assertEquals(20, indexedNames.size());
assertEquals(java.util.List.of("ATK", "BUF", "HEAL"), new java.util.ArrayList<>(categorized.keySet()));
assertEquals(java.util.List.of("Chain Lightning", "Fireball", "Frost Nova", "Frost Shard", "Great Sword",
"Hex", "Longbow", "Meteor", "Shield Bash"), categorized.get("ATK"));
assertEquals(java.util.List.of("Guardian Bond", "Inspire", "Purify", "Quickstep", "Rallying Cry",
"Stone Skin", "War Banner"), categorized.get("BUF"));
assertEquals(java.util.List.of("Mending Light", "Revive", "Sanctuary", "Second Wind"), categorized.get("HEAL"));
assertEquals(20, categorized.values().stream().mapToInt(java.util.List::size).sum());
var detailStartByAbility = new java.util.HashMap<String, Integer>();
for (var entry : indexPageByAbility.entrySet()) {
var jump = changePage(pages.get(entry.getValue()), entry.getKey()); assertNotNull(jump);
detailStartByAbility.put(entry.getKey(), jump.page() - 1);
}
int passiveIndex = firstPageStarting(pages, "Passive index");
int firstPassiveDetail = Integer.MAX_VALUE;
for (int page = passiveIndex; page < pages.size() && pages.get(page).getString().startsWith("Passive index"); page++) {
for (String line : pages.get(page).getString().split("\\n")) if (!line.isBlank() && !line.equals("Passive index")) {
var jump = changePage(pages.get(page), line); if (jump != null) firstPassiveDetail = Math.min(firstPassiveDetail, jump.page() - 1);
}
}
for (var entry : indexPageByAbility.entrySet()) {
int detailPage = detailStartByAbility.get(entry.getKey());
assertTrue(pages.get(detailPage).getString().startsWith(entry.getKey() + "\n"));
assertTrue(pages.get(detailPage).getString().split("\\n")[1].matches("ATK|BUF|HEAL"));
int end = firstPassiveDetail;
for (int other : detailStartByAbility.values()) if (other > detailPage) end = Math.min(end, other);
var backPages = new java.util.ArrayList<Integer>();
for (int candidate = detailPage; candidate < end; candidate++)
if (changePage(pages.get(candidate), "[Back to index]") != null) backPages.add(candidate);
assertEquals(java.util.List.of(end - 1), backPages, entry.getKey());
var back = changePage(pages.get(end - 1), "[Back to index]"); assertNotNull(back);
assertEquals(entry.getValue() + 1, back.page(), entry.getKey());
}
var fireballJump = changePage(pages.get(index), "Fireball");
assertNotNull(fireballJump);
var detail = pages.get(fireballJump.page() - 1);
assertTrue(detail.getString().startsWith("Fireball\nINT 12\n[Learn]"), detail.getString());
assertTrue(detail.getString().startsWith("Fireball\nATK\nINT 12\n[Learn]"), detail.getString());
assertFalse(text(a.books.getLast()).contains("Cost:"));
assertFalse(text(a.books.getLast()).contains("Requires:"));
assertTrue(text(a.books.getLast()).contains("Shield Bash\nSTR 12, CON 12"));
assertTrue(text(a.books.getLast()).contains("Shield Bash\nATK\nSTR 12, CON 12"));
assertFalse(detail.getString().contains("Icon:"));
assertFalse(text(a.books.getLast()).contains("Assign ability"));
assertFalse(text(a.books.getLast()).contains("[3]"));
@@ -366,7 +400,29 @@ class CharacterBookNativeTest {
assertTrue(refreshed.get(2).getString().contains("3: Fireball"));
var activeJump = changePage(refreshed.get(2), "Fireball");
assertNotNull(activeJump);
assertTrue(refreshed.get(activeJump.page() - 1).getString().startsWith("Fireball\nINT 12\n[Unlearn]"));
assertTrue(refreshed.get(activeJump.page() - 1).getString().startsWith("Fireball\nATK\nINT 12\n[Unlearn]"));
}
}
@Test void mixedCategorySlotsAndUnknownIdsRemainUnrestrictedAndUnchanged() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(a.core.record.id()).orElseThrow();
var mixed = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
old.stats(), old.level(), old.experience(), old.unspentPoints(), old.health(),
java.util.Set.of("fireball", "quickstep", "second_wind", "unknown_saved"),
java.util.List.of("fireball", "quickstep", "second_wind", "unknown_saved", "", "", ""),
old.cooldowns(), old.restrictedKills(), false, null, null);
repository.replace(mixed, old.revision()); f.players.put(a.core.id, a.player); f.activate(a); PartyBookNativeTest.open(f, a);
var pages = nativePages(a); var active = pages.get(firstPageStarting(pages, "Active abilities"));
assertTrue(active.getString().contains("3: Fireball")); assertTrue(active.getString().contains("4: Quickstep"));
assertTrue(active.getString().contains("5: Second Wind")); assertTrue(active.getString().contains("6: unknown_saved"));
for (String ability : java.util.List.of("Fireball", "Quickstep", "Second Wind")) {
var jump = changePage(active, ability); assertNotNull(jump, ability);
assertTrue(pages.get(jump.page() - 1).getString().startsWith(ability + "\n"));
}
assertEquals(mixed, characters(f).find(mixed.id()).get(5, TimeUnit.SECONDS).orElseThrow());
}
}
@@ -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);
}
}
@@ -32,6 +32,167 @@ class CombatExperienceNativeTest {
games.dmg.fantasystick.party.CombatExperience.coinTarget(wither));
assertEquals(games.dmg.fantasystick.progression.CombatRewardRules.Target.ORDINARY,
games.dmg.fantasystick.party.CombatExperience.classify(wither));
var witch = type(EntityType.WITCH);
assertEquals(games.dmg.fantasystick.progression.CombatRewardRules.Target.HARD,
games.dmg.fantasystick.party.CombatExperience.classify(witch));
assertEquals(games.dmg.fantasystick.progression.CoinRules.Target.HOSTILE,
games.dmg.fantasystick.party.CombatExperience.coinTarget(witch));
}
@Test void tooCloseHostileDeathDiscardsItsAllocatedCoinsAndPublishesNoCoinFeedback() 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 actor = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(actor.core.id, actor.player); f.activate(actor);
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
var first = mob(EntityType.ZOMBIE, new Location(world, 0, 64, 0));
hit(f, actor.player, first, false); die(f, first); settle(f);
var eligible = record(f, actor);
int earnedCoinMessages = (int) actor.messages.stream().filter(message -> message.contains("character coins")).count();
assertEquals(1, earnedCoinMessages);
var near = mob(EntityType.ZOMBIE, new Location(world, 1, 64, 0));
hit(f, actor.player, near, false); die(f, near); settle(f);
var blocked = record(f, actor);
assertEquals(eligible.experience(), blocked.experience());
assertEquals(eligible.coins(), blocked.coins(), "Spacing-blocked combat coins are discarded");
assertEquals(earnedCoinMessages,
actor.messages.stream().filter(message -> message.contains("character coins")).count());
assertTrue(actor.messages.stream().anyMatch(message -> message.contains("too close")));
assertEquals(new java.math.BigDecimal("1.0"), blocked.restrictedKills().values().iterator().next().x(),
"Blocked attempts still advance the spacing reference");
}
}
@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"
+ "boss-coin-drop-min: 12\nboss-coin-drop-max: 12\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var types = java.util.List.of(EntityType.ZOMBIE, EntityType.BEE, EntityType.WITCH,
EntityType.WITHER, EntityType.WARDEN, EntityType.ENDER_DRAGON);
for (var type : types) {
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(actor.core.id, actor.player); f.activate(actor);
var firstWorld = mock(org.bukkit.World.class); when(firstWorld.getUID()).thenReturn(UUID.randomUUID());
var otherWorld = mock(org.bukkit.World.class); when(otherWorld.getUID()).thenReturn(UUID.randomUUID());
var initial = record(f, actor);
var first = mob(type, new Location(firstWorld, 0, 64, 0));
hit(f, actor.player, first, false); die(f, first); settle(f);
var eligible = record(f, actor);
var expectedCoins = type == EntityType.BEE ? java.math.BigInteger.ZERO : java.math.BigInteger.valueOf(12);
assertEquals(initial.coins().add(expectedCoins), eligible.coins(), type + " first kill");
int coinMessages = (int) actor.messages.stream().filter(message -> message.contains("character coins")).count();
var near = mob(type, new Location(firstWorld, 1, 64, 0));
hit(f, actor.player, near, false); die(f, near); settle(f);
var blocked = record(f, actor);
assertEquals(eligible.experience(), blocked.experience(), type + " near XP");
assertEquals(eligible.coins(), blocked.coins(), type + " near coins");
assertEquals(coinMessages,
actor.messages.stream().filter(message -> message.contains("character coins")).count(), type + " feedback");
assertEquals(new java.math.BigDecimal("1.0"), blocked.restrictedKills().get(firstWorld.getUID()).x());
var exact = mob(type, new Location(firstWorld, 4, 64, 0));
hit(f, actor.player, exact, false); die(f, exact); settle(f);
var boundary = record(f, actor);
assertTrue(boundary.experience().compareTo(blocked.experience()) > 0, type + " exact distance XP");
assertEquals(blocked.coins().add(expectedCoins), boundary.coins(), type + " exact distance coins");
var acrossWorld = mob(type, new Location(otherWorld, 0, 64, 0));
hit(f, actor.player, acrossWorld, false); die(f, acrossWorld); settle(f);
var independent = record(f, actor);
assertTrue(independent.experience().compareTo(boundary.experience()) > 0, type + " cross-world XP");
assertEquals(boundary.coins().add(expectedCoins), independent.coins(), type + " cross-world coins");
assertEquals(2, independent.restrictedKills().size());
}
}
}
@Test void partyRecipientsApplyIndependentSpacingWithoutRedistributingBlockedShares() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"hostile-coin-drop-min: 20\nhostile-coin-drop-max: 20\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.activate(a); f.activate(b);
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
var history = mob(EntityType.ZOMBIE, new Location(world, 0, 64, 0));
hit(f, a.player, history, false); die(f, history); settle(f);
var beforeA = record(f, a); var beforeB = record(f, b);
var parties = PartyLifecycleNativeTest.parties(f); var party = parties.create(a.core.id).orElseThrow();
assertTrue(parties.accept(b.core.id, parties.invite(a.core.id, party.id(), b.core.id).orElseThrow().id()));
var shared = mob(EntityType.ZOMBIE, new Location(world, 1, 64, 0));
hit(f, a.player, shared, false); die(f, shared); settle(f); settle(f);
var afterA = record(f, a); var afterB = record(f, b);
assertEquals(beforeA.experience(), afterA.experience());
assertEquals(beforeA.coins(), afterA.coins(), "A's blocked half is discarded");
assertEquals(beforeB.experience().add(java.math.BigInteger.valueOf(5)), afterB.experience());
assertEquals(beforeB.coins().add(java.math.BigInteger.TEN), afterB.coins(), "B retains only B's half");
assertEquals(new java.math.BigDecimal("1.0"), afterA.restrictedKills().get(world.getUID()).x());
assertEquals(new java.math.BigDecimal("1.0"), afterB.restrictedKills().get(world.getUID()).x());
assertTrue(a.messages.stream().anyMatch(message -> message.contains("too close")));
assertTrue(b.messages.stream().anyMatch(message -> message.contains("10 character coins")));
}
}
@Test void packagedWitchPoolFlowsThroughExactSoloAndPartyAllocationWithOrdinaryCoins() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var solo = new FantasyStickLifecycleNativeTest.Actor(data);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
var c = new FantasyStickLifecycleNativeTest.Actor(data);
for (var actor : java.util.List.of(solo, a, b, c)) { f.players.put(actor.core.id, actor.player); f.activate(actor); }
var parties = PartyLifecycleNativeTest.parties(f); var party = parties.create(a.core.id).orElseThrow();
assertTrue(parties.accept(b.core.id, parties.invite(a.core.id, party.id(), b.core.id).orElseThrow().id()));
assertTrue(parties.accept(c.core.id, parties.invite(a.core.id, party.id(), c.core.id).orElseThrow().id()));
var beforeCoins = record(f, solo).coins().add(record(f, a).coins())
.add(record(f, b).coins()).add(record(f, c).coins());
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
var witch = mob(EntityType.WITCH, new Location(world, 10, 64, 0));
hit(f, solo.player, witch, false);
var projectile = PartyCombatNativeTest.projectile(f, a.player); hit(f, projectile, witch, false);
die(f, witch); settle(f); settle(f); settle(f);
assertEquals(java.math.BigInteger.TEN, record(f, solo).experience());
var partyXp = java.util.List.of(record(f, a).experience(), record(f, b).experience(), record(f, c).experience());
assertEquals(java.math.BigInteger.TEN,
partyXp.stream().reduce(java.math.BigInteger.ZERO, java.math.BigInteger::add));
assertEquals(java.util.List.of(java.math.BigInteger.valueOf(3), java.math.BigInteger.valueOf(3),
java.math.BigInteger.valueOf(4)), partyXp.stream().sorted().toList());
var afterCoins = record(f, solo).coins().add(record(f, a).coins())
.add(record(f, b).coins()).add(record(f, c).coins());
var delta = afterCoins.subtract(beforeCoins);
assertTrue(delta.compareTo(java.math.BigInteger.ONE) >= 0 && delta.compareTo(java.math.BigInteger.valueOf(5)) <= 0,
"Witch uses one ordinary hostile coin pool");
assertTrue(solo.messages.stream().anyMatch(message -> message.contains("10") && message.contains("XP")));
solo.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, solo);
assertTrue(PartyBookNativeTest.text(solo).contains("XP: 10/100"));
die(f, witch); settle(f);
assertEquals(java.math.BigInteger.TEN, record(f, solo).experience(), "Duplicate death callbacks are consumed once");
}
}
@Test void packagedBasicAttackToBossDeathPersistsLevelAndUpdatesBookFeedbackExactlyOnce() throws Exception {
@@ -74,9 +235,7 @@ class CombatExperienceNativeTest {
assertEquals(17, death.getDroppedExp(), "Fantasy rewards never replace native Minecraft XP");
assertTrue(a.messages.stream().anyMatch(message -> message.contains("100") && message.contains("XP")));
assertTrue(a.messages.stream().anyMatch(message -> message.contains("Level 2")));
assertTrue(mockingDetails(a.world).getInvocations().stream().anyMatch(invocation ->
invocation.getMethod().getName().equals("spawnParticle")
&& java.util.Arrays.asList(invocation.getArguments()).contains(12)));
assertTrue(a.particleCalls.stream().anyMatch(call -> java.util.Arrays.asList(call).contains(12)));
assertFalse(a.sounds.isEmpty());
a.core.inventory.getInventory().setSelectedSlot(0);
@@ -189,40 +348,45 @@ class CombatExperienceNativeTest {
var original = new games.dmg.fantasystick.crafting.CharacterFactory().create(progression);
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(UUID.randomUUID(), UUID.randomUUID(), original.id());
var attacker = mock(org.bukkit.entity.Player.class);
when(attacker.getUniqueId()).thenReturn(active.player());
when(attacker.getUniqueId()).thenReturn(active.player()); when(attacker.isOnline()).thenReturn(true);
when(sessions.active(active.player())).thenReturn(java.util.Optional.of(active));
when(sessions.activeCharacter(active.character())).thenReturn(java.util.Optional.of(active));
when(sessions.current(active)).thenReturn(true);
when(sessions.acknowledgeProgress(eq(active), any(), any())).thenReturn(true);
when(storage.find(original.id())).thenReturn(java.util.concurrent.CompletableFuture.completedFuture(java.util.Optional.of(original)));
var failed = new java.util.concurrent.CompletableFuture<games.dmg.fantasystick.persistence.CharacterStorage.CombatAward>();
failed.completeExceptionally(new java.io.IOException("simulated write failure"));
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(original.id(), original.name(), java.math.BigInteger.ONE,
original.stats(), original.level(), java.math.BigInteger.TEN, original.unspentPoints(), original.health(),
original.stats(), original.level(), java.math.BigInteger.valueOf(20), original.unspentPoints(), original.health(),
original.learned(), original.loadout(), original.cooldowns(), original.restrictedKills(), false, null, null);
var success = java.util.concurrent.CompletableFuture.completedFuture(
new games.dmg.fantasystick.persistence.CharacterStorage.CombatAward(
games.dmg.fantasystick.persistence.CharacterStorage.CombatAwardStatus.APPLIED, java.util.Optional.of(saved), 0));
var writes = new java.util.concurrent.atomic.AtomicInteger();
when(storage.awardCombatReward(eq(original.id()), any(), any(), eq(java.math.BigInteger.TEN), any(),
when(storage.awardCombatReward(eq(original.id()), any(), any(), eq(java.math.BigInteger.valueOf(20)), any(),
same(progression), any(), any())).thenAnswer(ignored -> writes.getAndIncrement() == 0 ? failed : success);
var failures = new ArrayList<games.dmg.fantasystick.party.CombatExperience.Failure>();
var service = new games.dmg.fantasystick.party.CombatExperience(storage, progression,
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.ZOMBIE, new Location(world, 0, 64, 0));
var target = mob(EntityType.WITCH, new Location(world, 0, 64, 0));
var damageSource = mock(org.bukkit.damage.DamageSource.class);
when(damageSource.getDirectEntity()).thenReturn(attacker); when(damageSource.getCausingEntity()).thenReturn(attacker);
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());
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"));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());
verify(attacker, times(1)).sendMessage(contains("character coins"));
service.close();
}
@@ -261,6 +425,40 @@ class CombatExperienceNativeTest {
}
}
@Test void hardMobXpCommandPersistsBeforePublishingAndAffectsOnlyFutureWitchDefeats() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"hard-mob-xp: 25\nunrelated: retained\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = true;
assertEquals(java.util.List.of("hard-mob-xp"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "hard"}));
assertEquals(java.util.List.of("20", "25"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "hard-mob-xp", ""}));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "hard-mob-xp"});
assertTrue(sender.messages.getLast().contains("=25"));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "hard-mob-xp", "2147483648"});
assertTrue(sender.messages.getLast().contains("invalid"));
var service = (games.dmg.fantasystick.party.CombatExperience)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "combatExperience");
assertEquals(java.math.BigInteger.valueOf(25), service.rules().hardPool());
f.command.execute(sender.port, "fantasystick", new String[]{"config", "hard-mob-xp", "30"});
Runnable response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response);
assertEquals(java.math.BigInteger.valueOf(25), service.rules().hardPool(), "Durable write precedes publication");
response.run(); assertEquals(java.math.BigInteger.valueOf(30), service.rules().hardPool());
var yaml = (java.util.Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(
java.nio.file.Files.readString(data.resolve("config.yml")));
assertEquals("retained", yaml.get("unrelated")); assertEquals("30", String.valueOf(yaml.get("hard-mob-xp")));
var actor = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(actor.core.id, actor.player); f.activate(actor);
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
var witch = mob(EntityType.WITCH, new Location(world, 0, 64, 0));
hit(f, actor.player, witch, false); die(f, witch); settle(f);
assertEquals(java.math.BigInteger.valueOf(30), record(f, actor).experience());
sender.allowed = false;
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "hard"}).isEmpty());
}
}
@Test void interceptedFantasyDefeatUsesVictimLevelPoolAndCancelledDamageCreatesNoCredit() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
@@ -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());
@@ -0,0 +1,135 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.adventure.AdventureQuestController;
import games.dmg.fantasystick.adventure.AdventureQuestDirectory;
import games.dmg.fantasystick.adventure.AdventureQuestState;
import games.dmg.fantasystick.dungeon.DungeonDirectory;
import games.dmg.fantasystick.dungeon.DungeonState;
import games.dmg.fantasystick.persistence.CharacterStorage;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.TimeUnit;
import org.bukkit.*;
import org.bukkit.entity.*;
import org.bukkit.event.entity.*;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.ItemStack;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.io.TempDir;
class DungeonNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredDungeonTeleportUsesExactEntranceForDisabledAndEnabledDefinitions() 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);var destination=new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(admin.core.id,admin.player);f.players.put(destination.core.id,destination.player);
doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
UUID startWorld=UUID.randomUUID(),targetWorld=UUID.randomUUID();when(admin.world.getUID()).thenReturn(startWorld);
when(destination.world.getUID()).thenReturn(targetWorld);when(destination.world.getName()).thenReturn("Dungeon Realm");
var directory=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");
var disabled=directory.create("Disabled Vault",targetWorld,34.75,66.5,18.25,91.5f,-7.25f).get(5,TimeUnit.SECONDS);
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());
doAnswer(call->{teleports.add(((Location)call.getArgument(0)).clone());return java.util.concurrent.CompletableFuture.completedFuture(true);})
.when(admin.player).teleportAsync(any(Location.class));
assertTrue(f.command.tabComplete(admin.player,"fantasystick",new String[]{"dungeon",""}).contains("teleport"));
assertEquals(List.of(disabled.id().toString(),enabled.id().toString()).stream().sorted().toList(),
f.command.tabComplete(admin.player,"fantasystick",new String[]{"dungeon","teleport",""}));
f.command.execute(admin.player,"fantasystick",new String[]{"dungeon","teleport",disabled.id().toString()});drain(f);drain(f);
var first=teleports.getFirst();assertSame(destination.world,first.getWorld());assertEquals(34.75,first.getX());assertEquals(66.5,first.getY());assertEquals(18.25,first.getZ());assertEquals(91.5f,first.getYaw());assertEquals(-7.25f,first.getPitch());
assertEquals("Teleported to Disabled Vault (dungeon entrance) at Dungeon Realm (34, 66, 18).",admin.messages.getLast());
f.command.execute(admin.player,"fantasystick",new String[]{"dungeon","teleport",enabled.id().toString()});drain(f);drain(f);
var second=teleports.getLast();assertEquals(-20.5,second.getX());assertEquals(55.25,second.getY());assertEquals(-33.75,second.getZ());assertEquals(-135f,second.getYaw());assertEquals(22f,second.getPitch());
assertTrue(admin.messages.getLast().contains("Enabled Depths (dungeon entrance) at Dungeon Realm (-21, 55, -34)"));
assertEquals(before,directory.snapshot());
verify(destination.world).getChunkAtAsync(2,1);verify(destination.world).getChunkAtAsync(-2,-3);
}
}
@SuppressWarnings({"unchecked","rawtypes"})
@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.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","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");
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","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(30),BigInteger.valueOf(5)),adventures.reward(adventures.findSite(dungeon.id()).orElseThrow()));
f.activate(actor);var questController=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");questController.open(actor.player);drain(f);var menu=actor.inventories.getLast();assertEquals(Material.PAPER,menu.getItem(0).getType());var offerLore=menu.getItem(0).getItemMeta().lore().toString();assertTrue(offerLore.contains("Completion trophy pool: 30 XP, 5 coins"));assertTrue(offerLore.contains("additional variable XP, coins and native loot"));var click=mock(InventoryClickEvent.class);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getInventory()).thenReturn(menu);when(click.getCurrentItem()).thenReturn(menu.getItem(0));questController.click(click);drain(f);assertEquals(dungeon.id(),adventures.active().getFirst().site());var partyA=new FantasyStickLifecycleNativeTest.Actor(data);var partyB=new FantasyStickLifecycleNativeTest.Actor(data);for(var member:List.of(partyA,partyB)){member.core.location.setWorld(actor.world);member.core.location.setX(100);f.players.put(member.core.id,member.player);doReturn(java.util.Set.of()).when(member.player).getActivePotionEffects();doReturn(true).when(member.player).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());f.activate(member);}var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(partyA.core.id).orElseThrow();assertTrue(parties.accept(partyB.core.id,parties.invite(partyA.core.id,party.id(),partyB.core.id).orElseThrow().id()));var catalog=adventures.snapshot().catalog();assertEquals(AdventureQuestDirectory.Acceptance.APPLIED,adventures.accept(catalog.revision(),dungeon.id(),AdventureQuestState.UnitType.PARTY,party.id(),Set.of(partyA.core.record.id(),partyB.core.record.id())).get(5,TimeUnit.SECONDS).status());
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var before=storage.find(actor.core.record.id()).get().orElseThrow();var partyABefore=storage.find(partyA.core.record.id()).get().orElseThrow();var partyBBefore=storage.find(partyB.core.record.id()).get().orElseThrow();for(var member:List.of(actor,partyA)){member.core.location.setX(first.bounds().minX()+.5);member.core.location.setY(first.bounds().minY()+1);member.core.location.setZ(first.bounds().minZ()+.5);}
pulse(f);drain(f);pulse(f);drain(f);var initialMobs=spawned.stream().filter(Husk.class::isInstance).toList();assertEquals(2,initialMobs.size());var ordinary=initialMobs.getFirst();assertEquals(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,dungeonController.encounterOrigin(ordinary).orElseThrow().kind());var survivor=initialMobs.getLast();assertNotEquals(dungeonController.encounterOrigin(ordinary),dungeonController.encounterOrigin(survivor),"each slot has independent kill authority");var survivorOrigin=dungeonController.encounterOrigin(survivor);var source=mock(org.bukkit.damage.DamageSource.class);when(source.getCausingEntity()).thenReturn(actor.player);when(source.getDirectEntity()).thenReturn(actor.player);var partySource=mock(org.bukkit.damage.DamageSource.class);when(partySource.getCausingEntity()).thenReturn(partyA.player);when(partySource.getDirectEntity()).thenReturn(partyA.player);f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,ordinary,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,ordinary,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));var drops=new ArrayList<ItemStack>();drops.add(new ItemStack(Material.ROTTEN_FLESH));var ordinaryDeath=new EntityDeathEvent(ordinary,source,drops,7);f.dispatcher.accept(ordinaryDeath);await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).map(run->run.defeatedSlots().size()==1).orElse(false),actor.messages);assertEquals(7,ordinaryDeath.getDroppedExp());assertFalse(ordinaryDeath.getDrops().isEmpty());var partial=dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow();assertEquals(1,partial.defeatedSlots().size());assertFalse(partial.clearedRooms().contains(first.id()));for(var member:List.of(actor,partyA))member.core.location.setX(100);pulse(f);verify(survivor,atLeastOnce()).remove();assertFalse(actorEffects.containsKey(org.bukkit.potion.PotionEffectType.SPEED));for(var member:List.of(actor,partyA)){member.core.location.setX(first.bounds().minX()+.5);member.core.location.setY(first.bounds().minY()+1);member.core.location.setZ(first.bounds().minZ()+.5);}pulse(f);drain(f);var respawned=spawned.stream().filter(Husk.class::isInstance).toList().getLast();assertNotSame(survivor,respawned);assertEquals(survivorOrigin,dungeonController.encounterOrigin(respawned),"respawned slot retains duplicate-safe kill authority");f.dispatcher.accept(new EntityDamageByEntityEvent(partyA.player,respawned,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,partySource,1));f.dispatcher.accept(new EntityDamageByEntityEvent(actor.player,respawned,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,1));f.dispatcher.accept(new EntityDeathEvent(respawned,source,new ArrayList<>(),0));await(f,()->dungeons.runView(dungeon.id(),adventures.snapshot().catalog().revision()).orElseThrow().clearedRooms().contains(first.id()),actor.messages);assertTrue(storage.find(partyB.core.record.id()).get().orElseThrow().experience().compareTo(partyBBefore.experience())>0,"ordinary party combat sharing remains active");
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
}
}
@Test void roomCreationReportsOverlapDistanceLimitsAndPersistenceSeparately() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),"adventure-daily-site-count: 0\ndungeon-room-link-distance: 10\n");
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
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");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);
f.command.execute(actor.player,"fantasystick",new String[]{"config","dungeon-room-link-distance","11"});var settings=(games.dmg.fantasystick.persistence.AdventureQuestSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestSettings");await(f,()->settings.rules().dungeonRoomLinkDistance()==11,actor.messages);command(f,actor,"dungeon","room","create","corner","1","1","1","Now Linked");await(f,()->dungeons.find(dungeon.id()).orElseThrow().rooms().size()==2,actor.messages);var linked=dungeons.find(dungeon.id()).orElseThrow().rooms().getLast();
command(f,actor,"dungeon","room","create","corner","257","1","1","Too Large");await(f,()->actor.messages.stream().anyMatch(message->message.contains("dimensions or room limit")),actor.messages);
var file=data.resolve("dungeons.yml");java.nio.file.Files.delete(file);java.nio.file.Files.createDirectory(file);actor.core.location.setX(linked.bounds().maxX()+11.1);command(f,actor,"dungeon","room","create","corner","1","1","1","Unsaved");await(f,()->actor.messages.stream().anyMatch(message->message.contains("persistence is unavailable")),actor.messages);assertEquals(2,dungeons.find(dungeon.id()).orElseThrow().rooms().size());
}
}
@Test void authenticSheetAssignmentBlocksActivationAndRecoveryReturnsExactCharacter() throws Exception {
var root=data.resolve("custody");java.nio.file.Files.createDirectories(root);
try(var f=new FantasyStickLifecycleNativeTest.Fixture(root)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(root);f.players.put(actor.core.id,actor.player);doReturn(true).when(actor.player).hasPermission("fantasystick.admin");UUID world=UUID.randomUUID();when(actor.world.getUID()).thenReturn(world);var dungeons=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var dungeon=dungeons.create("Custody",world,0,64,0,0,0).get(5,TimeUnit.SECONDS);var room=dungeons.addRadiusRoom(dungeon.id(),"Boss",0,64,0,3,3).get(5,TimeUnit.SECONDS);dungeons.setBoss(dungeon.id(),room.id(),EntityType.HUSK,"Temporary",0,65,0,0,0).get(5,TimeUnit.SECONDS);command(f,actor,"dungeon","edit","start",dungeon.id().toString());var controller=(games.dmg.fantasystick.dungeon.DungeonController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeonController");await(f,()->controller.editing(actor.core.id).isPresent(),actor.messages);actor.core.location.setX(.5);actor.core.location.setY(65);actor.core.location.setZ(.5);assertEquals(List.of("assign-sheet"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"dungeon","room","boss","ass"}));assertEquals(List.of("0.1","1","1.5","10","100"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"dungeon","room","boss","assign-sheet",""}));var sheet=actor.core.inventory.getItemInOffHand();actor.core.inventory.setItemInOffHand(null);actor.core.inventory.setItemInMainHand(sheet);actor.core.inventory.setItem(5,sheet.clone());command(f,actor,"dungeon","room","boss","assign-sheet","1.125");assertTrue(dungeons.custody(actor.core.record.id()).isEmpty());actor.core.inventory.setItem(5,null);var partyDirectory=(games.dmg.fantasystick.party.CharacterPartyDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterParties");var forbiddenParty=partyDirectory.create(actor.core.record.id(),"Before Custody").get(5,TimeUnit.SECONDS).party().orElseThrow();command(f,actor,"dungeon","room","boss","assign-sheet","1.125");assertTrue(dungeons.custody(actor.core.record.id()).isEmpty());assertFalse(actor.core.inventory.getItemInMainHand().isEmpty());partyDirectory.disband(actor.core.record.id(),forbiddenParty.id()).get(5,TimeUnit.SECONDS);command(f,actor,"dungeon","room","boss","assign-sheet","1.125");await(f,()->dungeons.custody(actor.core.record.id()).isPresent(),actor.messages);assertTrue(actor.core.inventory.getItemInMainHand().isEmpty());assertEquals(actor.core.record,dungeons.custody(actor.core.record.id()).orElseThrow().character());actor.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(actor.core.record));var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");sessions.enter(actor.player);assertTrue(sessions.active(actor.core.id).isEmpty());assertTrue(actor.messages.stream().anyMatch(message->message.contains("dungeon boss")));var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertThrows(java.util.concurrent.ExecutionException.class,()->storage.awardExperience(actor.core.record.id(),actor.core.record.revision(),BigInteger.ONE,games.dmg.fantasystick.progression.ProgressionRules.defaults()).get(5,TimeUnit.SECONDS));assertEquals(games.dmg.fantasystick.party.CharacterPartyDirectory.Status.INVALID,partyDirectory.create(actor.core.record.id(),"Forbidden").get(5,TimeUnit.SECONDS).status());actor.core.inventory.setItemInOffHand(null);actor.core.location.setX(100);command(f,actor,"dungeon","edit","done");assertEquals(List.of(dungeon.id().toString()),f.command.tabComplete(actor.player,"fantasystick",new String[]{"dungeon","boss","recover-sheet",""}));command(f,actor,"dungeon","boss","recover-sheet",dungeon.id().toString());await(f,()->dungeons.custody(actor.core.record.id()).isEmpty(),actor.messages);var returned=Arrays.stream(actor.core.inventory.getContents()).filter(Objects::nonNull).filter(item->new games.dmg.fantasystick.items.CharacterSheets().identify(item).filter(actor.core.record.id()::equals).isPresent()).toList();assertEquals(1,returned.size());assertEquals(actor.core.record,storage.find(actor.core.record.id()).get(5,TimeUnit.SECONDS).orElseThrow());assertNull(dungeons.room(dungeon.id(),room.id()).orElseThrow().boss());}
}
@SuppressWarnings({"unchecked","rawtypes"})
@Test void characterBossMaterializesScaledVindicatorAndFixatesOnAcceptedThreat() throws Exception {
var root=data.resolve("character-runtime");java.nio.file.Files.createDirectories(root);
java.nio.file.Files.writeString(root.resolve("config.yml"),"adventure-daily-site-count: 0\n");
try(var f=new FantasyStickLifecycleNativeTest.Fixture(root)){
f.plugin.setEnabled(true);
var admin=new FantasyStickLifecycleNativeTest.Actor(root);f.players.put(admin.core.id,admin.player);doReturn(true).when(admin.player).hasPermission("fantasystick.admin");var bossStats=new java.util.EnumMap<games.dmg.fantasystick.progression.Stat,BigInteger>(games.dmg.fantasystick.progression.Stat.class);for(var stat:games.dmg.fantasystick.progression.Stat.values())bossStats.put(stat,BigInteger.valueOf(30));var abilities=List.of("fireball","frost_nova","hex","second_wind","purify","stone_skin","guardian_bond");var configuredBoss=new games.dmg.fantasystick.persistence.CharacterRecord(admin.core.record.id(),admin.core.record.name(),BigInteger.ONE,bossStats,admin.core.record.level(),admin.core.record.experience(),admin.core.record.unspentPoints(),java.math.BigDecimal.valueOf(40),Set.copyOf(abilities),abilities,List.of("speed_i","low_light_vision","quicker_regeneration_i"),Map.of("fireball",java.time.Instant.parse("2099-01-01T00:00:00Z")),Map.of(),false,null,null,admin.core.record.coins(),admin.core.record.coinsInitialized(),admin.core.record.rewardReceipts(),admin.core.record.pvpCoinEscrow(),new games.dmg.fantasystick.persistence.CharacterGear(games.dmg.fantasystick.persistence.GearTier.COPPER,games.dmg.fantasystick.persistence.GearTier.GOLD,games.dmg.fantasystick.persistence.GearTier.IRON));new games.dmg.fantasystick.persistence.YamlCharacterRepository(root).replace(configuredBoss,BigInteger.ZERO);
UUID worldId=UUID.randomUUID();when(admin.world.getUID()).thenReturn(worldId);when(admin.world.isChunkLoaded(anyInt(),anyInt())).thenReturn(true);
var spawned=new ArrayList<Vindicator>();var maximumBases=new IdentityHashMap<Vindicator,double[]>();var attackBases=new IdentityHashMap<Vindicator,double[]>();var bossEffects=new IdentityHashMap<Vindicator,Set<org.bukkit.potion.PotionEffect>>();var bossEquipment=new IdentityHashMap<Vindicator,org.bukkit.inventory.EntityEquipment>();
doAnswer(call->{var mob=mock(Vindicator.class);var pdc=new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());var maximum=mock(org.bukkit.attribute.AttributeInstance.class);var damage=mock(org.bukkit.attribute.AttributeInstance.class);var equipment=mock(org.bukkit.inventory.EntityEquipment.class);var maximumBase=new double[]{20};var attackBase=new double[]{1};var health=new double[]{20};var effects=new LinkedHashSet<org.bukkit.potion.PotionEffect>();effects.add(new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.WEAKNESS,200,0));maximumBases.put(mob,maximumBase);attackBases.put(mob,attackBase);bossEffects.put(mob,effects);bossEquipment.put(mob,equipment);when(mob.getPersistentDataContainer()).thenReturn(pdc);when(mob.getUniqueId()).thenReturn(UUID.randomUUID());when(mob.getType()).thenReturn(EntityType.VINDICATOR);when(mob.getWorld()).thenReturn(admin.world);when(mob.getLocation()).thenReturn(((Location)call.getArgument(0)).clone());when(mob.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).thenReturn(maximum);when(mob.getAttribute(org.bukkit.attribute.Attribute.ATTACK_DAMAGE)).thenReturn(damage);when(maximum.getValue()).thenAnswer(ignored->maximumBase[0]);doAnswer(invocation->{maximumBase[0]=(Double)invocation.getArgument(0);return null;}).when(maximum).setBaseValue(anyDouble());doAnswer(invocation->{attackBase[0]=(Double)invocation.getArgument(0);return null;}).when(damage).setBaseValue(anyDouble());when(mob.getHealth()).thenAnswer(ignored->health[0]);doAnswer(invocation->{health[0]=(Double)invocation.getArgument(0);return null;}).when(mob).setHealth(anyDouble());when(mob.getEquipment()).thenReturn(equipment);when(mob.hasLineOfSight(any(Entity.class))).thenReturn(true);when(mob.getActivePotionEffects()).thenAnswer(ignored->Set.copyOf(effects));doAnswer(invocation->{effects.add(invocation.getArgument(0));return true;}).when(mob).addPotionEffect(any(org.bukkit.potion.PotionEffect.class));doAnswer(invocation->{effects.add(invocation.getArgument(0));return true;}).when(mob).addPotionEffect(any(org.bukkit.potion.PotionEffect.class),anyBoolean());doAnswer(invocation->{effects.removeIf(effect->effect.getType().equals(invocation.getArgument(0)));return null;}).when(mob).removePotionEffect(any(org.bukkit.potion.PotionEffectType.class));((java.util.function.Consumer<Vindicator>)call.getArgument(2)).accept(mob);spawned.add(mob);return mob;}).when(admin.world).spawn(any(Location.class),eq(Vindicator.class),any(java.util.function.Consumer.class));
when(admin.world.getEntities()).thenAnswer(ignored->new ArrayList<Entity>(spawned));
var dungeons=(DungeonDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeons");var dungeon=dungeons.create("Mirror",worldId,0,64,0,0,0).get(5,TimeUnit.SECONDS);var room=dungeons.addRadiusRoom(dungeon.id(),"Boss",0,64,0,3,3).get(5,TimeUnit.SECONDS);dungeons.setBoss(dungeon.id(),room.id(),EntityType.HUSK,"Temporary",0,65,0,0,0).get(5,TimeUnit.SECONDS);dungeons.setCompletionReward(dungeon.id(),BigInteger.ZERO,BigInteger.ZERO).get(5,TimeUnit.SECONDS);command(f,admin,"dungeon","edit","start",dungeon.id().toString());var controller=(games.dmg.fantasystick.dungeon.DungeonController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"dungeonController");await(f,()->controller.editing(admin.core.id).isPresent(),admin.messages);admin.core.location.setX(.5);admin.core.location.setY(65);admin.core.location.setZ(.5);var sheet=admin.core.inventory.getItemInOffHand();admin.core.inventory.setItemInOffHand(null);admin.core.inventory.setItemInMainHand(sheet);command(f,admin,"dungeon","room","boss","assign-sheet","2");await(f,()->dungeons.custody(admin.core.record.id()).isPresent(),admin.messages);command(f,admin,"dungeon","edit","done");command(f,admin,"dungeon","enable",dungeon.id().toString());command(f,admin,"dungeon","open",dungeon.id().toString());
var 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()));
}
}
@Test void questGiverPaginatesManualDungeonOffersBeyondAutomaticCount() 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 actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);UUID world=UUID.randomUUID();when(actor.world.getUID()).thenReturn(world);f.activate(actor);var directory=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");var sites=new ArrayList<AdventureQuestState.Site>();for(int i=0;i<46;i++)sites.add(new AdventureQuestState.Site(UUID.randomUUID(),AdventureQuestState.SiteType.DUNGEON,"Dungeon "+i,world,i,64,0));var catalog=new AdventureQuestState.Catalog(java.time.LocalDate.now(java.time.ZoneOffset.UTC),UUID.randomUUID(),java.time.Instant.now(),sites.stream().map(AdventureQuestState.Site::id).toList());var field=directory.getClass().getDeclaredField("snapshot");field.setAccessible(true);field.set(directory,new AdventureQuestState(sites,List.of(),catalog,List.of(),Set.of(),Set.of(),Map.of()));var controller=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");controller.open(actor.player);drain(f);var first=actor.inventories.getLast();assertEquals(54,first.getSize());for(int slot=0;slot<45;slot++)assertEquals(Material.PAPER,first.getItem(slot).getType());assertEquals(Material.ARROW,first.getItem(53).getType());var click=mock(InventoryClickEvent.class);when(click.getWhoClicked()).thenReturn(actor.player);when(click.getInventory()).thenReturn(first);when(click.getCurrentItem()).thenReturn(first.getItem(53));controller.click(click);drain(f);var second=actor.inventories.getLast();assertNotSame(first,second);assertEquals(Material.PAPER,second.getItem(0).getType());assertTrue(second.getItem(0).getItemMeta().displayName().toString().contains("Dungeon 45"));}
}
private static void command(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor,String... args)throws Exception{f.command.execute(actor.player,"fantasystick",args);drain(f);}
private static void pulse(FantasyStickLifecycleNativeTest.Fixture f){for(var task:List.copyOf(f.pulses))task.run();}
private static void drain(FantasyStickLifecycleNativeTest.Fixture f)throws Exception{Runnable task;for(int attempt=0;attempt<20;attempt++){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,Object evidence)throws Exception{for(int attempt=0;attempt<50&&!condition.getAsBoolean();attempt++){drain(f);Thread.sleep(5);}assertTrue(condition.getAsBoolean(),String.valueOf(evidence));}
}
@@ -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);
@@ -53,7 +81,7 @@ class EquipmentAdminCommandNativeTest {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var target = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(target.core.id,target.player);
var sender=new Sender();
assertEquals(List.of("config","experience","give","inspect","party-registrar","recover","shop","training-dummy"),f.command.tabComplete(sender.port,"fantasystick",new String[]{""}));
assertEquals(List.of("adventure-site","config","dungeon","experience","give","inspect","party-registrar","quest-giver","recover","shop","training-dummy"),f.command.tabComplete(sender.port,"fantasystick",new String[]{""}));
assertEquals(List.of("give"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"g"}));
assertEquals(List.of("Account"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"give",""}));
assertEquals(List.of("Account"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"give","Acc"}));
@@ -94,7 +122,7 @@ class EquipmentAdminCommandNativeTest {
f.players.put(first.core.id, first.player); f.players.put(second.core.id, second.player); f.players.put(offline.core.id, offline.player);
var sender = new Sender();
var expected = java.util.stream.Stream.of(first.core.id.toString(), second.core.id.toString()).sorted().toList();
assertEquals(List.of("config", "experience", "give", "inspect", "party-registrar", "recover", "shop", "training-dummy"), f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
assertEquals(List.of("adventure-site", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "recover", "shop", "training-dummy"), f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
assertEquals(List.of("inspect"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"in"}));
assertEquals(List.of("recover"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"rec"}));
assertEquals(expected, f.command.tabComplete(sender.port, "fantasystick", new String[]{"inspect", ""}));
@@ -109,7 +137,8 @@ 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;
for (String[] args : List.of(new String[]{""}, new String[]{"inspect", ""}, new String[]{"config", ""},
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());
}
@@ -633,6 +633,110 @@ class EquipmentSessionEventsNativeTest {
}
}
@Test void crossWorldDefeatCloseRebindsProvedTailDeliveryWithOrWithoutArmor() throws Exception {
for (boolean armored : new boolean[]{false, true}) try (var f = new Fixture()) {
var a = f.actor;
var overworld = org.mockito.Mockito.mock(org.bukkit.World.class);
var end = org.mockito.Mockito.mock(org.bukkit.World.class);
org.mockito.Mockito.when(overworld.getUID()).thenReturn(java.util.UUID.randomUUID());
org.mockito.Mockito.when(end.getUID()).thenReturn(java.util.UUID.randomUUID());
a.location = new org.bukkit.Location(overworld, 12, 70, -8, 45, 10);
if (!armored) a.inventory.setArmorContents(new ItemStack[4]);
var entry = a.location.clone(); f.core.activate(a);
a.location = new org.bukkit.Location(end, 80, 64, 40);
var closingView = a.view;
a.teleport = () -> {
f.dispatcher.accept(new org.bukkit.event.inventory.InventoryCloseEvent(closingView));
a.view = null;
};
f.dispatcher.accept(f.death(org.bukkit.damage.DamageType.OUT_OF_WORLD));
f.core.tick();
assertEquals(entry, a.location);
assertEquals(Material.WRITTEN_BOOK, a.inventory.getItemInOffHand().getType());
assertTrue(f.core.characters.find(a.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS)
.orElseThrow().dead());
assertNull(f.core.state(a).pending(), "Successful Tail delivery releases the completed receipt");
var deadSheet = new games.dmg.fantasystick.items.CharacterSheets().create(a.record);
a.inventory.setItemInOffHand(deadSheet); f.core.sessions.enter(a.player);
f.core.characters.find(a.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS); f.core.tick();
assertEquals("UNAVAILABLE_CHARACTER", f.core.notices.getLast().code(),
"A completed defeat has no false persistence blocker and cannot reactivate");
}
}
@Test void exactRetainedSheetUseCompletesOnlyNeverStartedSameLifetimeTailConversion() throws Exception {
try (var f = new Fixture()) {
var a = f.actor; f.core.activate(a);
var retained = a.inventory.getItemInOffHand().clone();
f.dispatcher.accept(f.death(org.bukkit.damage.DamageType.GENERIC));
var original = f.core.next();
assertEquals(Material.PAPER, a.inventory.getItemInOffHand().getType());
assertNull(f.core.state(a).pending(), "Ordinary restoration must be acknowledged before retry");
f.core.sessions.enter(a.player);
assertEquals(Material.WRITTEN_BOOK, a.inventory.getItemInOffHand().getType());
var delivered = a.inventory.getItemInOffHand().clone();
original.run(); original.run(); f.core.sessions.enter(a.player);
assertEquals(delivered, a.inventory.getItemInOffHand());
assertEquals(1, a.teleports, "Retry never replays defeat restoration or return teleport");
a.inventory.setItemInOffHand(retained); f.core.sessions.enter(a.player);
f.core.characters.find(a.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS); f.core.tick();
assertEquals("UNAVAILABLE_CHARACTER", f.core.notices.getLast().code());
assertEquals(Material.PAPER, a.inventory.getItemInOffHand().getType(),
"The dead sheet cannot activate or create a second Tail after release");
}
try (var f = new Fixture()) {
var a = f.actor; f.core.activate(a);
var copied = a.inventory.getItemInOffHand().clone();
f.dispatcher.accept(f.death(org.bukkit.damage.DamageType.GENERIC));
var original = f.core.next();
a.inventory.setItemInOffHand(copied);
f.core.sessions.enter(a.player);
assertEquals(Material.PAPER, a.inventory.getItemInOffHand().getType());
assertTrue(f.core.notices.getLast().message().contains("inspection"));
original.run(); original.run();
assertEquals(Material.PAPER, a.inventory.getItemInOffHand().getType(),
"An apparently identical replacement sheet is not process-local custody proof");
assertEquals(1, f.core.failures.size());
f.core.expectedFailure = IllegalStateException.class;
}
}
@Test void rejectedOriginalTailCallbackRetainsNarrowSameLifetimeRetryAuthority() throws Exception {
var entered = new java.util.concurrent.CountDownLatch(1);
var release = new java.util.concurrent.CountDownLatch(1);
var writes = new java.util.concurrent.atomic.AtomicInteger();
try (var f = new Fixture((source, target) -> {
if (writes.incrementAndGet() == 2) {
entered.countDown();
try { if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new java.io.IOException("Gate timeout"); }
catch (InterruptedException interrupted) { throw new java.io.IOException(interrupted); }
}
EquipmentSessionsNativeTest.move(source, target);
})) {
var a = f.actor; f.core.activate(a);
try {
f.dispatcher.accept(f.death(org.bukkit.damage.DamageType.GENERIC));
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
assertNull(f.core.state(a).pending());
f.core.rejectSchedule = true;
} finally { release.countDown(); }
f.core.characters.find(a.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS);
assertEquals(Material.PAPER, a.inventory.getItemInOffHand().getType());
assertEquals(1, f.core.failures.size()); f.core.expectedFailure = IllegalStateException.class;
f.core.rejectSchedule = false;
f.core.sessions.enter(a.player);
assertEquals(Material.WRITTEN_BOOK, a.inventory.getItemInOffHand().getType());
assertNull(f.core.state(a).pending());
assertEquals(1, a.teleports);
}
}
@Test void everyRegisteredDamageTypeUsesTheSameDeathBoundaryButOrdinaryAndPriorVetoesStayUntouched() throws Exception {
int causes = 0;
for (var type : org.bukkit.Registry.DAMAGE_TYPE) {
@@ -717,6 +821,11 @@ class EquipmentSessionEventsNativeTest {
assertEquals(1, f.core.failures.size()); assertInstanceOf(IllegalStateException.class, f.core.failures.getFirst().cause());
f.core.expectedFailure = IllegalStateException.class;
a.online = true; f.core.sessions.arrive(a.player); f.core.sessions.recover(a.player);
if (scenario == 3) {
f.core.sessions.enter(a.player);
assertEquals("DEFEAT_INSPECTION_REQUIRED", f.core.notices.getLast().code(),
"Reconnect loses same-lifetime Tail retry authority");
}
assertArrayEquals(before, a.snapshot()); assertTrue(f.core.tasks.isEmpty());
}
}
@@ -762,7 +871,7 @@ class EquipmentSessionEventsNativeTest {
assertTrue(event.isCancelled()); assertArrayEquals(before, a.snapshot());
assertTrue(a.marker().endsWith(exception ? "|RESTORING" : "|PACKED"));
assertNotNull(f.core.state(a).pending()); assertEquals(a.normalMaximum, a.health);
var callback = f.core.next(); callback.run(); callback.run();
assertTrue(f.core.tasks.isEmpty(), "Unrestored equipment never queues a Tail write");
assertArrayEquals(before, a.snapshot()); assertNotNull(f.core.state(a).pending());
assertTrue(f.core.characters.find(a.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().dead());
assertEquals(exception ? 2 : 1, f.core.failures.size()); f.core.expectedFailure = IllegalStateException.class;
@@ -790,6 +899,10 @@ class EquipmentSessionEventsNativeTest {
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
assertNull(f.core.state(a).pending(), "Gear receipt cleanup acknowledged first");
assertEquals(Material.PAPER, a.inventory.getItemInOffHand().getType());
f.core.sessions.enter(a.player);
assertEquals("DEFEAT_PENDING", f.core.notices.getLast().code());
assertEquals(Material.PAPER, a.inventory.getItemInOffHand().getType(),
"Pending death persistence grants neither activation nor a Tail");
competingRead.run();
assertEquals("CHARACTER_BUSY", f.core.notices.getLast().code());
assertTrue(f.core.sessions.active(copy.id).isEmpty()); assertTrue(f.core.tasks.isEmpty());
@@ -824,7 +937,7 @@ class EquipmentSessionEventsNativeTest {
assertEquals(Material.DIAMOND_SWORD, a.inventory.getItem(0).getType());
assertEquals(sheet, a.inventory.getItemInOffHand());
} finally { release.countDown(); }
if (route == 0) f.core.tick();
if (route == 0) assertTrue(f.core.tasks.isEmpty(), "Quit invalidates delivery before any Tail write");
f.core.sessions.close(); f.core.sessions.drained().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
var dead = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(a.record.id()).orElseThrow();
assertTrue(dead.dead()); assertEquals(sheet, a.inventory.getItemInOffHand()); assertEquals(2, writes.get());
@@ -932,6 +1045,11 @@ class EquipmentSessionEventsNativeTest {
callback.run(); a.offhandMutation = () -> {}; a.beforeOffhandMutation = () -> {};
assertEquals(afterWrite ? Material.WRITTEN_BOOK : Material.PAPER, a.inventory.getItemInOffHand().getType());
var current = a.snapshot(); callback.run(); f.core.sessions.recover(a.player);
if (!afterWrite) {
f.core.sessions.enter(a.player);
assertEquals("DEFEAT_INSPECTION_REQUIRED", f.core.notices.getLast().code(),
"A possible native write never authorizes retained-sheet retry");
}
assertArrayEquals(current, a.snapshot()); assertTrue(f.core.tasks.isEmpty());
assertEquals(2, f.core.failures.size()); f.core.expectedFailure = IllegalStateException.class;
assertTrue(f.core.characters.find(a.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().dead());
@@ -942,7 +1060,8 @@ class EquipmentSessionEventsNativeTest {
try (var f = new Fixture()) {
var a = f.actor; f.core.activate(a); var control = a.inventory.getItem(0).clone();
a.inventory.setItem(0, new ItemStack(Material.EMERALD));
f.dispatcher.accept(f.death(org.bukkit.damage.DamageType.GENERIC)); f.core.tick();
f.dispatcher.accept(f.death(org.bukkit.damage.DamageType.GENERIC));
assertTrue(f.core.tasks.isEmpty(), "Failed restoration queues no Tail write");
assertNotNull(f.core.state(a).pending()); assertTrue(a.marker().endsWith("|PACKED"));
var dead = f.core.characters.find(a.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
a.inventory.setItem(20, a.inventory.getItem(0)); a.inventory.setItem(0, control);
@@ -1292,8 +1292,9 @@ class EquipmentSessionsNativeTest {
}
}
@Test void cooldownExplainsRemainingSecondsAndSameCharacterBeforeEquipmentPreflight() throws Exception {
try (var f = new Fixture()) {
@Test void cooldownShowsOnlyFreshRoundedRemainingSecondsBeforeEquipmentPreflight() throws Exception {
var clock = new MutableClock();
try (var f = new Fixture(Duration.ofSeconds(13), clock)) {
var actor = f.actor(); f.activate(actor);
f.sessions.exit(actor.player);
var exited = f.state(actor);
@@ -1304,14 +1305,18 @@ class EquipmentSessionsNativeTest {
actor.inventory.setItem(0, new ItemStack(Material.SHULKER_BOX));
actor.cursor = new ItemStack(Material.EMERALD);
var before = actor.snapshot(); var cursor = actor.cursor.clone(); var marker = actor.marker();
for (int retry = 0; retry < 2; retry++) {
clock.advance(Duration.ofMillis(900));
for (String expected : java.util.List.of(
"Wait 13 seconds to switch characters; the same character may be resumed now.",
"Wait 12 seconds to switch characters; the same character may be resumed now.")) {
f.sessions.enter(actor.player); f.tick();
var notice = f.notices.getLast();
assertEquals("COOLDOWN", notice.code());
assertTrue(notice.message().matches("(?s).*Wait [1-9][0-9]* seconds.*"), notice::message);
assertTrue(notice.message().contains("same character may be resumed now"), notice::message);
assertEquals(expected, notice.message());
assertFalse(notice.message().contains(exited.lastExit().deadline().toString()));
assertArrayEquals(before, actor.snapshot()); assertEquals(cursor, actor.cursor);
assertEquals(marker, actor.marker()); assertEquals(exited, f.state(actor));
clock.advance(Duration.ofMillis(100));
}
}
}
@@ -36,6 +36,7 @@ class FantasyFeedbackCommandNativeTest {
fixture.plugin.setEnabled(true);
assertTrue(fixture.feedbackCommand.execute(player.player, "fantasyfeedback", new String[]{}));
assertEquals("Fantasy feedback is off.", player.messages.getLast());
assertTrue(player.messageComponents.isEmpty(), "preference/status notices keep their existing presentation");
}
}
@@ -48,13 +49,14 @@ 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));
assertTrue(player.messageComponents.isEmpty(), "refusals are not routine outcome components");
}
}
@@ -75,6 +77,10 @@ class FantasyFeedbackCommandNativeTest {
String healing = "Mending Light healed " + target.core.record.name() + " for 1.23 health.";
assertEquals(1, source.messages.stream().filter(healing::equals).count());
assertEquals(1, target.messages.stream().filter(healing::equals).count());
assertEquals(net.kyori.adventure.text.format.NamedTextColor.GRAY,
source.messageComponents.getLast().color());
assertEquals(net.kyori.adventure.text.format.NamedTextColor.GRAY,
target.messageComponents.getLast().color());
fixture.feedbackCommand.execute(source.player, "fantasyfeedback", new String[]{"off"}); fixture.tick();
feedback.report("Sanctuary", source.player,
@@ -114,10 +120,19 @@ class FantasyFeedbackCommandNativeTest {
assertTrue(source.messages.contains("Stone Skin prevented 1.01 damage to "
+ target.core.record.name() + "."));
assertTrue(source.messages.contains("Rallying Cry added 0.5 damage against Zombie."));
assertEquals(6, source.messageComponents.size());
assertEquals(2, target.messageComponents.size());
assertTrue(source.messageComponents.stream().allMatch(component ->
net.kyori.adventure.text.format.NamedTextColor.GRAY.equals(component.color())));
assertTrue(target.messageComponents.stream().allMatch(component ->
net.kyori.adventure.text.format.NamedTextColor.GRAY.equals(component.color())));
assertTrue(source.messages.stream().noneMatch(message -> message.indexOf('\u00a7') >= 0));
int messages = source.messages.size();
int components = source.messageComponents.size();
feedback.report("No-op", source.player,
games.dmg.fantasystick.ability.AbilityReport.Type.DAMAGE, zombie, java.math.BigDecimal.ZERO);
assertEquals(messages, source.messages.size());
assertEquals(components, source.messageComponents.size());
}
}
}
@@ -64,16 +64,21 @@ class FantasyHealthNativeTest {
}
}
@Test void activeBlueMistAndOneRedDefeatBurstUseWorldLocationsAndStopWithTheSession() throws Exception {
@Test void activeBlueMistDefaultsOffWhileOptInAndOneRedDefeatBurstRespectTheirOwnLifetimes() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var ordinary = new FantasyStickLifecycleNativeTest.Actor(data); ordinary.core.inventory.setItemInOffHand(null);
f.players.put(a.core.id, a.player); f.players.put(ordinary.core.id, ordinary.player);
var entry = a.player.getLocation(); f.activate(a);
f.pulses.forEach(Runnable::run);
assertTrue(dust(a, org.bukkit.Color.BLUE) > 0, "Active fantasy mode must have visible public blue particles");
assertEquals(0, dust(ordinary, org.bukkit.Color.BLUE));
f.pulses.getLast().run();
assertEquals(0, receivedDust(a, org.bukkit.Color.BLUE), "Active-character auras default off per viewer");
assertEquals(0, receivedDust(ordinary, org.bukkit.Color.BLUE));
f.particlesCommand.execute(a.player, "fantasyparticles", new String[]{"players", "on"}); f.tick();
f.pulses.getLast().run();
assertTrue(receivedDust(a, org.bukkit.Color.BLUE) > 0, "An opted-in active viewer sees the current blue aura");
assertEquals(0, receivedDust(ordinary, org.bukkit.Color.BLUE));
f.particlesCommand.execute(a.player, "fantasyparticles", new String[]{"players", "off"}); f.tick();
a.core.location.add(20, 0, 20);
var defeatLocation = a.player.getLocation().add(0, 1, 0);
var source = new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(
@@ -81,20 +86,42 @@ 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);
assertEquals(1, dust(a, org.bukkit.Color.RED));
assertTrue(org.mockito.Mockito.mockingDetails(a.world).getInvocations().stream().anyMatch(call ->
call.getMethod().getName().equals("spawnParticle") && java.util.Arrays.asList(call.getArguments()).contains(defeatLocation)));
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());
var tail = a.core.inventory.getItemInOffHand().clone();
assertEquals(org.bukkit.Material.WRITTEN_BOOK, tail.getType()); assertEquals(1, tail.getAmount());
long blue = dust(a, org.bukkit.Color.BLUE);
long blue = receivedDust(a, org.bukkit.Color.BLUE);
f.pulses.forEach(Runnable::run); f.dispatcher.accept(death);
assertEquals(blue, dust(a, org.bukkit.Color.BLUE)); assertEquals(1, dust(a, org.bukkit.Color.RED));
assertEquals(blue, receivedDust(a, org.bukkit.Color.BLUE)); assertEquals(1, receivedDust(a, org.bukkit.Color.RED));
assertEquals(tail, a.core.inventory.getItemInOffHand());
f.plugin.setEnabled(false); f.awaitWorkers();
f.pulses.forEach(Runnable::run);
assertEquals(blue, dust(a, org.bukkit.Color.BLUE), "Retired timers cannot emit fantasy effects");
assertEquals(blue, receivedDust(a, org.bukkit.Color.BLUE), "Retired timers cannot emit fantasy effects");
}
}
@Test void allParticleOptOutSuppressesDefeatBurstOnlyForThatViewer() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var defeated = new FantasyStickLifecycleNativeTest.Actor(data);
var viewer = new FantasyStickLifecycleNativeTest.Actor(data);
viewer.core.inventory.setItemInOffHand(null); viewer.core.location.setWorld(defeated.world);
f.players.put(defeated.core.id, defeated.player); f.players.put(viewer.core.id, viewer.player);
f.activate(defeated);
f.particlesCommand.execute(defeated.player, "fantasyparticles", new String[]{"all", "off"}); f.tick();
defeated.particleCalls.clear(); viewer.particleCalls.clear(); defeated.messages.clear();
var source = new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(
org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(org.bukkit.damage.DamageType.OUT_OF_WORLD)));
var death = new org.bukkit.event.entity.PlayerDeathEvent(defeated.player, source,
new java.util.ArrayList<>(), 0, net.kyori.adventure.text.Component.empty(), true);
f.dispatcher.accept(death); PartyBookNativeTest.drain(f);
assertTrue(death.isCancelled());
assertEquals(0, receivedDust(defeated, org.bukkit.Color.RED));
assertEquals(1, receivedDust(viewer, org.bukkit.Color.RED));
assertTrue(defeated.messages.stream().anyMatch(message -> message.toLowerCase(java.util.Locale.ROOT).contains("defeat")));
assertEquals(org.bukkit.Material.WRITTEN_BOOK, defeated.core.inventory.getItemInOffHand().getType());
}
}
@@ -347,10 +374,10 @@ class FantasyHealthNativeTest {
}
}
private static long dust(FantasyStickLifecycleNativeTest.Actor a, org.bukkit.Color color) {
return org.mockito.Mockito.mockingDetails(a.world).getInvocations().stream().filter(call ->
call.getMethod().getName().equals("spawnParticle") && java.util.Arrays.stream(call.getArguments())
.anyMatch(argument -> argument instanceof org.bukkit.Particle.DustOptions dust && dust.getColor().equals(color))).count();
private static long receivedDust(FantasyStickLifecycleNativeTest.Actor a, org.bukkit.Color color) {
return a.particleCalls.stream().filter(call -> java.util.Arrays.stream(call)
.anyMatch(argument -> argument instanceof org.bukkit.Particle.DustOptions dust
&& dust.getColor().equals(color))).count();
}
private static net.minecraft.world.entity.ai.attributes.AttributeModifier modifier(String name, double amount,
@@ -0,0 +1,309 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Path;
import java.util.List;
import org.bukkit.Color;
import org.bukkit.Particle;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Registered account command through the real recurring active-session aura pulse. */
class FantasyParticlesNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredPlayersRouteDefaultsOffPersistsAndRejectsInvalidSendersAndArguments() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
assertNotNull(fixture.particlesCommand);
var player = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(player.core.id, player.player);
assertTrue(fixture.particlesCommand.execute(player.player, "fantasyparticles",
new String[]{"players"}));
assertEquals("Active-character aura particles are off.", player.messages.getLast());
assertTrue(fixture.particlesCommand.execute(player.player, "fantasyparticles",
new String[]{"all"}));
assertEquals("All Fantasy Stick particles are on.", player.messages.getLast());
assertEquals(List.of("all", "players"), fixture.particlesCommand.tabComplete(
player.player, "fantasyparticles", new String[]{""}));
assertEquals(List.of("off", "on"), fixture.particlesCommand.tabComplete(
player.player, "fantasyparticles", new String[]{"players", ""}));
assertEquals(List.of("off"), fixture.particlesCommand.tabComplete(
player.player, "fantasyparticles", new String[]{"players", "of"}));
assertEquals(List.of("on"), fixture.particlesCommand.tabComplete(
player.player, "fantasyparticles", new String[]{"players", "on"}));
assertFalse(java.nio.file.Files.exists(data.resolve("player-preferences.yml")),
"Status and completion perform no persistence write");
assertTrue(player.particleCalls.isEmpty(), "Status and completion emit no particles");
int before = player.messages.size();
assertTrue(fixture.particlesCommand.execute(player.player, "fantasyparticles",
new String[]{"players", "on"}));
assertEquals(before, player.messages.size(), "Persistence must complete before acknowledgement");
fixture.tick();
assertEquals("Active-character aura particles are now on.", player.messages.getLast());
fixture.particlesCommand.execute(player.player, "fantasyparticles",
new String[]{"all", "off"}); fixture.tick();
assertEquals("All Fantasy Stick particles are now off.", player.messages.getLast());
fixture.particlesCommand.execute(player.player, "fantasyparticles", new String[]{"players"});
assertEquals("Active-character aura particles are on (effectively off while all particles are off).",
player.messages.getLast());
fixture.particlesCommand.execute(player.player, "fantasyparticles",
new String[]{"all", "on"}); fixture.tick();
assertEquals("All Fantasy Stick particles are now on.", player.messages.getLast());
var console = new EquipmentAdminCommandNativeTest.Sender();
assertTrue(fixture.particlesCommand.execute(console.port, "fantasyparticles",
new String[]{"players", "off"}));
assertTrue(console.messages.getLast().contains("player-account"));
assertTrue(fixture.particlesCommand.tabComplete(console.port, "fantasyparticles",
new String[]{""}).isEmpty());
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"})) {
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",
new String[]{"all", "off"}); fixture.tick();
var other = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(other.core.id, other.player);
var playerSheet = player.core.inventory.getItemInOffHand();
player.core.inventory.setItemInOffHand(other.core.inventory.getItemInOffHand());
other.core.inventory.setItemInOffHand(playerSheet);
fixture.particlesCommand.execute(player.player, "fantasyparticles", new String[]{"players"});
assertEquals("Active-character aura particles are on (effectively off while all particles are off).",
player.messages.getLast(), "The preference stays with the account after character-sheet switching");
fixture.particlesCommand.execute(player.player, "fantasyparticles", new String[]{"all"});
assertEquals("All Fantasy Stick particles are off.", player.messages.getLast());
fixture.particlesCommand.execute(other.player, "fantasyparticles", new String[]{"players"});
assertEquals("Active-character aura particles are off.", other.messages.getLast(),
"A transferred character does not transfer its prior holder's preference");
fixture.particlesCommand.execute(other.player, "fantasyparticles", new String[]{"all"});
assertEquals("All Fantasy Stick particles are on.", other.messages.getLast(),
"A transferred character does not transfer its prior holder's all-particle preference");
fixture.plugin.setEnabled(false);
fixture.plugin.setEnabled(true);
fixture.particlesCommand.execute(player.player, "fantasyparticles", new String[]{"players"});
assertEquals("Active-character aura particles are on (effectively off while all particles are off).",
player.messages.getLast());
fixture.particlesCommand.execute(player.player, "fantasyparticles", new String[]{"all"});
assertEquals("All Fantasy Stick particles are off.", player.messages.getLast());
}
}
@Test void reversedCompetingCommandCallbacksCannotPublishStaleAuraState() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var player = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(player.core.id, player.player);
fixture.particlesCommand.execute(player.player, "fantasyparticles",
new String[]{"players", "on"});
fixture.particlesCommand.execute(player.player, "fantasyparticles",
new String[]{"players", "off"});
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
preferences.setPlayerAuras(java.util.UUID.randomUUID(), false).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS);
var callbacks = new java.util.ArrayList<Runnable>();
fixture.tasks.drainTo(callbacks);
assertEquals(2, callbacks.size());
java.util.Collections.reverse(callbacks); callbacks.forEach(Runnable::run);
assertFalse(preferences.playerAurasEnabled(player.core.id));
fixture.particlesCommand.execute(player.player, "fantasyparticles", new String[]{"players"});
assertEquals("Active-character aura particles are off.", player.messages.getLast());
fixture.particlesCommand.execute(player.player, "fantasyparticles", new String[]{"all", "off"});
fixture.particlesCommand.execute(player.player, "fantasyparticles", new String[]{"all", "on"});
preferences.setAllParticles(java.util.UUID.randomUUID(), true).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS);
callbacks.clear(); fixture.tasks.drainTo(callbacks);
assertEquals(2, callbacks.size());
java.util.Collections.reverse(callbacks); callbacks.forEach(Runnable::run);
assertTrue(preferences.allParticlesEnabled(player.core.id));
fixture.particlesCommand.execute(player.player, "fantasyparticles", new String[]{"all"});
assertEquals("All Fantasy Stick particles are on.", player.messages.getLast());
}
}
@Test void failedAuraPreferenceSaveKeepsPriorEffectiveStateAndReportsRefusal() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var player = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(player.core.id, player.player);
java.nio.file.Files.createDirectory(data.resolve("player-preferences.yml"));
fixture.particlesCommand.execute(player.player, "fantasyparticles",
new String[]{"players", "on"});
fixture.tick();
assertTrue(player.messages.getLast().contains("could not be saved"));
fixture.particlesCommand.execute(player.player, "fantasyparticles", new String[]{"players"});
assertEquals("Active-character aura particles are off.", player.messages.getLast());
fixture.particlesCommand.execute(player.player, "fantasyparticles",
new String[]{"all", "off"});
fixture.tick();
assertTrue(player.messages.getLast().contains("could not be saved"));
fixture.particlesCommand.execute(player.player, "fantasyparticles", new String[]{"all"});
assertEquals("All Fantasy Stick particles are on.", player.messages.getLast());
}
}
@Test void allPreferenceFiltersSharedWorldAndExplicitDeliveryWithoutExpandingNativeBounds() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var enabled = new FantasyStickLifecycleNativeTest.Actor(data); enabled.accountName = "Enabled";
var enabledPeer = new FantasyStickLifecycleNativeTest.Actor(data); enabledPeer.accountName = "EnabledPeer";
var disabled = new FantasyStickLifecycleNativeTest.Actor(data); disabled.accountName = "Disabled";
var far = new FantasyStickLifecycleNativeTest.Actor(data); far.accountName = "Far";
var crossWorld = new FantasyStickLifecycleNativeTest.Actor(data); crossWorld.accountName = "CrossWorld";
enabledPeer.core.location.setWorld(enabled.world);
disabled.core.location.setWorld(enabled.world);
far.core.location.setWorld(enabled.world); far.core.location.add(40, 0, 0);
for (var actor : List.of(enabled, enabledPeer, disabled, far, crossWorld)) {
actor.core.inventory.setItemInOffHand(null); fixture.players.put(actor.core.id, actor.player);
}
fixture.particlesCommand.execute(disabled.player, "fantasyparticles", new String[]{"all", "off"});
fixture.tick(); clear(enabled, enabledPeer, disabled, far, crossWorld);
var delivery = (games.dmg.fantasystick.presentation.ParticleDelivery)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "particleDelivery");
var origin = enabled.player.getLocation();
var dust = new Particle.DustOptions(Color.RED, 1.5f);
delivery.world(Particle.DUST, origin, 7, 0.1, 0.2, 0.3, 0.04, dust);
assertEquals(1, enabled.particleCalls.size());
assertEquals(1, enabledPeer.particleCalls.size());
var expected = new Object[]{Particle.DUST, origin, 7, 0.1, 0.2, 0.3, 0.04, dust};
assertArrayEquals(expected, enabled.particleCalls.getFirst());
assertArrayEquals(expected, enabledPeer.particleCalls.getFirst());
assertTrue(disabled.particleCalls.isEmpty());
assertTrue(far.particleCalls.isEmpty());
assertTrue(crossWorld.particleCalls.isEmpty());
org.mockito.Mockito.verify(enabled.world, org.mockito.Mockito.never()).spawnParticle(
org.mockito.ArgumentMatchers.any(Particle.class), org.mockito.ArgumentMatchers.any(org.bukkit.Location.class),
org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.anyDouble(),
org.mockito.ArgumentMatchers.anyDouble(), org.mockito.ArgumentMatchers.anyDouble(),
org.mockito.ArgumentMatchers.anyDouble(), org.mockito.ArgumentMatchers.any());
delivery.viewer(disabled.player, Particle.ANGRY_VILLAGER, origin, 8, 0.3, 0.4, 0.3, 0);
delivery.viewer(crossWorld.player, Particle.ANGRY_VILLAGER, origin, 8, 0.3, 0.4, 0.3, 0);
delivery.viewer(enabled.player, Particle.ANGRY_VILLAGER, origin, 8, 0.3, 0.4, 0.3, 0);
assertTrue(disabled.particleCalls.isEmpty());
assertTrue(crossWorld.particleCalls.isEmpty());
assertEquals(2, enabled.particleCalls.size());
assertEquals(1, enabledPeer.particleCalls.size(), "Targeted delivery is not duplicated to another viewer");
assertEquals(Particle.ANGRY_VILLAGER, enabled.particleCalls.getLast()[0]);
}
}
@Test void recurringAuraIsDefaultOffAndDeliveredOnlyToOptedInNativeViewers() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var first = new FantasyStickLifecycleNativeTest.Actor(data); first.accountName = "First";
var second = new FantasyStickLifecycleNativeTest.Actor(data); second.accountName = "Second";
var enabled = new FantasyStickLifecycleNativeTest.Actor(data); enabled.accountName = "Enabled";
var disabled = new FantasyStickLifecycleNativeTest.Actor(data); disabled.accountName = "Disabled";
var untracked = new FantasyStickLifecycleNativeTest.Actor(data); untracked.accountName = "Untracked";
var hidden = new FantasyStickLifecycleNativeTest.Actor(data); hidden.accountName = "Hidden"; hidden.visible = false;
var crossWorld = new FantasyStickLifecycleNativeTest.Actor(data); crossWorld.accountName = "CrossWorld";
second.core.location.setWorld(first.world); second.core.location.add(5, 0, 0);
enabled.core.location.setWorld(first.world);
disabled.core.location.setWorld(first.world);
untracked.core.location.setWorld(first.world);
hidden.core.location.setWorld(first.world);
enabled.core.inventory.setItemInOffHand(null);
disabled.core.inventory.setItemInOffHand(null);
untracked.core.inventory.setItemInOffHand(null);
hidden.core.inventory.setItemInOffHand(null);
crossWorld.core.inventory.setItemInOffHand(null);
for (var actor : List.of(first, second, enabled, disabled, untracked, hidden, crossWorld))
fixture.players.put(actor.core.id, actor.player);
first.tracked.add(enabled.player); first.tracked.add(disabled.player); first.tracked.add(hidden.player);
first.tracked.add(crossWorld.player);
second.tracked.add(enabled.player); second.tracked.add(disabled.player); second.tracked.add(hidden.player);
second.tracked.add(first.player);
fixture.activate(first); fixture.activate(second);
var firstInventory = first.core.snapshot(); var secondInventory = second.core.snapshot();
String firstMarker = first.core.marker(); String secondMarker = second.core.marker();
clear(first, second, enabled, disabled, untracked, hidden, crossWorld);
auraPulse(fixture);
assertTrue(first.particleCalls.isEmpty());
assertTrue(second.particleCalls.isEmpty());
assertTrue(enabled.particleCalls.isEmpty());
assertTrue(disabled.particleCalls.isEmpty());
assertTrue(hidden.particleCalls.isEmpty());
org.mockito.Mockito.verify(first.world, org.mockito.Mockito.never()).spawnParticle(
org.mockito.ArgumentMatchers.eq(Particle.DUST), org.mockito.ArgumentMatchers.any(org.bukkit.Location.class),
org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.anyDouble(),
org.mockito.ArgumentMatchers.anyDouble(), org.mockito.ArgumentMatchers.anyDouble(),
org.mockito.ArgumentMatchers.anyDouble(), org.mockito.ArgumentMatchers.any(Particle.DustOptions.class));
optIn(fixture, enabled);
optIn(fixture, first);
optIn(fixture, untracked);
optIn(fixture, hidden);
optIn(fixture, crossWorld);
clear(first, second, enabled, disabled, untracked, hidden, crossWorld);
auraPulse(fixture);
assertEquals(2, blueAuraCalls(enabled).size(), "An ordinary opted-in viewer sees both tracked sources");
assertEquals(2, blueAuraCalls(first).size(), "An opted-in active viewer sees self and a tracked peer");
assertTrue(disabled.particleCalls.isEmpty(), "Default-off viewers receive no substitute packet");
assertTrue(untracked.particleCalls.isEmpty(), "Opt-in does not expand native tracking");
assertTrue(hidden.particleCalls.isEmpty(), "Opt-in preserves native player visibility");
assertTrue(crossWorld.particleCalls.isEmpty(), "Opt-in does not cross world boundaries");
assertEquals(java.util.Set.of(first.player.getLocation().add(0, 1, 0),
second.player.getLocation().add(0, 1, 0)), blueAuraCalls(enabled).stream()
.map(call -> (org.bukkit.Location) call[1]).collect(java.util.stream.Collectors.toSet()));
for (Object[] call : blueAuraCalls(enabled)) {
assertEquals(3, call[2]);
assertEquals(0.3, (Double) call[3]); assertEquals(0.4, (Double) call[4]);
assertEquals(0.3, (Double) call[5]); assertEquals(0.0, (Double) call[6]);
}
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
var allOff = preferences.setAllParticles(enabled.core.id, false).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS);
assertTrue(preferences.publish(allOff));
clear(enabled); auraPulse(fixture);
assertTrue(enabled.particleCalls.isEmpty(), "All-particle off takes precedence over saved aura opt-in");
assertTrue(preferences.playerAurasEnabled(enabled.core.id), "Precedence does not overwrite aura preference");
var allOn = preferences.setAllParticles(enabled.core.id, true).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS);
assertTrue(preferences.publish(allOn));
clear(enabled); auraPulse(fixture);
assertEquals(2, blueAuraCalls(enabled).size(), "Re-enabling all restores the saved aura preference");
assertArrayEquals(firstInventory, first.core.snapshot()); assertArrayEquals(secondInventory, second.core.snapshot());
assertEquals(firstMarker, first.core.marker()); assertEquals(secondMarker, second.core.marker());
}
}
private static void optIn(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
fixture.particlesCommand.execute(actor.player, "fantasyparticles", new String[]{"players", "on"});
fixture.tick();
}
private static void auraPulse(FantasyStickLifecycleNativeTest.Fixture fixture) {
fixture.pulses.getLast().run();
}
private static List<Object[]> blueAuraCalls(FantasyStickLifecycleNativeTest.Actor actor) {
return actor.particleCalls.stream().filter(call -> call[0] == Particle.DUST && call[2].equals(3)
&& java.util.Arrays.stream(call).anyMatch(value -> value instanceof Particle.DustOptions dust
&& dust.getColor().equals(Color.BLUE))).toList();
}
private static void clear(FantasyStickLifecycleNativeTest.Actor... actors) {
for (var actor : actors) { actor.particles.clear(); actor.particleCalls.clear(); }
}
}
@@ -60,8 +60,8 @@ class FantasyStickLifecycleNativeTest {
assertTrue(fixture.order.indexOf("CharacterChatNames") >= 0);
assertTrue(fixture.order.indexOf("CharacterChatNames") < recipe);
assertNotNull(fixture.packetListener);
assertEquals(13, fixture.timers.size(),
"Presentation, ability/bond/rally/banner cleanup, provenance, reward retry, recovery, passive effects, armor-market/menu 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());
}
}
@@ -544,6 +549,22 @@ class FantasyStickLifecycleNativeTest {
}
}
@Test void allParticleOptOutSuppressesInvalidControlParticlesWithoutChangingRefusalOrInventory() throws Exception {
try (var f = new Fixture(data)) {
f.plugin.setEnabled(true);
var actor = new Actor(data); f.players.put(actor.core.id, actor.player);
actor.core.inventory.setItem(4, new org.bukkit.inventory.ItemStack(org.bukkit.Material.SHULKER_BOX));
f.particlesCommand.execute(actor.player, "fantasyparticles", new String[]{"all", "off"}); f.tick();
actor.messages.clear(); actor.particles.clear(); actor.particleCalls.clear();
var before = actor.core.snapshot(); f.changed(actor); f.tick(); f.tick();
assertArrayEquals(before, actor.core.snapshot()); assertNull(actor.core.marker());
assertEquals(1, actor.messages.size()); assertTrue(actor.messages.getFirst().contains("vanilla shulker"));
assertTrue(actor.particleCalls.isEmpty(), "Invalid-control feedback sends no particle to an opted-out viewer");
assertTrue(f.receipts().find(actor.core.id).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS).isEmpty());
}
}
@Test void startupArrivalIsSilentForOrdinaryPlayersAndReceiptFirstForSheetHolders() throws Exception {
try (var f = new Fixture(data)) {
var ordinary = new Actor(data); ordinary.core.inventory.setItemInOffHand(null);
@@ -904,6 +925,14 @@ class FantasyStickLifecycleNativeTest {
@Test void invalidCombatRewardConfigurationFailsBeforeNativeRegistrationWithoutRewritingConfig() throws Exception {
int scenario = 0;
for (String row : List.of("passive-xp: -1", "ordinary-xp: 1.5", "boss-xp: nope",
"hard-mob-xp: -1", "hard-mob-xp: 2147483648", "hard-mob-xp: 1.5",
"adventure-daily-site-count: 21", "adventure-site-radius: 3",
"adventure-site-reset-seconds: 601", "dungeon-room-link-distance: 0",
"dungeon-room-link-distance: 257", "adventure-camp-enemies: 3",
"adventure-camp-reward-xp: -1", "adventure-camp-reward-coins: 2147483648",
"adventure-champion-health: 0", "adventure-champion-health: 2049",
"adventure-champion-damage: 0", "adventure-champion-damage: 2049",
"adventure-champion-reward-xp: -1", "adventure-champion-reward-coins: 2147483648",
"restricted-kill-spacing-blocks: -0.1", "restricted-kill-spacing-blocks: 1e1000000")) {
var directory = data.resolve("invalid-reward-" + scenario++); java.nio.file.Files.createDirectories(directory);
String config = row + "\n"; java.nio.file.Files.writeString(directory.resolve("config.yml"), config);
@@ -1114,12 +1143,16 @@ class FantasyStickLifecycleNativeTest {
final List<Runnable> pulses = new ArrayList<>();
final List<Runnable> delayed = new ArrayList<>();
final List<Long> delayedTicks = new ArrayList<>();
final List<org.bukkit.boss.BossBar> bossBars = new ArrayList<>();
com.comphenix.protocol.events.PacketListener packetListener;
final List<java.util.concurrent.ExecutorService> workers = new ArrayList<>();
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 playerCommand;
org.bukkit.command.PluginCommand adminCommand;
RoutedPlayerCommand feedbackCommand;
RoutedPlayerCommand particlesCommand;
int playerLookups;
String failRegistrationAt;
boolean failRemoval;
@@ -1202,10 +1235,15 @@ class FantasyStickLifecycleNativeTest {
case "getWorld" -> players.values().stream().map(org.bukkit.entity.Player::getWorld)
.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" -> "fantasyfeedback".equals(args[0]) ? feedbackCommand : command;
case "getPluginCommand" -> switch ((String) args[0]) {
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])
: new org.bukkit.craftbukkit.inventory.CraftInventoryCustom((org.bukkit.inventory.InventoryHolder) args[0], (org.bukkit.event.inventory.InventoryType) args[1], (net.kyori.adventure.text.Component) args[2]);
case "createBossBar" -> { var bar=mock(org.bukkit.boss.BossBar.class);bossBars.add(bar);yield bar; }
case "addRecipe" -> {
order.add("recipe");
if ("recipe".equals(failRegistrationAt)) throw new IllegalStateException("Recipe registration failed");
@@ -1225,21 +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"));
}
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;
}
}
@@ -1288,7 +1349,8 @@ class FantasyStickLifecycleNativeTest {
assertTrue(actor.core.marker().endsWith("|PACKED"));
}
private void captureWorkers(JavaPlugin instance) {
for (String name : List.of("characters", "receipts", "adventureSettings", "revivals", "trainingDummies")) {
for (String name : List.of("characters", "receipts", "adventureSettings", "revivals", "trainingDummies",
"hardMobXpSettings", "adventureQuestSettings", "adventureQuests")) {
var store = field(instance, name);
if (store != null) {
var worker = (java.util.concurrent.ExecutorService) field(store, "worker");
@@ -1320,12 +1382,15 @@ class FantasyStickLifecycleNativeTest {
static final class Actor {
final EquipmentSessionsNativeTest.Actor core;
// Location holds a WeakReference: the fixture must model the server's world ownership.
final org.bukkit.World world = mock(org.bukkit.World.class);
final org.bukkit.craftbukkit.CraftWorld world = mock(org.bukkit.craftbukkit.CraftWorld.class);
final List<String> messages = new ArrayList<>();
final List<net.kyori.adventure.text.Component> messageComponents = new ArrayList<>();
final List<String> actionBars = new ArrayList<>();
final List<org.bukkit.inventory.ItemStack> books = new ArrayList<>();
final List<org.bukkit.inventory.Inventory> inventories = new ArrayList<>();
int inventoryCloses;
final List<Integer> particles = new ArrayList<>();
final List<Object[]> particleCalls = new ArrayList<>();
final List<org.bukkit.Sound> sounds = new ArrayList<>();
final List<net.kyori.adventure.title.Title> titles = new ArrayList<>();
final List<Object[]> resourcePacks = new ArrayList<>();
@@ -1337,6 +1402,10 @@ class FantasyStickLifecycleNativeTest {
healthAttributes.getInstance(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH));
final net.minecraft.world.food.FoodData food = new net.minecraft.world.food.FoodData();
boolean healthScaled;
boolean sprinting;
boolean allowed = true;
boolean visible = true;
String accountName = "Account";
double healthScale = 20;
double absorption;
Runnable scaleMutation = () -> {};
@@ -1366,11 +1435,12 @@ class FantasyStickLifecycleNativeTest {
case "equals" -> call.getMock() == args[0];
case "getHandle" -> nativePlayer;
case "getEntityId" -> 42;
case "getName" -> "Account";
case "getName" -> accountName;
case "hasPermission" -> allowed;
case "getWorld" -> core.location.getWorld();
case "getHeight" -> 1.8;
case "getTrackedBy" -> tracked;
case "canSee" -> true;
case "canSee" -> visible;
case "getAttribute" -> {
assertEquals(org.bukkit.attribute.Attribute.MAX_HEALTH, args[0]);
yield new org.bukkit.craftbukkit.attribute.CraftAttributeInstance(maximumHealth, org.bukkit.attribute.Attribute.MAX_HEALTH);
@@ -1399,13 +1469,27 @@ class FantasyStickLifecycleNativeTest {
if (!event.isCancelled()) { core.beforeMode.run(); core.gameMode = event.getNewGameMode(); }
yield null;
}
case "isSneaking", "isInvisible" -> false;
case "isSneaking", "isInvisible", "isDead" -> false;
case "isSprinting" -> sprinting;
case "setSprinting" -> { sprinting = (Boolean) args[0]; yield null; }
case "getScoreboard" -> craftBoard;
case "sendMessage" -> { messages.add((String) args[0]); yield null; }
case "sendMessage" -> {
if (args[0] instanceof net.kyori.adventure.text.Component component) {
messageComponents.add(component);
messages.add(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText()
.serialize(component));
} else messages.add((String) args[0]);
yield null;
}
case "sendActionBar" -> {
actionBars.add(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText()
.serialize((net.kyori.adventure.text.Component) args[0]));
yield null;
}
case "openBook" -> { books.add(((org.bukkit.inventory.ItemStack) args[0]).clone()); yield null; }
case "openInventory" -> { inventories.add((org.bukkit.inventory.Inventory) args[0]); yield core.view; }
case "closeInventory" -> { inventoryCloses++; yield null; }
case "spawnParticle" -> { particles.add((Integer) args[2]); yield null; }
case "spawnParticle" -> { particleCalls.add(args.clone()); particles.add((Integer) args[2]); yield null; }
case "playSound" -> { if (args.length > 1 && args[1] instanceof org.bukkit.Sound sound) sounds.add(sound); yield null; }
case "showTitle" -> { titles.add((net.kyori.adventure.title.Title)args[0]); yield null; }
case "addResourcePack" -> { resourcePacks.add(args.clone()); yield null; }
@@ -1431,6 +1515,8 @@ class FantasyStickLifecycleNativeTest {
when(nativePlayer.getAttribute(net.minecraft.world.entity.ai.attributes.Attributes.NAME_TAG_DISTANCE)).thenReturn(distance);
var level = mock(net.minecraft.server.level.ServerLevel.class);
when(nativePlayer.level()).thenReturn(level);
when(world.getHandle()).thenReturn(level);
when(level.dimension()).thenReturn(net.minecraft.world.level.Level.OVERWORLD);
var worldConfig = mock(io.papermc.paper.configuration.WorldConfiguration.class);
worldConfig.entities = allocate(io.papermc.paper.configuration.WorldConfiguration.Entities.class);
worldConfig.entities.spawning = allocate(io.papermc.paper.configuration.WorldConfiguration.Entities.Spawning.class);
@@ -49,7 +49,15 @@ class FireballNativeTest {
a.core.inventory.getInventory().setSelectedSlot(2);
var cast = new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
a.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
var viewer = new FantasyStickLifecycleNativeTest.Actor(data);
viewer.core.inventory.setItemInOffHand(null); viewer.core.location.setWorld(a.world);
f.players.put(viewer.core.id, viewer.player);
f.particlesCommand.execute(a.player, "fantasyparticles", new String[]{"all", "off"}); PartyBookNativeTest.drain(f);
a.particleCalls.clear(); viewer.particleCalls.clear();
f.dispatcher.accept(cast); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertTrue(a.particleCalls.isEmpty(), "The opted-out caster receives no Fireball particles");
assertTrue(viewer.particleCalls.stream().anyMatch(call -> call[0] == org.bukkit.Particle.FLAME
&& call[2].equals(8)), "An enabled nearby viewer retains the Fireball burst");
assertEquals(org.bukkit.event.Event.Result.DENY, cast.useItemInHand()); assertEquals(1, launches.get());
org.mockito.Mockito.verify(projectile).setVisualFire(true);
org.mockito.Mockito.verify(projectile).setPickupStatus(org.bukkit.entity.AbstractArrow.PickupStatus.DISALLOWED);
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff. Show more